diff --git a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java index 6aabf1c..d634c15 100644 --- a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java +++ b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java @@ -4,6 +4,7 @@ import com.yoyuzh.config.CquApiProperties; import com.yoyuzh.config.CorsProperties; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.JwtProperties; +import com.yoyuzh.config.AdminProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -13,7 +14,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties JwtProperties.class, FileStorageProperties.class, CquApiProperties.class, - CorsProperties.class + CorsProperties.class, + AdminProperties.class }) public class PortalBackendApplication { diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java b/backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java new file mode 100644 index 0000000..8be317b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminAccessEvaluator.java @@ -0,0 +1,27 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.config.AdminProperties; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class AdminAccessEvaluator { + + private final Set adminUsernames; + + public AdminAccessEvaluator(AdminProperties adminProperties) { + this.adminUsernames = adminProperties.getUsernames().stream() + .map(username -> username == null ? "" : username.trim()) + .filter(username -> !username.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + } + + public boolean isAdmin(Authentication authentication) { + return authentication != null + && authentication.isAuthenticated() + && adminUsernames.contains(authentication.getName()); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminController.java b/backend/src/main/java/com/yoyuzh/admin/AdminController.java new file mode 100644 index 0000000..9b4870d --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminController.java @@ -0,0 +1,82 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.common.ApiResponse; +import com.yoyuzh.common.PageResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +@PreAuthorize("@adminAccessEvaluator.isAdmin(authentication)") +public class AdminController { + + private final AdminService adminService; + + @GetMapping("/summary") + public ApiResponse summary() { + return ApiResponse.success(adminService.getSummary()); + } + + @GetMapping("/users") + public ApiResponse> users(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "") String query) { + return ApiResponse.success(adminService.listUsers(page, size, query)); + } + + @GetMapping("/files") + public ApiResponse> files(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "") String query, + @RequestParam(defaultValue = "") String ownerQuery) { + return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery)); + } + + @DeleteMapping("/files/{fileId}") + public ApiResponse deleteFile(@PathVariable Long fileId) { + adminService.deleteFile(fileId); + return ApiResponse.success(); + } + + @GetMapping("/school-snapshots") + public ApiResponse> schoolSnapshots( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ApiResponse.success(adminService.listSchoolSnapshots(page, size)); + } + + @PatchMapping("/users/{userId}/role") + public ApiResponse updateUserRole(@PathVariable Long userId, + @Valid @RequestBody AdminUserRoleUpdateRequest request) { + return ApiResponse.success(adminService.updateUserRole(userId, request.role())); + } + + @PatchMapping("/users/{userId}/status") + public ApiResponse updateUserStatus(@PathVariable Long userId, + @Valid @RequestBody AdminUserStatusUpdateRequest request) { + return ApiResponse.success(adminService.updateUserBanned(userId, request.banned())); + } + + @PutMapping("/users/{userId}/password") + public ApiResponse updateUserPassword(@PathVariable Long userId, + @Valid @RequestBody AdminUserPasswordUpdateRequest request) { + return ApiResponse.success(adminService.updateUserPassword(userId, request.newPassword())); + } + + @PostMapping("/users/{userId}/password/reset") + public ApiResponse resetUserPassword(@PathVariable Long userId) { + return ApiResponse.success(adminService.resetUserPassword(userId)); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminFileResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminFileResponse.java new file mode 100644 index 0000000..eb7b4c9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminFileResponse.java @@ -0,0 +1,17 @@ +package com.yoyuzh.admin; + +import java.time.LocalDateTime; + +public record AdminFileResponse( + Long id, + String filename, + String path, + long size, + String contentType, + boolean directory, + LocalDateTime createdAt, + Long ownerId, + String ownerUsername, + String ownerEmail +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminPasswordResetResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminPasswordResetResponse.java new file mode 100644 index 0000000..aa7f9d9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminPasswordResetResponse.java @@ -0,0 +1,4 @@ +package com.yoyuzh.admin; + +public record AdminPasswordResetResponse(String temporaryPassword) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminSchoolSnapshotResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminSchoolSnapshotResponse.java new file mode 100644 index 0000000..bc90717 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminSchoolSnapshotResponse.java @@ -0,0 +1,13 @@ +package com.yoyuzh.admin; + +public record AdminSchoolSnapshotResponse( + Long id, + Long userId, + String username, + String email, + String studentId, + String semester, + long scheduleCount, + long gradeCount +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java new file mode 100644 index 0000000..725d90e --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -0,0 +1,207 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.PasswordPolicy; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.auth.RefreshTokenService; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.cqu.CourseRepository; +import com.yoyuzh.cqu.GradeRepository; +import com.yoyuzh.files.FileService; +import com.yoyuzh.files.StoredFile; +import com.yoyuzh.files.StoredFileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final UserRepository userRepository; + private final StoredFileRepository storedFileRepository; + private final FileService fileService; + private final CourseRepository courseRepository; + private final GradeRepository gradeRepository; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenService refreshTokenService; + private final SecureRandom secureRandom = new SecureRandom(); + + public AdminSummaryResponse getSummary() { + return new AdminSummaryResponse( + userRepository.count(), + storedFileRepository.count(), + userRepository.countByLastSchoolStudentIdIsNotNull() + ); + } + + public PageResponse listUsers(int page, int size, String query) { + Page result = userRepository.searchByUsernameOrEmail( + normalizeQuery(query), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + List items = result.getContent().stream() + .map(this::toUserResponse) + .toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + } + + public PageResponse listFiles(int page, int size, String query, String ownerQuery) { + Page result = storedFileRepository.searchAdminFiles( + normalizeQuery(query), + normalizeQuery(ownerQuery), + PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "user.username") + .and(Sort.by(Sort.Direction.DESC, "createdAt"))) + ); + List items = result.getContent().stream() + .map(this::toFileResponse) + .toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + } + + public PageResponse listSchoolSnapshots(int page, int size) { + Page result = userRepository.findByLastSchoolStudentIdIsNotNull( + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + List items = result.getContent().stream() + .map(this::toSchoolSnapshotResponse) + .toList(); + return new PageResponse<>(items, result.getTotalElements(), page, size); + } + + @Transactional + public void deleteFile(Long fileId) { + StoredFile storedFile = storedFileRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在")); + fileService.delete(storedFile.getUser(), fileId); + } + + @Transactional + public AdminUserResponse updateUserRole(Long userId, UserRole role) { + User user = getRequiredUser(userId); + user.setRole(role); + return toUserResponse(userRepository.save(user)); + } + + @Transactional + public AdminUserResponse updateUserBanned(Long userId, boolean banned) { + User user = getRequiredUser(userId); + user.setBanned(banned); + refreshTokenService.revokeAllForUser(user.getId()); + return toUserResponse(userRepository.save(user)); + } + + @Transactional + public AdminUserResponse updateUserPassword(Long userId, String newPassword) { + if (!PasswordPolicy.isStrong(newPassword)) { + throw new BusinessException(ErrorCode.UNKNOWN, "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符"); + } + User user = getRequiredUser(userId); + user.setPasswordHash(passwordEncoder.encode(newPassword)); + refreshTokenService.revokeAllForUser(user.getId()); + return toUserResponse(userRepository.save(user)); + } + + @Transactional + public AdminPasswordResetResponse resetUserPassword(Long userId) { + String temporaryPassword = generateTemporaryPassword(); + updateUserPassword(userId, temporaryPassword); + return new AdminPasswordResetResponse(temporaryPassword); + } + + private AdminUserResponse toUserResponse(User user) { + return new AdminUserResponse( + user.getId(), + user.getUsername(), + user.getEmail(), + user.getCreatedAt(), + user.getLastSchoolStudentId(), + user.getLastSchoolSemester(), + user.getRole(), + user.isBanned() + ); + } + + private AdminFileResponse toFileResponse(StoredFile storedFile) { + User owner = storedFile.getUser(); + return new AdminFileResponse( + storedFile.getId(), + storedFile.getFilename(), + storedFile.getPath(), + storedFile.getSize(), + storedFile.getContentType(), + storedFile.isDirectory(), + storedFile.getCreatedAt(), + owner.getId(), + owner.getUsername(), + owner.getEmail() + ); + } + + private AdminSchoolSnapshotResponse toSchoolSnapshotResponse(User user) { + String studentId = user.getLastSchoolStudentId(); + String semester = user.getLastSchoolSemester(); + long scheduleCount = studentId == null || semester == null + ? 0 + : courseRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester); + long gradeCount = studentId == null || semester == null + ? 0 + : gradeRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester); + + return new AdminSchoolSnapshotResponse( + user.getId(), + user.getId(), + user.getUsername(), + user.getEmail(), + studentId, + semester, + scheduleCount, + gradeCount + ); + } + + private User getRequiredUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在")); + } + + private String normalizeQuery(String query) { + if (query == null) { + return ""; + } + return query.trim(); + } + + private String generateTemporaryPassword() { + String lowers = "abcdefghjkmnpqrstuvwxyz"; + String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ"; + String digits = "23456789"; + String specials = "!@#$%^&*"; + String all = lowers + uppers + digits + specials; + char[] password = new char[12]; + password[0] = lowers.charAt(secureRandom.nextInt(lowers.length())); + password[1] = uppers.charAt(secureRandom.nextInt(uppers.length())); + password[2] = digits.charAt(secureRandom.nextInt(digits.length())); + password[3] = specials.charAt(secureRandom.nextInt(specials.length())); + for (int i = 4; i < password.length; i += 1) { + password[i] = all.charAt(secureRandom.nextInt(all.length())); + } + for (int i = password.length - 1; i > 0; i -= 1) { + int j = secureRandom.nextInt(i + 1); + char tmp = password[i]; + password[i] = password[j]; + password[j] = tmp; + } + return new String(password); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java new file mode 100644 index 0000000..7df26a7 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java @@ -0,0 +1,8 @@ +package com.yoyuzh.admin; + +public record AdminSummaryResponse( + long totalUsers, + long totalFiles, + long usersWithSchoolCache +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserPasswordUpdateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserPasswordUpdateRequest.java new file mode 100644 index 0000000..e2c076b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserPasswordUpdateRequest.java @@ -0,0 +1,18 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.PasswordPolicy; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminUserPasswordUpdateRequest( + @NotBlank + @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + String newPassword +) { + + @AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + public boolean isPasswordStrong() { + return PasswordPolicy.isStrong(newPassword); + } +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java new file mode 100644 index 0000000..be78718 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java @@ -0,0 +1,17 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.UserRole; + +import java.time.LocalDateTime; + +public record AdminUserResponse( + Long id, + String username, + String email, + LocalDateTime createdAt, + String lastSchoolStudentId, + String lastSchoolSemester, + UserRole role, + boolean banned +) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserRoleUpdateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserRoleUpdateRequest.java new file mode 100644 index 0000000..06af960 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserRoleUpdateRequest.java @@ -0,0 +1,7 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.UserRole; +import jakarta.validation.constraints.NotNull; + +public record AdminUserRoleUpdateRequest(@NotNull UserRole role) { +} diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserStatusUpdateRequest.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserStatusUpdateRequest.java new file mode 100644 index 0000000..6670b12 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserStatusUpdateRequest.java @@ -0,0 +1,6 @@ +package com.yoyuzh.admin; + +import jakarta.validation.constraints.NotNull; + +public record AdminUserStatusUpdateRequest(@NotNull Boolean banned) { +} diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthService.java b/backend/src/main/java/com/yoyuzh/auth/AuthService.java index be08876..61ca277 100644 --- a/backend/src/main/java/com/yoyuzh/auth/AuthService.java +++ b/backend/src/main/java/com/yoyuzh/auth/AuthService.java @@ -3,21 +3,41 @@ package com.yoyuzh.auth; import com.yoyuzh.auth.dto.AuthResponse; import com.yoyuzh.auth.dto.LoginRequest; import com.yoyuzh.auth.dto.RegisterRequest; +import com.yoyuzh.auth.dto.UpdateUserAvatarRequest; +import com.yoyuzh.auth.dto.UpdateUserPasswordRequest; +import com.yoyuzh.auth.dto.UpdateUserProfileRequest; import com.yoyuzh.auth.dto.UserProfileResponse; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; import com.yoyuzh.files.FileService; +import com.yoyuzh.files.InitiateUploadResponse; +import com.yoyuzh.files.storage.FileContentStorage; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.UUID; @Service @RequiredArgsConstructor public class AuthService { + private static final String AVATAR_PATH = "/.avatar"; + private static final long MAX_AVATAR_SIZE = 5L * 1024 * 1024L; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @@ -25,6 +45,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; private final FileService fileService; + private final FileContentStorage fileContentStorage; @Transactional public AuthResponse register(RegisterRequest request) { @@ -37,8 +58,11 @@ public class AuthService { User user = new User(); user.setUsername(request.username()); + user.setDisplayName(request.username()); user.setEmail(request.email()); user.setPasswordHash(passwordEncoder.encode(request.password())); + user.setRole(UserRole.USER); + user.setPreferredLanguage("zh-CN"); User saved = userRepository.save(user); fileService.ensureDefaultDirectories(saved); return issueTokens(saved); @@ -48,6 +72,8 @@ public class AuthService { try { authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.username(), request.password())); + } catch (DisabledException ex) { + throw new BusinessException(ErrorCode.PERMISSION_DENIED, "账号已被封禁"); } catch (BadCredentialsException ex) { throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误"); } @@ -69,8 +95,11 @@ public class AuthService { User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> { User created = new User(); created.setUsername(finalCandidate); + created.setDisplayName(finalCandidate); created.setEmail(finalCandidate + "@dev.local"); created.setPasswordHash(passwordEncoder.encode("1")); + created.setRole(UserRole.USER); + created.setPreferredLanguage("zh-CN"); return userRepository.save(created); }); fileService.ensureDefaultDirectories(user); @@ -89,8 +118,139 @@ public class AuthService { return toProfile(user); } + @Transactional + public UserProfileResponse updateProfile(String username, UpdateUserProfileRequest request) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + + String nextEmail = request.email().trim(); + if (!user.getEmail().equalsIgnoreCase(nextEmail) && userRepository.existsByEmail(nextEmail)) { + throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在"); + } + + user.setDisplayName(request.displayName().trim()); + user.setEmail(nextEmail); + user.setBio(normalizeOptionalText(request.bio())); + user.setPreferredLanguage(normalizePreferredLanguage(request.preferredLanguage())); + return toProfile(userRepository.save(user)); + } + + @Transactional + public AuthResponse changePassword(String username, UpdateUserPasswordRequest request) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + + if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) { + throw new BusinessException(ErrorCode.UNKNOWN, "当前密码错误"); + } + + user.setPasswordHash(passwordEncoder.encode(request.newPassword())); + userRepository.save(user); + refreshTokenService.revokeAllForUser(user.getId()); + return issueTokens(user); + } + + public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + + validateAvatarUpload(request.filename(), request.contentType(), request.size()); + String storageName = normalizeAvatarStorageName(request.storageName(), request.filename(), request.contentType()); + + var preparedUpload = fileContentStorage.prepareUpload( + user.getId(), + AVATAR_PATH, + storageName, + request.contentType(), + request.size() + ); + + String uploadUrl = preparedUpload.direct() + ? preparedUpload.uploadUrl() + : "/api/user/avatar/upload?storageName=" + URLEncoder.encode(storageName, StandardCharsets.UTF_8); + + return new InitiateUploadResponse( + preparedUpload.direct(), + uploadUrl, + preparedUpload.direct() ? preparedUpload.method() : "POST", + preparedUpload.direct() ? preparedUpload.headers() : java.util.Map.of(), + storageName + ); + } + + public void uploadAvatar(String username, String storageName, MultipartFile file) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + + String normalizedStorageName = normalizeAvatarStorageName(storageName, file.getOriginalFilename(), file.getContentType()); + validateAvatarUpload(file.getOriginalFilename(), file.getContentType(), file.getSize()); + fileContentStorage.upload(user.getId(), AVATAR_PATH, normalizedStorageName, file); + } + + @Transactional + public UserProfileResponse completeAvatarUpload(String username, UpdateUserAvatarRequest request) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + + validateAvatarUpload(request.filename(), request.contentType(), request.size()); + String storageName = normalizeAvatarStorageName(request.storageName(), request.filename(), request.contentType()); + + fileContentStorage.completeUpload(user.getId(), AVATAR_PATH, storageName, request.contentType(), request.size()); + + String previousStorageName = user.getAvatarStorageName(); + if (StringUtils.hasText(previousStorageName) && !previousStorageName.equals(storageName)) { + fileContentStorage.deleteFile(user.getId(), AVATAR_PATH, previousStorageName); + } + + user.setAvatarStorageName(storageName); + user.setAvatarContentType(request.contentType()); + user.setAvatarUpdatedAt(LocalDateTime.now()); + return toProfile(userRepository.save(user)); + } + + public ResponseEntity getAvatarContent(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + + if (!StringUtils.hasText(user.getAvatarStorageName())) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "头像不存在"); + } + + String downloadName = buildAvatarDownloadName(user.getAvatarStorageName(), user.getAvatarContentType()); + if (fileContentStorage.supportsDirectDownload()) { + return ResponseEntity.status(302) + .location(URI.create(fileContentStorage.createDownloadUrl( + user.getId(), + AVATAR_PATH, + user.getAvatarStorageName(), + downloadName + ))) + .build(); + } + + byte[] content = fileContentStorage.readFile(user.getId(), AVATAR_PATH, user.getAvatarStorageName()); + String contentType = StringUtils.hasText(user.getAvatarContentType()) + ? user.getAvatarContentType() + : MediaType.APPLICATION_OCTET_STREAM_VALUE; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename*=UTF-8''" + URLEncoder.encode(downloadName, StandardCharsets.UTF_8)) + .contentType(MediaType.parseMediaType(contentType)) + .body(content); + } + private UserProfileResponse toProfile(User user) { - return new UserProfileResponse(user.getId(), user.getUsername(), user.getEmail(), user.getCreatedAt()); + return new UserProfileResponse( + user.getId(), + user.getUsername(), + user.getDisplayName(), + user.getEmail(), + user.getBio(), + user.getPreferredLanguage(), + buildAvatarUrl(user), + user.getRole(), + user.getCreatedAt() + ); } private AuthResponse issueTokens(User user) { @@ -101,4 +261,89 @@ public class AuthService { String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername()); return AuthResponse.issued(accessToken, refreshToken, toProfile(user)); } + + private String normalizeOptionalText(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizePreferredLanguage(String preferredLanguage) { + if (preferredLanguage == null || preferredLanguage.trim().isEmpty()) { + return "zh-CN"; + } + return preferredLanguage.trim(); + } + + private void validateAvatarUpload(String filename, String contentType, long size) { + if (!StringUtils.hasText(filename)) { + throw new BusinessException(ErrorCode.UNKNOWN, "头像文件名不能为空"); + } + if (!StringUtils.hasText(contentType) || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) { + throw new BusinessException(ErrorCode.UNKNOWN, "头像仅支持图片文件"); + } + if (size <= 0 || size > MAX_AVATAR_SIZE) { + throw new BusinessException(ErrorCode.UNKNOWN, "头像大小不能超过 5MB"); + } + } + + private String normalizeAvatarStorageName(String requestedStorageName, String filename, String contentType) { + String candidate = StringUtils.hasText(requestedStorageName) + ? requestedStorageName.trim() + : "avatar-" + UUID.randomUUID() + resolveAvatarExtension(filename, contentType); + candidate = candidate.replace("\\", "/"); + if (candidate.contains("/")) { + candidate = candidate.substring(candidate.lastIndexOf('/') + 1); + } + if (!StringUtils.hasText(candidate)) { + throw new BusinessException(ErrorCode.UNKNOWN, "头像文件名不合法"); + } + return candidate; + } + + private String resolveAvatarExtension(String filename, String contentType) { + if (StringUtils.hasText(filename)) { + int dot = filename.lastIndexOf('.'); + if (dot >= 0 && dot < filename.length() - 1) { + String extension = filename.substring(dot).toLowerCase(Locale.ROOT); + if (extension.matches("\\.[a-z0-9]{1,8}")) { + return extension; + } + } + } + + return switch (contentType.toLowerCase(Locale.ROOT)) { + case "image/jpeg" -> ".jpg"; + case "image/webp" -> ".webp"; + case "image/gif" -> ".gif"; + default -> ".png"; + }; + } + + private String buildAvatarUrl(User user) { + if (!StringUtils.hasText(user.getAvatarStorageName())) { + return null; + } + + if (fileContentStorage.supportsDirectDownload()) { + return fileContentStorage.createDownloadUrl( + user.getId(), + AVATAR_PATH, + user.getAvatarStorageName(), + buildAvatarDownloadName(user.getAvatarStorageName(), user.getAvatarContentType()) + ); + } + + long version = user.getAvatarUpdatedAt() == null ? 0L : user.getAvatarUpdatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli(); + return "/user/avatar/content?v=" + version; + } + + private String buildAvatarDownloadName(String storageName, String contentType) { + if (StringUtils.hasText(storageName) && storageName.contains(".")) { + return storageName; + } + return "avatar" + resolveAvatarExtension(storageName, contentType == null ? "image/png" : contentType); + } } diff --git a/backend/src/main/java/com/yoyuzh/auth/CustomUserDetailsService.java b/backend/src/main/java/com/yoyuzh/auth/CustomUserDetailsService.java index d74894f..bfbc0ed 100644 --- a/backend/src/main/java/com/yoyuzh/auth/CustomUserDetailsService.java +++ b/backend/src/main/java/com/yoyuzh/auth/CustomUserDetailsService.java @@ -20,7 +20,8 @@ public class CustomUserDetailsService implements UserDetailsService { .orElseThrow(() -> new UsernameNotFoundException("用户不存在")); return org.springframework.security.core.userdetails.User.withUsername(user.getUsername()) .password(user.getPasswordHash()) - .authorities("ROLE_USER") + .authorities("ROLE_" + user.getRole().name()) + .disabled(user.isBanned()) .build(); } diff --git a/backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java b/backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java new file mode 100644 index 0000000..6088c95 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java @@ -0,0 +1,33 @@ +package com.yoyuzh.auth; + +public final class PasswordPolicy { + + private PasswordPolicy() { + } + + public static boolean isStrong(String password) { + if (password == null || password.length() < 10) { + return false; + } + + boolean hasLower = false; + boolean hasUpper = false; + boolean hasDigit = false; + boolean hasSpecial = false; + + for (int i = 0; i < password.length(); i += 1) { + char c = password.charAt(i); + if (Character.isLowerCase(c)) { + hasLower = true; + } else if (Character.isUpperCase(c)) { + hasUpper = true; + } else if (Character.isDigit(c)) { + hasDigit = true; + } else { + hasSpecial = true; + } + } + + return hasLower && hasUpper && hasDigit && hasSpecial; + } +} diff --git a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java index f4aecba..12f7165 100644 --- a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java +++ b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenRepository.java @@ -3,8 +3,11 @@ package com.yoyuzh.auth; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.Optional; public interface RefreshTokenRepository extends JpaRepository { @@ -12,4 +15,12 @@ public interface RefreshTokenRepository extends JpaRepository findForUpdateByTokenHash(String tokenHash); + + @Modifying + @Query(""" + update RefreshToken token + set token.revoked = true, token.revokedAt = :revokedAt + where token.user.id = :userId and token.revoked = false + """) + int revokeAllActiveByUserId(@Param("userId") Long userId, @Param("revokedAt") LocalDateTime revokedAt); } diff --git a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java index 72426a7..bf892de 100644 --- a/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java +++ b/backend/src/main/java/com/yoyuzh/auth/RefreshTokenService.java @@ -60,6 +60,11 @@ public class RefreshTokenService { return new RotatedRefreshToken(user, nextRefreshToken); } + @Transactional + public void revokeAllForUser(Long userId) { + refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now()); + } + private String generateRawToken() { byte[] bytes = new byte[REFRESH_TOKEN_BYTES]; secureRandom.nextBytes(bytes); diff --git a/backend/src/main/java/com/yoyuzh/auth/User.java b/backend/src/main/java/com/yoyuzh/auth/User.java index b45fa91..c7e91e4 100644 --- a/backend/src/main/java/com/yoyuzh/auth/User.java +++ b/backend/src/main/java/com/yoyuzh/auth/User.java @@ -2,6 +2,8 @@ package com.yoyuzh.auth; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -41,11 +43,45 @@ public class User { @Column(name = "last_school_semester", length = 64) private String lastSchoolSemester; + @Column(name = "display_name", nullable = false, length = 64) + private String displayName; + + @Column(length = 280) + private String bio; + + @Column(name = "preferred_language", nullable = false, length = 16) + private String preferredLanguage; + + @Column(name = "avatar_storage_name", length = 255) + private String avatarStorageName; + + @Column(name = "avatar_content_type", length = 128) + private String avatarContentType; + + @Column(name = "avatar_updated_at") + private LocalDateTime avatarUpdatedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private UserRole role; + + @Column(nullable = false) + private boolean banned; + @PrePersist public void prePersist() { if (createdAt == null) { createdAt = LocalDateTime.now(); } + if (role == null) { + role = UserRole.USER; + } + if (displayName == null || displayName.isBlank()) { + displayName = username; + } + if (preferredLanguage == null || preferredLanguage.isBlank()) { + preferredLanguage = "zh-CN"; + } } public Long getId() { @@ -103,4 +139,68 @@ public class User { public void setLastSchoolSemester(String lastSchoolSemester) { this.lastSchoolSemester = lastSchoolSemester; } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getBio() { + return bio; + } + + public void setBio(String bio) { + this.bio = bio; + } + + public String getPreferredLanguage() { + return preferredLanguage; + } + + public void setPreferredLanguage(String preferredLanguage) { + this.preferredLanguage = preferredLanguage; + } + + public String getAvatarStorageName() { + return avatarStorageName; + } + + public void setAvatarStorageName(String avatarStorageName) { + this.avatarStorageName = avatarStorageName; + } + + public String getAvatarContentType() { + return avatarContentType; + } + + public void setAvatarContentType(String avatarContentType) { + this.avatarContentType = avatarContentType; + } + + public LocalDateTime getAvatarUpdatedAt() { + return avatarUpdatedAt; + } + + public void setAvatarUpdatedAt(LocalDateTime avatarUpdatedAt) { + this.avatarUpdatedAt = avatarUpdatedAt; + } + + public UserRole getRole() { + return role; + } + + public void setRole(UserRole role) { + this.role = role; + } + + public boolean isBanned() { + return banned; + } + + public void setBanned(boolean banned) { + this.banned = banned; + } } diff --git a/backend/src/main/java/com/yoyuzh/auth/UserController.java b/backend/src/main/java/com/yoyuzh/auth/UserController.java index 7284359..6980f0c 100644 --- a/backend/src/main/java/com/yoyuzh/auth/UserController.java +++ b/backend/src/main/java/com/yoyuzh/auth/UserController.java @@ -2,12 +2,23 @@ package com.yoyuzh.auth; import com.yoyuzh.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import com.yoyuzh.auth.dto.UpdateUserAvatarRequest; +import com.yoyuzh.auth.dto.UpdateUserPasswordRequest; +import com.yoyuzh.auth.dto.UpdateUserProfileRequest; @RestController @RequestMapping("/api/user") @@ -21,4 +32,47 @@ public class UserController { public ApiResponse profile(@AuthenticationPrincipal UserDetails userDetails) { return ApiResponse.success(authService.getProfile(userDetails.getUsername())); } + + @Operation(summary = "更新用户资料") + @PutMapping("/profile") + public ApiResponse updateProfile(@AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody UpdateUserProfileRequest request) { + return ApiResponse.success(authService.updateProfile(userDetails.getUsername(), request)); + } + + @Operation(summary = "修改当前用户密码") + @PostMapping("/password") + public ApiResponse changePassword(@AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody UpdateUserPasswordRequest request) { + return ApiResponse.success(authService.changePassword(userDetails.getUsername(), request)); + } + + @Operation(summary = "初始化头像上传") + @PostMapping("/avatar/upload/initiate") + public ApiResponse initiateAvatarUpload(@AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody UpdateUserAvatarRequest request) { + return ApiResponse.success(authService.initiateAvatarUpload(userDetails.getUsername(), request)); + } + + @Operation(summary = "代理上传头像") + @PostMapping("/avatar/upload") + public ApiResponse uploadAvatar(@AuthenticationPrincipal UserDetails userDetails, + @RequestParam String storageName, + @RequestPart("file") MultipartFile file) { + authService.uploadAvatar(userDetails.getUsername(), storageName, file); + return ApiResponse.success(); + } + + @Operation(summary = "完成头像上传") + @PostMapping("/avatar/upload/complete") + public ApiResponse completeAvatarUpload(@AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody UpdateUserAvatarRequest request) { + return ApiResponse.success(authService.completeAvatarUpload(userDetails.getUsername(), request)); + } + + @Operation(summary = "获取当前用户头像") + @GetMapping("/avatar/content") + public ResponseEntity avatarContent(@AuthenticationPrincipal UserDetails userDetails) { + return authService.getAvatarContent(userDetails.getUsername()); + } } diff --git a/backend/src/main/java/com/yoyuzh/auth/UserRepository.java b/backend/src/main/java/com/yoyuzh/auth/UserRepository.java index 0fe4561..82445e8 100644 --- a/backend/src/main/java/com/yoyuzh/auth/UserRepository.java +++ b/backend/src/main/java/com/yoyuzh/auth/UserRepository.java @@ -1,6 +1,10 @@ package com.yoyuzh.auth; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -10,4 +14,16 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByUsername(String username); + + long countByLastSchoolStudentIdIsNotNull(); + + Page findByLastSchoolStudentIdIsNotNull(Pageable pageable); + + @Query(""" + select u from User u + where (:query is null or :query = '' + or lower(u.username) like lower(concat('%', :query, '%')) + or lower(u.email) like lower(concat('%', :query, '%'))) + """) + Page searchByUsernameOrEmail(@Param("query") String query, Pageable pageable); } diff --git a/backend/src/main/java/com/yoyuzh/auth/UserRole.java b/backend/src/main/java/com/yoyuzh/auth/UserRole.java new file mode 100644 index 0000000..0c1b828 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/UserRole.java @@ -0,0 +1,7 @@ +package com.yoyuzh.auth; + +public enum UserRole { + USER, + MODERATOR, + ADMIN +} diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java index 0725188..d319562 100644 --- a/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java +++ b/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java @@ -1,5 +1,6 @@ package com.yoyuzh.auth.dto; +import com.yoyuzh.auth.PasswordPolicy; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; @@ -13,28 +14,6 @@ public record RegisterRequest( @AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") public boolean isPasswordStrong() { - if (password == null || password.length() < 10) { - return false; - } - - boolean hasLower = false; - boolean hasUpper = false; - boolean hasDigit = false; - boolean hasSpecial = false; - - for (int i = 0; i < password.length(); i += 1) { - char c = password.charAt(i); - if (Character.isLowerCase(c)) { - hasLower = true; - } else if (Character.isUpperCase(c)) { - hasUpper = true; - } else if (Character.isDigit(c)) { - hasDigit = true; - } else { - hasSpecial = true; - } - } - - return hasLower && hasUpper && hasDigit && hasSpecial; + return PasswordPolicy.isStrong(password); } } diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserAvatarRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserAvatarRequest.java new file mode 100644 index 0000000..37983a4 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserAvatarRequest.java @@ -0,0 +1,13 @@ +package com.yoyuzh.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record UpdateUserAvatarRequest( + @NotBlank @Size(max = 255) String filename, + @NotBlank @Size(max = 128) String contentType, + @Positive long size, + @Size(max = 255) String storageName +) { +} diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserPasswordRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserPasswordRequest.java new file mode 100644 index 0000000..e2a3127 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserPasswordRequest.java @@ -0,0 +1,19 @@ +package com.yoyuzh.auth.dto; + +import com.yoyuzh.auth.PasswordPolicy; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateUserPasswordRequest( + @NotBlank String currentPassword, + @NotBlank + @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + String newPassword +) { + + @AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") + public boolean isPasswordStrong() { + return PasswordPolicy.isStrong(newPassword); + } +} diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserProfileRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserProfileRequest.java new file mode 100644 index 0000000..120a6c1 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserProfileRequest.java @@ -0,0 +1,13 @@ +package com.yoyuzh.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateUserProfileRequest( + @NotBlank @Size(min = 2, max = 64) String displayName, + @NotBlank @Email @Size(max = 128) String email, + @Size(max = 280) String bio, + @Size(min = 2, max = 16) String preferredLanguage +) { +} diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java b/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java index 55e5159..ff3279a 100644 --- a/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java +++ b/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java @@ -1,6 +1,21 @@ package com.yoyuzh.auth.dto; +import com.yoyuzh.auth.UserRole; + import java.time.LocalDateTime; -public record UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) { +public record UserProfileResponse( + Long id, + String username, + String displayName, + String email, + String bio, + String preferredLanguage, + String avatarUrl, + UserRole role, + LocalDateTime createdAt +) { + public UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) { + this(id, username, username, email, null, "zh-CN", null, UserRole.USER, createdAt); + } } diff --git a/backend/src/main/java/com/yoyuzh/config/AdminProperties.java b/backend/src/main/java/com/yoyuzh/config/AdminProperties.java new file mode 100644 index 0000000..eb8e233 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/AdminProperties.java @@ -0,0 +1,20 @@ +package com.yoyuzh.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(prefix = "app.admin") +public class AdminProperties { + + private List usernames = new ArrayList<>(); + + public List getUsernames() { + return usernames; + } + + public void setUsernames(List usernames) { + this.usernames = usernames; + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java b/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java index 254e0a5..1a98318 100644 --- a/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/yoyuzh/config/JwtAuthenticationFilter.java @@ -34,6 +34,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { && SecurityContextHolder.getContext().getAuthentication() == null) { String username = jwtTokenProvider.getUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if (!userDetails.isEnabled()) { + filterChain.doFilter(request, response); + return; + } UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); diff --git a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java index 5189daa..fdc301a 100644 --- a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java @@ -47,6 +47,8 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() + .requestMatchers("/api/admin/**") + .authenticated() .requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**") .authenticated() .anyRequest() diff --git a/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java b/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java index adf7b1e..0453b2f 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java +++ b/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java @@ -11,4 +11,6 @@ public interface CourseRepository extends JpaRepository { Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); + + long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); } diff --git a/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java b/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java index 54be404..6ae4f6d 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java +++ b/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java @@ -13,4 +13,6 @@ public interface GradeRepository extends JpaRepository { Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); + + long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); } diff --git a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java index c43fb55..cfebdb0 100644 --- a/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java +++ b/backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java @@ -2,6 +2,7 @@ package com.yoyuzh.files; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -10,6 +11,24 @@ import java.util.List; public interface StoredFileRepository extends JpaRepository { + @EntityGraph(attributePaths = "user") + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + @EntityGraph(attributePaths = "user") + @Query(""" + select f from StoredFile f + join f.user u + where (:query is null or :query = '' + or lower(f.filename) like lower(concat('%', :query, '%')) + or lower(f.path) like lower(concat('%', :query, '%'))) + and (:ownerQuery is null or :ownerQuery = '' + or lower(u.username) like lower(concat('%', :ownerQuery, '%')) + or lower(u.email) like lower(concat('%', :ownerQuery, '%'))) + """) + Page searchAdminFiles(@Param("query") String query, + @Param("ownerQuery") String ownerQuery, + Pageable pageable); + @Query(""" select case when count(f) > 0 then true else false end from StoredFile f diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index eb238dd..8e61361 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -15,5 +15,7 @@ spring: app: jwt: secret: ${APP_JWT_SECRET:} + admin: + usernames: ${APP_ADMIN_USERNAMES:} cqu: mock-enabled: true diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e26cacf..18b334b 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -26,6 +26,8 @@ app: secret: ${APP_JWT_SECRET:} access-expiration-seconds: 900 refresh-expiration-seconds: 1209600 + admin: + usernames: ${APP_ADMIN_USERNAMES:} storage: root-dir: ./storage max-file-size: 524288000 diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java new file mode 100644 index 0000000..0f19e01 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -0,0 +1,182 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.PortalBackendApplication; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.files.StoredFile; +import com.yoyuzh.files.StoredFileRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest( + classes = PortalBackendApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:admin_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "app.jwt.secret=0123456789abcdef0123456789abcdef", + "app.admin.usernames=admin", + "app.storage.root-dir=./target/test-storage-admin", + "app.cqu.require-login=true", + "app.cqu.mock-enabled=false" + } +) +@AutoConfigureMockMvc +class AdminControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StoredFileRepository storedFileRepository; + + private User portalUser; + private User secondaryUser; + private StoredFile storedFile; + private StoredFile secondaryFile; + + @BeforeEach + void setUp() { + storedFileRepository.deleteAll(); + userRepository.deleteAll(); + + portalUser = new User(); + portalUser.setUsername("alice"); + portalUser.setEmail("alice@example.com"); + portalUser.setPasswordHash("encoded-password"); + portalUser.setCreatedAt(LocalDateTime.now()); + portalUser.setLastSchoolStudentId("20230001"); + portalUser.setLastSchoolSemester("2025-2026-1"); + portalUser = userRepository.save(portalUser); + + secondaryUser = new User(); + secondaryUser.setUsername("bob"); + secondaryUser.setEmail("bob@example.com"); + secondaryUser.setPasswordHash("encoded-password"); + secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1)); + secondaryUser = userRepository.save(secondaryUser); + + storedFile = new StoredFile(); + storedFile.setUser(portalUser); + storedFile.setFilename("report.pdf"); + storedFile.setPath("/"); + storedFile.setStorageName("report.pdf"); + storedFile.setContentType("application/pdf"); + storedFile.setSize(1024L); + storedFile.setDirectory(false); + storedFile.setCreatedAt(LocalDateTime.now()); + storedFile = storedFileRepository.save(storedFile); + + secondaryFile = new StoredFile(); + secondaryFile.setUser(secondaryUser); + secondaryFile.setFilename("notes.txt"); + secondaryFile.setPath("/docs"); + secondaryFile.setStorageName("notes.txt"); + secondaryFile.setContentType("text/plain"); + secondaryFile.setSize(256L); + secondaryFile.setDirectory(false); + secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2)); + secondaryFile = storedFileRepository.save(secondaryFile); + } + + @Test + @WithMockUser(username = "admin") + void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception { + mockMvc.perform(get("/api/admin/users?page=0&size=10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].username").value("alice")) + .andExpect(jsonPath("$.data.items[0].lastSchoolStudentId").value("20230001")) + .andExpect(jsonPath("$.data.items[0].role").value("USER")) + .andExpect(jsonPath("$.data.items[0].banned").value(false)); + + mockMvc.perform(get("/api/admin/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalUsers").value(2)) + .andExpect(jsonPath("$.data.totalFiles").value(2)) + .andExpect(jsonPath("$.data.usersWithSchoolCache").value(1)); + } + + @Test + @WithMockUser(username = "admin") + void shouldSupportUserSearchPasswordAndStatusManagement() throws Exception { + mockMvc.perform(get("/api/admin/users?page=0&size=10&query=ali")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].username").value("alice")); + + mockMvc.perform(patch("/api/admin/users/{userId}/role", portalUser.getId()) + .contentType("application/json") + .content(""" + {"role":"ADMIN"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + + mockMvc.perform(patch("/api/admin/users/{userId}/status", portalUser.getId()) + .contentType("application/json") + .content(""" + {"banned":true} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.banned").value(true)); + + mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId()) + .contentType("application/json") + .content(""" + {"newPassword":"AdminSetPass1!"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(portalUser.getId())); + + mockMvc.perform(post("/api/admin/users/{userId}/password/reset", secondaryUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.temporaryPassword").isNotEmpty()); + } + + @Test + @WithMockUser(username = "admin") + void shouldAllowConfiguredAdminToListAndDeleteFiles() throws Exception { + mockMvc.perform(get("/api/admin/files?page=0&size=10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].filename").value("report.pdf")) + .andExpect(jsonPath("$.data.items[0].ownerUsername").value("alice")); + + mockMvc.perform(get("/api/admin/files?page=0&size=10&query=report&ownerQuery=ali")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].filename").value("report.pdf")); + + mockMvc.perform(delete("/api/admin/files/{fileId}", storedFile.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + @WithMockUser(username = "portal-user") + void shouldRejectNonAdminUser() throws Exception { + mockMvc.perform(get("/api/admin/users?page=0&size=10")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.msg").value("没有权限访问该资源")); + } +} diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java index e263e29..1317315 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java @@ -3,8 +3,14 @@ package com.yoyuzh.auth; import com.yoyuzh.auth.dto.AuthResponse; import com.yoyuzh.auth.dto.LoginRequest; import com.yoyuzh.auth.dto.RegisterRequest; +import com.yoyuzh.auth.dto.UpdateUserAvatarRequest; +import com.yoyuzh.auth.dto.UpdateUserPasswordRequest; +import com.yoyuzh.auth.dto.UpdateUserProfileRequest; import com.yoyuzh.common.BusinessException; import com.yoyuzh.files.FileService; +import com.yoyuzh.files.InitiateUploadResponse; +import com.yoyuzh.files.storage.FileContentStorage; +import com.yoyuzh.files.storage.PreparedUpload; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -12,6 +18,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,6 +28,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,6 +54,9 @@ class AuthServiceTest { @Mock private FileService fileService; + @Mock + private FileContentStorage fileContentStorage; + @InjectMocks private AuthService authService; @@ -137,6 +149,17 @@ class AuthServiceTest { .hasMessageContaining("用户名或密码错误"); } + @Test + void shouldRejectBannedUserLogin() { + LoginRequest request = new LoginRequest("alice", "plain-password"); + when(authenticationManager.authenticate(any())) + .thenThrow(new DisabledException("disabled")); + + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("账号已被封禁"); + } + @Test void shouldCreateDefaultDirectoriesForDevLoginUser() { when(userRepository.findByUsername("demo")).thenReturn(Optional.empty()); @@ -157,4 +180,128 @@ class AuthServiceTest { assertThat(response.refreshToken()).isEqualTo("refresh-token"); verify(fileService).ensureDefaultDirectories(any(User.class)); } + + @Test + void shouldUpdateCurrentUserProfile() { + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + user.setDisplayName("Alice"); + user.setEmail("alice@example.com"); + user.setBio("old bio"); + user.setPreferredLanguage("zh-CN"); + user.setRole(UserRole.USER); + user.setCreatedAt(LocalDateTime.now()); + + UpdateUserProfileRequest request = new UpdateUserProfileRequest( + "Alicia", + "newalice@example.com", + "new bio", + "en-US" + ); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(userRepository.existsByEmail("newalice@example.com")).thenReturn(false); + when(userRepository.save(user)).thenReturn(user); + + var response = authService.updateProfile("alice", request); + + assertThat(response.displayName()).isEqualTo("Alicia"); + assertThat(response.email()).isEqualTo("newalice@example.com"); + assertThat(response.bio()).isEqualTo("new bio"); + assertThat(response.preferredLanguage()).isEqualTo("en-US"); + } + + @Test + void shouldChangePasswordAndIssueFreshTokens() { + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + user.setDisplayName("Alice"); + user.setEmail("alice@example.com"); + user.setPreferredLanguage("zh-CN"); + user.setRole(UserRole.USER); + user.setPasswordHash("encoded-old"); + user.setCreatedAt(LocalDateTime.now()); + + UpdateUserPasswordRequest request = new UpdateUserPasswordRequest("OldPass1!", "NewPass1!A"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true); + when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new"); + when(userRepository.save(user)).thenReturn(user); + when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("new-access"); + when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh"); + + AuthResponse response = authService.changePassword("alice", request); + + assertThat(response.accessToken()).isEqualTo("new-access"); + assertThat(response.refreshToken()).isEqualTo("new-refresh"); + verify(refreshTokenService).revokeAllForUser(1L); + verify(passwordEncoder).encode("NewPass1!A"); + } + + @Test + void shouldRejectPasswordChangeWhenCurrentPasswordIsWrong() { + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + user.setPasswordHash("encoded-old"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("WrongPass1!", "encoded-old")).thenReturn(false); + + assertThatThrownBy(() -> authService.changePassword("alice", new UpdateUserPasswordRequest("WrongPass1!", "NewPass1!A"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("当前密码错误"); + } + + @Test + void shouldInitiateAvatarUploadThroughStorage() { + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(fileContentStorage.prepareUpload(eq(1L), eq("/.avatar"), any(), eq("image/png"), eq(2048L))) + .thenReturn(new PreparedUpload(true, "https://upload.example.com/avatar", "PUT", java.util.Map.of("Content-Type", "image/png"), "avatar-generated.png")); + + InitiateUploadResponse response = authService.initiateAvatarUpload( + "alice", + new UpdateUserAvatarRequest("face.png", "image/png", 2048L, "avatar-generated.png") + ); + + assertThat(response.direct()).isTrue(); + assertThat(response.uploadUrl()).isEqualTo("https://upload.example.com/avatar"); + assertThat(response.storageName()).endsWith(".png"); + } + + @Test + void shouldCompleteAvatarUploadAndReplacePreviousAvatar() { + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + user.setDisplayName("Alice"); + user.setEmail("alice@example.com"); + user.setPreferredLanguage("zh-CN"); + user.setRole(UserRole.USER); + user.setAvatarStorageName("old-avatar.png"); + user.setAvatarContentType("image/png"); + user.setCreatedAt(LocalDateTime.now()); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(fileContentStorage.supportsDirectDownload()).thenReturn(true); + when(fileContentStorage.createDownloadUrl(anyLong(), eq("/.avatar"), eq("new-avatar.webp"), any())) + .thenReturn("https://cdn.example.com/avatar.webp"); + when(userRepository.save(user)).thenReturn(user); + + var response = authService.completeAvatarUpload( + "alice", + new UpdateUserAvatarRequest("face.webp", "image/webp", 4096L, "new-avatar.webp") + ); + + verify(fileContentStorage).completeUpload(1L, "/.avatar", "new-avatar.webp", "image/webp", 4096L); + verify(fileContentStorage).deleteFile(1L, "/.avatar", "old-avatar.png"); + assertThat(response.avatarUrl()).isEqualTo("https://cdn.example.com/avatar.webp"); + } } diff --git a/front/package-lock.json b/front/package-lock.json index bb79d48..c8c008e 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -8,7 +8,11 @@ "name": "react-example", "version": "0.0.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@google/genai": "^1.29.0", + "@mui/icons-material": "^7.3.9", + "@mui/material": "^7.3.9", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "better-sqlite3": "^12.4.1", @@ -19,6 +23,7 @@ "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", + "react-admin": "^5.14.4", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", "tailwind-merge": "^3.5.0", @@ -252,6 +257,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -297,6 +311,158 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -798,6 +964,239 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.9.tgz", + "integrity": "sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.9.tgz", + "integrity": "sha512-BT+zPJXss8Hg/oEMRmHl17Q97bPACG4ufFSfGEdhiE96jOyR5Dz1ty7ZWt1fVGR0y1p+sSgEwQT/MNZQmoWDCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.9", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.9.tgz", + "integrity": "sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.9", + "@mui/system": "^7.3.9", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.9", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.9", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.9.tgz", + "integrity": "sha512-ErIyRQvsiQEq7Yvcvfw9UDHngaqjMy9P3JDPnRAaKG5qhpl2C4tX/W1S4zJvpu+feihmZJStjIyvnv6KDbIrlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.9", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.9.tgz", + "integrity": "sha512-JqujWt5bX4okjUPGpVof/7pvgClqh7HvIbsIBIOOlCh2u3wG/Bwp4+E1bc1dXSwkrkp9WUAoNdI5HEC+5HKvMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.9.tgz", + "integrity": "sha512-aL1q9am8XpRrSabv9qWf5RHhJICJql34wnrc1nz0MuOglPRYF/liN+c8VqZdTvUn9qg+ZjRVbKf4sJVFfIDtmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.9", + "@mui/styled-engine": "^7.3.9", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.9", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", + "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -808,6 +1207,16 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1460,6 +1869,32 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/query-core": { + "version": "5.91.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.0.tgz", + "integrity": "sha512-FYXN8Kk9Q5VKuV6AIVaNwMThSi0nvAtR4X7HQoigf6ePOtFcavJYVIzgFhOVdtbBQtCJE3KimDIMMJM2DR1hjw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.91.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.0.tgz", + "integrity": "sha512-S8FODsDTNv0Ym+o/JVBvA6EWiWVhg6K2Q4qFehZyFKk6uW4H9OPbXl4kyiN9hAly0uHJ/1GEbR6kAI4MZWfjEA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.91.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1577,6 +2012,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -1591,6 +2038,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -1630,6 +2096,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -1702,6 +2175,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -1739,6 +2221,30 @@ "postcss": "^8.1.0" } }, + "node_modules/autosuggest-highlight": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/autosuggest-highlight/-/autosuggest-highlight-3.3.4.tgz", + "integrity": "sha512-j6RETBD2xYnrVcoV1S5R4t3WxOlWZKyDQjkwnggDPSjF5L4jV98ZltBpvPvbkM1HtoSe5o+bNrTHyjPbieGeYA==", + "license": "MIT", + "dependencies": { + "remove-accents": "^0.4.2" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1940,6 +2446,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1969,6 +2493,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001777", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", @@ -2064,6 +2597,31 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2078,6 +2636,18 @@ "node": ">= 8" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", + "license": "BSD" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2114,6 +2684,15 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2138,6 +2717,40 @@ "node": ">=4.0.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2166,6 +2779,31 @@ "node": ">=8" } }, + "node_modules/diacritic": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/diacritic/-/diacritic-0.0.2.tgz", + "integrity": "sha512-iQCeDkSPwkfwWPr+HZZ49WRrM2FSI9097Q9w7agyRCdLcF9Eh2Ek0sHKcmMWx2oZVBjRBE/sziGFjZu0uf1Jbg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -2256,6 +2894,15 @@ "node": ">=10.13.0" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2343,6 +2990,18 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2352,6 +3011,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2468,12 +3133,33 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2507,6 +3193,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2782,6 +3474,18 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2806,6 +3510,21 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2871,6 +3590,31 @@ ], "license": "BSD-3-Clause" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflection": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", + "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2892,6 +3636,27 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2958,6 +3723,12 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2970,6 +3741,15 @@ "node": ">=6" } }, + "node_modules/jsonexport": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonexport/-/jsonexport-3.2.0.tgz", + "integrity": "sha512-GbO9ugb0YTZatPd/hqCGR0FSwbr82H6OzG04yzdrG7XOe4QZ0jhQ+kOsB29zqkzoYJLmLxbbrFiuwbQu891XnQ==", + "license": "Apache-2.0", + "bin": { + "jsonexport": "bin/jsonexport.js" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3240,12 +4020,36 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3535,12 +4339,35 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-polyglot": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.6.0.tgz", + "integrity": "sha512-ZZFkaYzIfGfBvSM6QhA9dM8EEaUJOVewzGSRcXWbJELXDj0lajAtKaENCYxvF5yE+TgHg6NQb0CmgYMsMdcNJQ==", + "license": "BSD-2-Clause", + "dependencies": { + "hasown": "^2.0.2", + "object.entries": "^1.1.8", + "warning": "^4.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3553,6 +4380,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3593,6 +4444,36 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3611,6 +4492,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -3639,6 +4526,15 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3719,6 +4615,23 @@ "node": ">=10" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -3781,6 +4694,113 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ra-core": { + "version": "5.14.4", + "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-5.14.4.tgz", + "integrity": "sha512-kbZPQiZqyV/cz25kH5+CZ3PFzDMonqV1zBYSkIwdowBm8bFOqHYu4u5xj/R5N6Kb/x64gQButGCTmL+78uP7hg==", + "license": "MIT", + "dependencies": { + "date-fns": "^3.6.0", + "eventemitter3": "^5.0.1", + "inflection": "^3.0.0", + "jsonexport": "^3.2.0", + "lodash": "^4.17.21", + "query-string": "^7.1.3", + "react-error-boundary": "^4.0.13", + "react-is": "^18.2.0 || ^19.0.0" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.83.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "react-hook-form": "^7.65.0", + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" + } + }, + "node_modules/ra-core/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/ra-i18n-polyglot": { + "version": "5.14.4", + "resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-5.14.4.tgz", + "integrity": "sha512-ssEmnII1sEujEhzMPRabvb4Ivlh/F9AOVcsWT5O1ks6fdEj6414K+/rsiPcAKpSYgeViWowMw+HRh92gsYylMA==", + "license": "MIT", + "dependencies": { + "node-polyglot": "^2.2.2", + "ra-core": "^5.14.4" + } + }, + "node_modules/ra-language-english": { + "version": "5.14.4", + "resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-5.14.4.tgz", + "integrity": "sha512-DlLh6Kn5wrwwNfd7sGT8xUMzVwUMBesAIgf+y7p/TDcLsqyPkpBCH9hhBAW+s5SW8L0/ERuyrENvIV1dk0azvg==", + "license": "MIT", + "dependencies": { + "ra-core": "^5.14.4" + } + }, + "node_modules/ra-ui-materialui": { + "version": "5.14.4", + "resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-5.14.4.tgz", + "integrity": "sha512-Gb4GPfhAGoR+2i/KjjjRwAZq84YbQqMB2lMaCxHxtCngcGsE1ljEj4aMVAyzBnr1G2Z4ht3eBUTirfwE2VpBjQ==", + "license": "MIT", + "dependencies": { + "autosuggest-highlight": "^3.1.1", + "clsx": "^2.1.1", + "css-mediaquery": "^0.1.2", + "diacritic": "^0.0.2", + "dompurify": "^3.2.4", + "inflection": "^3.0.0", + "jsonexport": "^3.2.0", + "lodash": "~4.17.5", + "query-string": "^7.1.3", + "react-dropzone": "^14.2.3", + "react-error-boundary": "^4.0.13", + "react-hotkeys-hook": "^5.1.0", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "@mui/icons-material": "^5.16.12 || ^6.0.0 || ^7.0.0", + "@mui/material": "^5.16.12 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.20 || ^6.0.0 || ^7.0.0", + "@mui/utils": "^5.15.20 || ^6.0.0 || ^7.0.0", + "@tanstack/react-query": "^5.83.0", + "csstype": "^3.1.3", + "ra-core": "^5.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "react-hook-form": "*", + "react-is": "^18.0.0 || ^19.0.0", + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3829,6 +4849,30 @@ "node": ">=0.10.0" } }, + "node_modules/react-admin": { + "version": "5.14.4", + "resolved": "https://registry.npmjs.org/react-admin/-/react-admin-5.14.4.tgz", + "integrity": "sha512-XKnANy0KU0nHP2sytzFjxzjw7NG8sxjuVn9KVITs/+Z8LRaMMh+D7WOpqinv86uMfMo3VQjLcbjC1IT10MpPXg==", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^5.16.12 || ^6.0.0 || ^7.0.0", + "@mui/material": "^5.16.12 || ^6.0.0 || ^7.0.0", + "@tanstack/react-query": "^5.83.0", + "ra-core": "^5.14.4", + "ra-i18n-polyglot": "^5.14.4", + "ra-language-english": "^5.14.4", + "ra-ui-materialui": "^5.14.4", + "react-hook-form": "^7.65.0", + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -3841,6 +4885,67 @@ "react": "^19.2.4" } }, + "node_modules/react-dropzone": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-error-boundary": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", + "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-hotkeys-hook": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.2.4.tgz", + "integrity": "sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3901,6 +5006,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3915,6 +5036,41 @@ "node": ">= 6" } }, + "node_modules/remove-accents": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.4.tgz", + "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4094,6 +5250,23 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4250,6 +5423,15 @@ "simple-concat": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4259,6 +5441,15 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4268,6 +5459,15 @@ "node": ">= 0.8" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4382,6 +5582,24 @@ "node": ">=0.10.0" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -5129,6 +6347,15 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/front/package.json b/front/package.json index e9da3f2..8f8f6d5 100644 --- a/front/package.json +++ b/front/package.json @@ -12,7 +12,11 @@ "test": "node --import tsx --test src/**/*.test.ts" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@google/genai": "^1.29.0", + "@mui/icons-material": "^7.3.9", + "@mui/material": "^7.3.9", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "better-sqlite3": "^12.4.1", @@ -23,6 +27,7 @@ "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", + "react-admin": "^5.14.4", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", "tailwind-merge": "^3.5.0", diff --git a/front/src/App.tsx b/front/src/App.tsx index e6c4d4e..3b84353 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { Layout } from './components/layout/Layout'; import { useAuth } from './auth/AuthProvider'; @@ -8,6 +8,8 @@ import Files from './pages/Files'; import School from './pages/School'; import Games from './pages/Games'; +const PortalAdminApp = React.lazy(() => import('./admin/AdminApp')); + function AppRoutes() { const { ready, session } = useAuth(); @@ -37,6 +39,24 @@ function AppRoutes() { } /> } /> + + 正在加载后台管理台... + + } + > + + + ) : ( + + ) + } + /> } diff --git a/front/src/admin/AdminApp.tsx b/front/src/admin/AdminApp.tsx new file mode 100644 index 0000000..40fa573 --- /dev/null +++ b/front/src/admin/AdminApp.tsx @@ -0,0 +1,47 @@ +import FolderOutlined from '@mui/icons-material/FolderOutlined'; +import GroupsOutlined from '@mui/icons-material/GroupsOutlined'; +import SchoolOutlined from '@mui/icons-material/SchoolOutlined'; +import { Admin, Resource } from 'react-admin'; + +import { portalAdminAuthProvider } from './auth-provider'; +import { portalAdminDataProvider } from './data-provider'; +import { PortalAdminDashboard } from './dashboard'; +import { PortalAdminFilesList } from './files-list'; +import { PortalAdminUsersList } from './users-list'; +import { PortalAdminSchoolSnapshotsList } from './school-snapshots-list'; + +export default function PortalAdminApp() { + return ( + + + + + + ); +} diff --git a/front/src/admin/auth-provider.test.ts b/front/src/admin/auth-provider.test.ts new file mode 100644 index 0000000..7dd2c95 --- /dev/null +++ b/front/src/admin/auth-provider.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { AuthSession } from '@/src/lib/types'; + +import { buildAdminIdentity, hasAdminSession, portalAdminAuthProvider } from './auth-provider'; + +const session: AuthSession = { + token: 'token-123', + refreshToken: 'refresh-123', + user: { + id: 7, + username: 'alice', + email: 'alice@example.com', + createdAt: '2026-03-19T15:00:00', + }, +}; + +test('hasAdminSession returns true only when a token is present', () => { + assert.equal(hasAdminSession(session), true); + assert.equal(hasAdminSession({...session, token: ''}), false); + assert.equal(hasAdminSession(null), false); +}); + +test('buildAdminIdentity maps the portal session user to react-admin identity', () => { + assert.deepEqual(buildAdminIdentity(session), { + id: '7', + fullName: 'alice', + }); +}); + +test('checkError keeps the session when admin API returns 403', async () => { + await assert.doesNotReject(() => portalAdminAuthProvider.checkError?.({status: 403})); +}); + +test('checkError rejects when admin API returns 401', async () => { + await assert.rejects(() => portalAdminAuthProvider.checkError?.({status: 401})); +}); diff --git a/front/src/admin/auth-provider.ts b/front/src/admin/auth-provider.ts new file mode 100644 index 0000000..6e66d65 --- /dev/null +++ b/front/src/admin/auth-provider.ts @@ -0,0 +1,50 @@ +import type { AuthProvider, UserIdentity } from 'react-admin'; + +import { clearStoredSession, readStoredSession } from '@/src/lib/session'; +import type { AuthSession } from '@/src/lib/types'; + +export function hasAdminSession(session: AuthSession | null | undefined) { + return Boolean(session?.token?.trim()); +} + +export function buildAdminIdentity(session: AuthSession): UserIdentity { + return { + id: String(session.user.id), + fullName: session.user.username, + }; +} + +export const portalAdminAuthProvider: AuthProvider = { + login: async () => { + throw new Error('请先使用门户登录页完成登录'); + }, + logout: async () => { + clearStoredSession(); + return '/login'; + }, + checkAuth: async () => { + if (!hasAdminSession(readStoredSession())) { + throw new Error('当前没有可用登录状态'); + } + }, + checkError: async (error) => { + const status = error?.status; + if (status === 401) { + clearStoredSession(); + throw new Error('登录状态已失效'); + } + + if (status === 403) { + return; + } + }, + getIdentity: async () => { + const session = readStoredSession(); + if (!session) { + throw new Error('当前没有可用登录状态'); + } + + return buildAdminIdentity(session); + }, + getPermissions: async () => [], +}; diff --git a/front/src/admin/dashboard.tsx b/front/src/admin/dashboard.tsx new file mode 100644 index 0000000..7cf725f --- /dev/null +++ b/front/src/admin/dashboard.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react'; +import { Alert, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material'; + +import { apiRequest } from '@/src/lib/api'; +import { readStoredSession } from '@/src/lib/session'; +import type { AdminSummary } from '@/src/lib/types'; + +interface DashboardState { + summary: AdminSummary | null; +} + +const DASHBOARD_ITEMS = [ + { + title: '文件资源', + description: '已接入 /api/admin/files 与 /api/admin/files/{id} 删除接口,可查看全站文件元数据。', + status: 'connected', + }, + { + title: '用户管理', + description: '已接入 /api/admin/users,可查看用户、邮箱与最近教务缓存标记。', + status: 'connected', + }, + { + title: '教务快照', + description: '已接入 /api/admin/school-snapshots,可查看最近学号、学期和缓存条数。', + status: 'connected', + }, +]; + +export function PortalAdminDashboard() { + const [state, setState] = useState({ + summary: null, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const session = readStoredSession(); + + useEffect(() => { + let active = true; + + async function loadDashboardData() { + setLoading(true); + setError(''); + + try { + const summary = await apiRequest('/admin/summary'); + + if (!active) { + return; + } + + setState({ + summary, + }); + } catch (requestError) { + if (!active) { + return; + } + + setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败'); + } finally { + if (active) { + setLoading(false); + } + } + } + + loadDashboardData(); + + return () => { + active = false; + }; + }, []); + + return ( + + + + YOYUZH Admin + + + 这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。 + + + + {loading && ( + + + 正在加载后台数据... + + )} + + {error && {error}} + + + {DASHBOARD_ITEMS.map((item) => ( + + + + + + + {item.title} + + + {item.description} + + + + + + ))} + + + + + + + + + 当前管理员 + + + 用户名:{session?.user.username ?? '-'} + + + 邮箱:{session?.user.email ?? '-'} + + + 用户 ID:{session?.user.id ?? '-'} + + + + + + + + + + + + 后台汇总 + + + 用户总数:{state.summary?.totalUsers ?? 0} + + + 文件总数:{state.summary?.totalFiles ?? 0} + + + 有教务缓存的用户:{state.summary?.usersWithSchoolCache ?? 0} + + + + + + + + ); +} diff --git a/front/src/admin/data-provider.test.ts b/front/src/admin/data-provider.test.ts new file mode 100644 index 0000000..822c2db --- /dev/null +++ b/front/src/admin/data-provider.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { AdminFile, PageResponse } from '@/src/lib/types'; + +import { + buildAdminListPath, + buildFilesListPath, + mapFilesListResponse, +} from './data-provider'; + +test('buildFilesListPath maps react-admin pagination to the backend files list query', () => { + assert.equal( + buildFilesListPath({ + pagination: { + page: 3, + perPage: 25, + }, + filter: {}, + }), + '/admin/files?page=2&size=25', + ); +}); + +test('buildFilesListPath includes file and owner search filters when present', () => { + assert.equal( + buildFilesListPath({ + pagination: { + page: 1, + perPage: 25, + }, + filter: { + query: 'report', + ownerQuery: 'alice', + }, + }), + '/admin/files?page=0&size=25&query=report&ownerQuery=alice', + ); +}); + +test('mapFilesListResponse preserves list items and total count', () => { + const payload: PageResponse = { + items: [ + { + id: 1, + filename: 'hello.txt', + path: '/', + size: 12, + contentType: 'text/plain', + directory: false, + createdAt: '2026-03-19T15:00:00', + ownerId: 7, + ownerUsername: 'alice', + ownerEmail: 'alice@example.com', + }, + ], + total: 1, + page: 0, + size: 25, + }; + + assert.deepEqual(mapFilesListResponse(payload), { + data: payload.items, + total: 1, + }); +}); + +test('buildAdminListPath maps generic admin resources to backend paging queries', () => { + assert.equal( + buildAdminListPath('users', { + pagination: { + page: 2, + perPage: 20, + }, + filter: {}, + }), + '/admin/users?page=1&size=20', + ); + + assert.equal( + buildAdminListPath('schoolSnapshots', { + pagination: { + page: 1, + perPage: 50, + }, + filter: {}, + }), + '/admin/school-snapshots?page=0&size=50', + ); +}); + +test('buildAdminListPath includes the user search query when present', () => { + assert.equal( + buildAdminListPath('users', { + pagination: { + page: 1, + perPage: 25, + }, + filter: { + query: 'alice', + }, + }), + '/admin/users?page=0&size=25&query=alice', + ); +}); diff --git a/front/src/admin/data-provider.ts b/front/src/admin/data-provider.ts new file mode 100644 index 0000000..65d4473 --- /dev/null +++ b/front/src/admin/data-provider.ts @@ -0,0 +1,144 @@ +import type { DataProvider, GetListParams, GetListResult, Identifier } from 'react-admin'; + +import { apiRequest } from '@/src/lib/api'; +import type { + AdminFile, + AdminSchoolSnapshot, + AdminUser, + PageResponse, +} from '@/src/lib/types'; + +const FILES_RESOURCE = 'files'; +const USERS_RESOURCE = 'users'; +const SCHOOL_SNAPSHOTS_RESOURCE = 'schoolSnapshots'; + +function createUnsupportedError(resource: string, action: string) { + return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`); +} + +function ensureSupportedResource(resource: string, action: string) { + if (![FILES_RESOURCE, USERS_RESOURCE, SCHOOL_SNAPSHOTS_RESOURCE].includes(resource)) { + throw createUnsupportedError(resource, action); + } +} + +function normalizeFilterValue(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function buildAdminListPath(resource: string, params: Pick) { + const page = Math.max(0, params.pagination.page - 1); + const size = Math.max(1, params.pagination.perPage); + const query = normalizeFilterValue(params.filter?.query); + + if (resource === USERS_RESOURCE) { + return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`; + } + + if (resource === SCHOOL_SNAPSHOTS_RESOURCE) { + return `/admin/school-snapshots?page=${page}&size=${size}`; + } + + throw createUnsupportedError(resource, 'list'); +} + +export function buildFilesListPath(params: Pick) { + const page = Math.max(0, params.pagination.page - 1); + const size = Math.max(1, params.pagination.perPage); + const query = normalizeFilterValue(params.filter?.query); + const ownerQuery = normalizeFilterValue(params.filter?.ownerQuery); + const search = new URLSearchParams({ + page: String(page), + size: String(size), + }); + if (query) { + search.set('query', query); + } + if (ownerQuery) { + search.set('ownerQuery', ownerQuery); + } + return `/admin/files?${search.toString()}`; +} + +export function mapFilesListResponse( + payload: PageResponse, +): GetListResult { + return { + data: payload.items, + total: payload.total, + }; +} + +async function deleteFile(id: Identifier) { + await apiRequest(`/admin/files/${id}`, { + method: 'DELETE', + }); +} + +export const portalAdminDataProvider: DataProvider = { + getList: async (resource, params) => { + ensureSupportedResource(resource, 'list'); + + if (resource === FILES_RESOURCE) { + const payload = await apiRequest>(buildFilesListPath(params)); + return mapFilesListResponse(payload) as GetListResult; + } + + if (resource === USERS_RESOURCE) { + const payload = await apiRequest>(buildAdminListPath(resource, params)); + return { + data: payload.items, + total: payload.total, + } as GetListResult; + } + + const payload = await apiRequest>(buildAdminListPath(resource, params)); + return { + data: payload.items, + total: payload.total, + } as GetListResult; + }, + getOne: async (resource) => { + ensureSupportedResource(resource, 'getOne'); + throw createUnsupportedError(resource, 'getOne'); + }, + getMany: async (resource) => { + ensureSupportedResource(resource, 'getMany'); + throw createUnsupportedError(resource, 'getMany'); + }, + getManyReference: async (resource) => { + ensureSupportedResource(resource, 'getManyReference'); + throw createUnsupportedError(resource, 'getManyReference'); + }, + update: async (resource) => { + ensureSupportedResource(resource, 'update'); + throw createUnsupportedError(resource, 'update'); + }, + updateMany: async (resource) => { + ensureSupportedResource(resource, 'updateMany'); + throw createUnsupportedError(resource, 'updateMany'); + }, + create: async (resource) => { + ensureSupportedResource(resource, 'create'); + throw createUnsupportedError(resource, 'create'); + }, + delete: async (resource, params) => { + if (resource !== FILES_RESOURCE) { + throw createUnsupportedError(resource, 'delete'); + } + await deleteFile(params.id); + const fallbackRecord = { id: params.id } as typeof params.previousData; + return { + data: (params.previousData ?? fallbackRecord) as typeof params.previousData, + }; + }, + deleteMany: async (resource, params) => { + if (resource !== FILES_RESOURCE) { + throw createUnsupportedError(resource, 'deleteMany'); + } + await Promise.all(params.ids.map((id) => deleteFile(id))); + return { + data: params.ids, + }; + }, +}; diff --git a/front/src/admin/files-list.tsx b/front/src/admin/files-list.tsx new file mode 100644 index 0000000..a8904d5 --- /dev/null +++ b/front/src/admin/files-list.tsx @@ -0,0 +1,69 @@ +import { Chip } from '@mui/material'; +import { + Datagrid, + DateField, + DeleteWithConfirmButton, + FunctionField, + List, + RefreshButton, + SearchInput, + TextField, + TopToolbar, +} from 'react-admin'; + +import type { AdminFile } from '@/src/lib/types'; + +function FilesListActions() { + return ( + + + + ); +} + +function formatFileSize(size: number) { + if (size >= 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + } + if (size >= 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${size} B`; +} + +export function PortalAdminFilesList() { + return ( + } + filters={[ + , + , + ]} + perPage={25} + resource="files" + title="文件管理" + sort={{ field: 'createdAt', order: 'DESC' }} + > + + + + + + + + label="类型" + render={(record) => + record.directory ? : + } + /> + + label="大小" + render={(record) => (record.directory ? '-' : formatFileSize(record.size))} + /> + + + + + + ); +} diff --git a/front/src/admin/school-snapshots-list.tsx b/front/src/admin/school-snapshots-list.tsx new file mode 100644 index 0000000..e1e3678 --- /dev/null +++ b/front/src/admin/school-snapshots-list.tsx @@ -0,0 +1,22 @@ +import { Datagrid, List, NumberField, TextField } from 'react-admin'; + +export function PortalAdminSchoolSnapshotsList() { + return ( + + + + + + + + + + + + ); +} diff --git a/front/src/admin/users-list.tsx b/front/src/admin/users-list.tsx new file mode 100644 index 0000000..6b158eb --- /dev/null +++ b/front/src/admin/users-list.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { Button, Chip, Stack } from '@mui/material'; +import { + Datagrid, + DateField, + FunctionField, + List, + SearchInput, + TextField, + TopToolbar, + RefreshButton, + useNotify, + useRefresh, +} from 'react-admin'; + +import { apiRequest } from '@/src/lib/api'; +import type { AdminPasswordResetResponse, AdminUser, AdminUserRole } from '@/src/lib/types'; + +const USER_ROLE_OPTIONS: AdminUserRole[] = ['USER', 'MODERATOR', 'ADMIN']; + +function UsersListActions() { + return ( + + + + ); +} + +function AdminUserActions({ record }: { record: AdminUser }) { + const notify = useNotify(); + const refresh = useRefresh(); + const [busy, setBusy] = useState(false); + + async function handleRoleAssign() { + const input = window.prompt('请输入角色:USER / MODERATOR / ADMIN', record.role); + if (!input) { + return; + } + const role = input.trim().toUpperCase() as AdminUserRole; + if (!USER_ROLE_OPTIONS.includes(role)) { + notify('角色必须是 USER、MODERATOR 或 ADMIN', { type: 'warning' }); + return; + } + + setBusy(true); + try { + await apiRequest(`/admin/users/${record.id}/role`, { + method: 'PATCH', + body: { role }, + }); + notify(`已将 ${record.username} 设为 ${role}`, { type: 'success' }); + refresh(); + } catch (error) { + notify(error instanceof Error ? error.message : '角色更新失败', { type: 'error' }); + } finally { + setBusy(false); + } + } + + async function handleToggleBan() { + const nextBanned = !record.banned; + const confirmed = window.confirm( + nextBanned ? `确认封禁用户 ${record.username} 吗?` : `确认解封用户 ${record.username} 吗?`, + ); + if (!confirmed) { + return; + } + + setBusy(true); + try { + await apiRequest(`/admin/users/${record.id}/status`, { + method: 'PATCH', + body: { banned: nextBanned }, + }); + notify(nextBanned ? '用户已封禁' : '用户已解封', { type: 'success' }); + refresh(); + } catch (error) { + notify(error instanceof Error ? error.message : '状态更新失败', { type: 'error' }); + } finally { + setBusy(false); + } + } + + async function handleSetPassword() { + const newPassword = window.prompt( + '请输入新密码。密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符。', + ); + if (!newPassword) { + return; + } + + setBusy(true); + try { + await apiRequest(`/admin/users/${record.id}/password`, { + method: 'PUT', + body: { newPassword }, + }); + notify('密码已更新,旧 refresh token 已失效', { type: 'success' }); + } catch (error) { + notify(error instanceof Error ? error.message : '密码更新失败', { type: 'error' }); + } finally { + setBusy(false); + } + } + + async function handleResetPassword() { + const confirmed = window.confirm(`确认重置 ${record.username} 的密码吗?`); + if (!confirmed) { + return; + } + + setBusy(true); + try { + const result = await apiRequest(`/admin/users/${record.id}/password/reset`, { + method: 'POST', + }); + notify('已生成临时密码,请立即复制并安全发送给用户', { type: 'success' }); + window.prompt(`用户 ${record.username} 的临时密码如下,请复制保存`, result.temporaryPassword); + } catch (error) { + notify(error instanceof Error ? error.message : '密码重置失败', { type: 'error' }); + } finally { + setBusy(false); + } + } + + return ( + + + + + + + ); +} + +export function PortalAdminUsersList() { + return ( + } + filters={[]} + perPage={25} + resource="users" + title="用户管理" + sort={{ field: 'createdAt', order: 'DESC' }} + > + + + + + + label="角色" + render={(record) => } + /> + + label="状态" + render={(record) => ( + + )} + /> + + + + label="操作" render={(record) => } /> + + + ); +} diff --git a/front/src/auth/AuthProvider.tsx b/front/src/auth/AuthProvider.tsx index f22fd39..7148e95 100644 --- a/front/src/auth/AuthProvider.tsx +++ b/front/src/auth/AuthProvider.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { apiRequest } from '@/src/lib/api'; +import { fetchAdminAccessStatus } from './admin-access'; import { clearStoredSession, createSession, @@ -19,6 +20,7 @@ interface AuthContextValue { ready: boolean; session: AuthSession | null; user: UserProfile | null; + isAdmin: boolean; login: (payload: LoginPayload) => Promise; devLogin: (username?: string) => Promise; logout: () => void; @@ -34,6 +36,7 @@ function buildSession(auth: AuthResponse): AuthSession { export function AuthProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = useState(() => readStoredSession()); const [ready, setReady] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); useEffect(() => { const syncSession = () => { @@ -93,6 +96,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; }, []); + useEffect(() => { + let active = true; + + async function syncAdminAccess() { + if (!session?.token) { + if (active) { + setIsAdmin(false); + } + return; + } + + try { + const allowed = await fetchAdminAccessStatus(); + if (active) { + setIsAdmin(allowed); + } + } catch { + if (active) { + setIsAdmin(false); + } + } + } + + syncAdminAccess(); + + return () => { + active = false; + }; + }, [session?.token]); + async function refreshProfile() { const currentSession = readStoredSession(); if (!currentSession) { @@ -146,6 +179,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ready, session, user: session?.user || null, + isAdmin, login, devLogin, logout, diff --git a/front/src/auth/admin-access.test.ts b/front/src/auth/admin-access.test.ts new file mode 100644 index 0000000..928a2d2 --- /dev/null +++ b/front/src/auth/admin-access.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ApiError } from '@/src/lib/api'; + +import { fetchAdminAccessStatus } from './admin-access'; + +test('fetchAdminAccessStatus returns true when the admin summary request succeeds', async () => { + const request = async () => ({ + totalUsers: 1, + totalFiles: 2, + usersWithSchoolCache: 3, + }); + + await assert.doesNotReject(async () => { + const allowed = await fetchAdminAccessStatus(request); + assert.equal(allowed, true); + }); +}); + +test('fetchAdminAccessStatus returns false when the server rejects the user with 403', async () => { + const request = async () => { + throw new ApiError('没有后台权限', 403); + }; + + const allowed = await fetchAdminAccessStatus(request); + assert.equal(allowed, false); +}); diff --git a/front/src/auth/admin-access.ts b/front/src/auth/admin-access.ts new file mode 100644 index 0000000..cec3c51 --- /dev/null +++ b/front/src/auth/admin-access.ts @@ -0,0 +1,19 @@ +import { ApiError, apiRequest } from '@/src/lib/api'; +import type { AdminSummary } from '@/src/lib/types'; + +type AdminSummaryRequest = () => Promise; + +export async function fetchAdminAccessStatus( + request: AdminSummaryRequest = () => apiRequest('/admin/summary'), +) { + try { + await request(); + return true; + } catch (error) { + if (error instanceof ApiError && error.status === 403) { + return false; + } + + throw error; + } +} diff --git a/front/src/components/layout/Layout.test.ts b/front/src/components/layout/Layout.test.ts new file mode 100644 index 0000000..0869b66 --- /dev/null +++ b/front/src/components/layout/Layout.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getVisibleNavItems } from './Layout'; + +test('getVisibleNavItems hides the admin entry for non-admin users', () => { + assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false); +}); + +test('getVisibleNavItems keeps the admin entry for admin users', () => { + assert.equal(getVisibleNavItems(true).some((item) => item.path === '/admin'), true); +}); diff --git a/front/src/components/layout/Layout.tsx b/front/src/components/layout/Layout.tsx index a540060..5dedfeb 100644 --- a/front/src/components/layout/Layout.tsx +++ b/front/src/components/layout/Layout.tsx @@ -1,38 +1,333 @@ -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, Outlet, useNavigate } from 'react-router-dom'; -import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react'; +import { + Gamepad2, + FolderOpen, + GraduationCap, + Key, + LayoutDashboard, + LogOut, + Mail, + Settings, + Shield, + Smartphone, + X, +} from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; -import { clearStoredSession } from '@/src/lib/session'; +import { useAuth } from '@/src/auth/AuthProvider'; +import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api'; +import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session'; +import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; +import { Button } from '@/src/components/ui/button'; +import { Input } from '@/src/components/ui/input'; + +import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils'; const NAV_ITEMS = [ { name: '总览', path: '/overview', icon: LayoutDashboard }, { name: '网盘', path: '/files', icon: FolderOpen }, { name: '教务', path: '/school', icon: GraduationCap }, { name: '游戏', path: '/games', icon: Gamepad2 }, -]; + { name: '后台', path: '/admin', icon: Shield }, +] as const; + +type ActiveModal = 'security' | 'settings' | null; + +export function getVisibleNavItems(isAdmin: boolean) { + return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin'); +} export function Layout() { const navigate = useNavigate(); + const { isAdmin, logout, refreshProfile, user } = useAuth(); + const navItems = getVisibleNavItems(isAdmin); + const fileInputRef = useRef(null); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [activeModal, setActiveModal] = useState(null); + const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null); + const [selectedAvatarFile, setSelectedAvatarFile] = useState(null); + const [avatarSourceUrl, setAvatarSourceUrl] = useState(user?.avatarUrl ?? null); + const [profileDraft, setProfileDraft] = useState(() => + buildAccountDraft( + user ?? { + id: 0, + username: '', + email: '', + createdAt: '', + }, + ), + ); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [profileMessage, setProfileMessage] = useState(''); + const [passwordMessage, setPasswordMessage] = useState(''); + const [profileError, setProfileError] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [profileSubmitting, setProfileSubmitting] = useState(false); + const [passwordSubmitting, setPasswordSubmitting] = useState(false); + + useEffect(() => { + if (!user) { + return; + } + setProfileDraft(buildAccountDraft(user)); + }, [user]); + + useEffect(() => { + if (!avatarPreviewUrl) { + return undefined; + } + + return () => { + URL.revokeObjectURL(avatarPreviewUrl); + }; + }, [avatarPreviewUrl]); + + useEffect(() => { + let active = true; + let objectUrl: string | null = null; + + async function syncAvatar() { + if (!user?.avatarUrl) { + if (active) { + setAvatarSourceUrl(null); + } + return; + } + + if (!shouldLoadAvatarWithAuth(user.avatarUrl)) { + if (active) { + setAvatarSourceUrl(user.avatarUrl); + } + return; + } + + try { + const response = await apiDownload(user.avatarUrl); + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + if (active) { + setAvatarSourceUrl(objectUrl); + } + } catch { + if (active) { + setAvatarSourceUrl(null); + } + } + } + + void syncAvatar(); + + return () => { + active = false; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [user?.avatarUrl]); + + const displayName = useMemo(() => { + if (!user) { + return '账户'; + } + return user.displayName || user.username; + }, [user]); + + const email = user?.email || '暂无邮箱'; + const roleLabel = getRoleLabel(user?.role); + const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase(); + const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl; const handleLogout = () => { - clearStoredSession(); + logout(); navigate('/login'); }; + const handleAvatarClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + setSelectedAvatarFile(file); + setAvatarPreviewUrl((current) => { + if (current) { + URL.revokeObjectURL(current); + } + return URL.createObjectURL(file); + }); + }; + + const handleProfileDraftChange = (field: keyof typeof profileDraft, value: string) => { + setProfileDraft((current) => ({ + ...current, + [field]: value, + })); + }; + + const closeModal = () => { + setActiveModal(null); + setProfileMessage(''); + setProfileError(''); + setPasswordMessage(''); + setPasswordError(''); + }; + + const persistSessionUser = (nextProfile: UserProfile) => { + const currentSession = readStoredSession(); + if (!currentSession) { + return; + } + + saveStoredSession({ + ...currentSession, + user: nextProfile, + }); + }; + + const uploadAvatar = async (file: File) => { + const initiated = await apiRequest('/user/avatar/upload/initiate', { + method: 'POST', + body: { + filename: file.name, + contentType: file.type || 'image/png', + size: file.size, + }, + }); + + if (initiated.direct) { + try { + await apiBinaryUploadRequest(initiated.uploadUrl, { + method: initiated.method, + headers: initiated.headers, + body: file, + }); + } catch { + const formData = new FormData(); + formData.append('file', file); + await apiUploadRequest(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, { + body: formData, + }); + } + } else { + const formData = new FormData(); + formData.append('file', file); + await apiUploadRequest(initiated.uploadUrl, { + body: formData, + method: initiated.method === 'PUT' ? 'PUT' : 'POST', + headers: initiated.headers, + }); + } + + const nextProfile = await apiRequest('/user/avatar/upload/complete', { + method: 'POST', + body: { + filename: file.name, + contentType: file.type || 'image/png', + size: file.size, + storageName: initiated.storageName, + }, + }); + + persistSessionUser(nextProfile); + return nextProfile; + }; + + const handleSaveProfile = async () => { + setProfileSubmitting(true); + setProfileMessage(''); + setProfileError(''); + + try { + if (selectedAvatarFile) { + await uploadAvatar(selectedAvatarFile); + } + + const nextProfile = await apiRequest('/user/profile', { + method: 'PUT', + body: { + displayName: profileDraft.displayName.trim(), + email: profileDraft.email.trim(), + bio: profileDraft.bio, + preferredLanguage: profileDraft.preferredLanguage, + }, + }); + + persistSessionUser(nextProfile); + + await refreshProfile(); + setSelectedAvatarFile(null); + setAvatarPreviewUrl((current) => { + if (current) { + URL.revokeObjectURL(current); + } + return null; + }); + setProfileMessage('账户资料已保存'); + } catch (error) { + setProfileError(error instanceof Error ? error.message : '账户资料保存失败'); + } finally { + setProfileSubmitting(false); + } + }; + + const handleChangePassword = async () => { + setPasswordMessage(''); + setPasswordError(''); + + if (newPassword !== confirmPassword) { + setPasswordError('两次输入的新密码不一致'); + return; + } + + setPasswordSubmitting(true); + try { + const auth = await apiRequest('/user/password', { + method: 'POST', + body: { + currentPassword, + newPassword, + }, + }); + + const currentSession = readStoredSession(); + if (currentSession) { + saveStoredSession({ + ...currentSession, + ...createSession(auth), + user: auth.user, + }); + } + + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordMessage('密码已更新,当前登录态已同步刷新'); + } catch (error) { + setPasswordError(error instanceof Error ? error.message : '密码修改失败'); + } finally { + setPasswordSubmitting(false); + } + }; + return (
- {/* Animated Gradient Background */}
-
-
-
+
+
+
- {/* Top Navigation */} -
+
- {/* Brand */}
Y @@ -43,26 +338,21 @@ export function Layout() {
- {/* Nav Links */} - {/* User / Actions */} -
+
+ + + {isDropdownOpen && ( + <> +
setIsDropdownOpen(false)} /> + +
+

{displayName}

+

{email}

+
+ + + + +
+ + + + + )} +
- {/* Main Content */} -
+
+ + + {activeModal === 'security' && ( +
+ +
+

+ + 安全中心 +

+ +
+
+
+
+
+
+ +
+
+

登录密码

+

密码修改后会刷新当前登录凭据并使旧 refresh token 失效

+
+
+ +
+ setCurrentPassword(event.target.value)} + className="bg-black/20 border-white/10" + /> + setNewPassword(event.target.value)} + className="bg-black/20 border-white/10" + /> + setConfirmPassword(event.target.value)} + className="bg-black/20 border-white/10" + /> +
+ +
+
+
+ +
+
+
+ +
+
+

手机绑定

+

当前项目暂未实现短信绑定流程

+
+
+ +
+ +
+
+
+ +
+
+

邮箱绑定

+

当前邮箱:{email}

+
+
+ +
+
+ + {passwordError &&

{passwordError}

} + {passwordMessage &&

{passwordMessage}

} +
+
+
+ )} + + {activeModal === 'settings' && ( +
+ +
+

+ + 账户设置 +

+ +
+
+
+
+
+ {displayedAvatarUrl ? Avatar : avatarFallback} +
+
+ {selectedAvatarFile ? '等待保存' : '更换头像'} +
+ +
+
+

{displayName}

+

{roleLabel}

+
+
+ +
+
+ + handleProfileDraftChange('displayName', event.target.value)} + className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" + /> +
+ +
+ + handleProfileDraftChange('email', event.target.value)} + className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" + /> +
+ +
+ +