diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index e44d3124..6bcba1a6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.dto.SessionBootstrapRequest; import com.iflytek.skillhub.service.AuthMethodCatalog; import com.iflytek.skillhub.service.DirectAuthService; import com.iflytek.skillhub.service.SessionBootstrapService; +import com.iflytek.skillhub.ratelimit.RateLimit; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.security.core.Authentication; @@ -64,6 +65,7 @@ public class AuthController extends BaseApiController { } @PostMapping("/session/bootstrap") + @RateLimit(category = "auth-session-bootstrap", authenticated = 30, anonymous = 15, windowSeconds = 60) public ApiResponse bootstrapSession(@Valid @RequestBody SessionBootstrapRequest request, HttpServletRequest httpRequest) { return ok( @@ -73,6 +75,7 @@ public class AuthController extends BaseApiController { } @PostMapping("/direct/login") + @RateLimit(category = "auth-direct-login", authenticated = 20, anonymous = 10, windowSeconds = 60) public ApiResponse directLogin(@Valid @RequestBody DirectLoginRequest request, HttpServletRequest httpRequest) { return ok( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 4aff530f..602b6b5b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.dto.LocalLoginRequest; import com.iflytek.skillhub.dto.LocalRegisterRequest; import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.ratelimit.RateLimit; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -38,6 +39,7 @@ public class LocalAuthController extends BaseApiController { } @PostMapping("/register") + @RateLimit(category = "auth-register", authenticated = 10, anonymous = 5, windowSeconds = 300) public ApiResponse register(@Valid @RequestBody LocalRegisterRequest request, HttpServletRequest httpRequest) { PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); @@ -47,6 +49,7 @@ public class LocalAuthController extends BaseApiController { } @PostMapping("/login") + @RateLimit(category = "auth-local-login", authenticated = 20, anonymous = 10, windowSeconds = 60) public ApiResponse login(@Valid @RequestBody LocalLoginRequest request, HttpServletRequest httpRequest) { PlatformPrincipal principal; @@ -62,6 +65,7 @@ public class LocalAuthController extends BaseApiController { } @PostMapping("/change-password") + @RateLimit(category = "auth-change-password", authenticated = 5, anonymous = 20, windowSeconds = 300) public ApiResponse changePassword(@AuthenticationPrincipal PlatformPrincipal principal, @Valid @RequestBody ChangePasswordRequest request) { if (principal == null) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index f0978f65..9bbf57d6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TokenCreateRequest; import com.iflytek.skillhub.dto.TokenCreateResponse; +import com.iflytek.skillhub.dto.TokenExpirationUpdateRequest; import com.iflytek.skillhub.dto.TokenSummaryResponse; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; @@ -34,7 +35,7 @@ public class TokenController extends BaseApiController { ? "[\"skill:read\",\"skill:publish\"]" : request.scopes().toString(); - var result = apiTokenService.createToken(principal.userId(), request.name(), scopeJson); + var result = apiTokenService.createToken(principal.userId(), request.name(), scopeJson, request.expiresAt()); return ok("response.success.created", new TokenCreateResponse( result.rawToken(), result.entity().getId(), @@ -69,4 +70,20 @@ public class TokenController extends BaseApiController { apiTokenService.revokeToken(id, principal.userId()); return ResponseEntity.noContent().build(); } + + @PutMapping("/{id}/expiration") + public ApiResponse updateExpiration( + @AuthenticationPrincipal PlatformPrincipal principal, + @PathVariable Long id, + @RequestBody TokenExpirationUpdateRequest request) { + var token = apiTokenService.updateExpiration(id, principal.userId(), request.expiresAt()); + return ok("response.success.updated", new TokenSummaryResponse( + token.getId(), + token.getName(), + token.getTokenPrefix(), + token.getCreatedAt().toString(), + token.getExpiresAt() != null ? token.getExpiresAt().toString() : "", + token.getLastUsedAt() != null ? token.getLastUsedAt().toString() : "" + )); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index e265ce57..94f32657 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -23,8 +23,10 @@ import org.springframework.http.MediaType; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletRequest; import java.io.InputStream; +import java.net.URI; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -276,13 +278,14 @@ public class SkillController extends BaseApiController { public ResponseEntity downloadLatest( @PathVariable String namespace, @PathVariable String slug, + HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); - return buildDownloadResponse(result); + return buildDownloadResponse(request, result); } @GetMapping("/{namespace}/{slug}/versions/{version}/download") @@ -291,13 +294,14 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @PathVariable String version, + HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of()); - return buildDownloadResponse(result); + return buildDownloadResponse(request, result); } @GetMapping("/{namespace}/{slug}/tags/{tagName}/download") @@ -306,17 +310,18 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @PathVariable String tagName, + HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); - return buildDownloadResponse(result); + return buildDownloadResponse(request, result); } - private ResponseEntity buildDownloadResponse(SkillDownloadService.DownloadResult result) { - if (result.presignedUrl() != null) { + private ResponseEntity buildDownloadResponse(HttpServletRequest request, SkillDownloadService.DownloadResult result) { + if (shouldRedirectToPresignedUrl(request, result.presignedUrl())) { return ResponseEntity.status(HttpStatus.FOUND) .header(HttpHeaders.LOCATION, result.presignedUrl()) .build(); @@ -328,4 +333,26 @@ public class SkillController extends BaseApiController { .contentLength(result.contentLength()) .body(new InputStreamResource(result.content())); } + + private boolean shouldRedirectToPresignedUrl(HttpServletRequest request, String presignedUrl) { + if (presignedUrl == null || presignedUrl.isBlank()) { + return false; + } + if (!isSecureRequest(request)) { + return true; + } + try { + return "https".equalsIgnoreCase(URI.create(presignedUrl).getScheme()); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private boolean isSecureRequest(HttpServletRequest request) { + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + if (forwardedProto != null && !forwardedProto.isBlank()) { + return "https".equalsIgnoreCase(forwardedProto); + } + return request.isSecure(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java index 2a80ad34..e10efa74 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java @@ -3,8 +3,8 @@ package com.iflytek.skillhub.dto; import jakarta.validation.constraints.NotBlank; public record ChangePasswordRequest( - @NotBlank(message = "当前密码不能为空") + @NotBlank(message = "{validation.auth.local.currentPassword.notBlank}") String currentPassword, - @NotBlank(message = "新密码不能为空") + @NotBlank(message = "{validation.auth.local.newPassword.notBlank}") String newPassword ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 99b7cf76..e5f51c0d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -13,6 +13,7 @@ public record SkillSummaryResponse( BigDecimal ratingAvg, Integer ratingCount, String latestVersion, + String latestVersionStatus, String namespace, LocalDateTime updatedAt ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java index 7b821bf4..cb48c855 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java @@ -9,5 +9,6 @@ public record TokenCreateRequest( @NotBlank(message = "{validation.token.name.notBlank}") @Size(max = 64, message = "{validation.token.name.size}") String name, - List scopes + List scopes, + String expiresAt ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenExpirationUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenExpirationUpdateRequest.java new file mode 100644 index 00000000..10ceb60a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenExpirationUpdateRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +public record TokenExpirationUpdateRequest( + String expiresAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 5d7a140d..7b649576 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -25,6 +25,7 @@ import org.springframework.util.StringUtils; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.TreeSet; import java.util.Set; import java.util.stream.Collectors; @@ -130,12 +131,29 @@ public class AdminUserAppService { if (userIds.isEmpty()) { return Map.of(); } - return userRoleBindingRepository.findByUserIdIn(userIds).stream() + Map> explicitRolesByUserId = userRoleBindingRepository.findByUserIdIn(userIds).stream() .collect(Collectors.groupingBy( UserRoleBinding::getUserId, Collectors.mapping(binding -> binding.getRole().getCode(), Collectors.collectingAndThen(Collectors.toList(), roles -> roles.stream().sorted().toList())))); + return userIds.stream().collect(Collectors.toMap( + userId -> userId, + userId -> withDefaultUserRole(explicitRolesByUserId.getOrDefault(userId, List.of())).stream() + .sorted() + .toList() + )); + } + + private Set withDefaultUserRole(List roles) { + Set resolvedRoles = new TreeSet<>(); + if (roles != null) { + resolvedRoles.addAll(roles); + } + if (resolvedRoles.isEmpty()) { + resolvedRoles.add("USER"); + } + return Set.copyOf(resolvedRoles); } private UserAccount loadUser(String userId) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java index 09c12260..fe7133f7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserManagementService.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.PageResponse; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.TreeSet; import java.util.Set; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -106,6 +107,7 @@ public class AdminUserManagementService { .map(binding -> binding.getRole().getCode()) .sorted(Comparator.naturalOrder()) .forEach(roles::add); + roles = new LinkedHashSet<>(withDefaultUserRole(roles)); return new AdminUserSummaryResponse( user.getId(), user.getDisplayName(), @@ -123,6 +125,17 @@ public class AdminUserManagementService { return keyword.trim(); } + private Set withDefaultUserRole(Set roles) { + Set resolvedRoles = new TreeSet<>(); + if (roles != null) { + resolvedRoles.addAll(roles); + } + if (resolvedRoles.isEmpty()) { + resolvedRoles.add("USER"); + } + return Set.copyOf(resolvedRoles); + } + private UserStatus parseStatus(String status) { if (status == null || status.isBlank()) { return null; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 219a5727..5a30ad70 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; import org.springframework.data.domain.Page; @@ -43,15 +44,7 @@ public class MySkillAppService { .sorted(Comparator.comparing(Skill::getUpdatedAt).reversed()) .toList(); - List latestVersionIds = skills.stream() - .map(Skill::getLatestVersionId) - .filter(java.util.Objects::nonNull) - .distinct() - .toList(); - Map versionsById = latestVersionIds.isEmpty() - ? Map.of() - : skillVersionRepository.findByIdIn(latestVersionIds).stream() - .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + Map versionsBySkillId = loadLatestRelevantVersions(skills); List namespaceIds = skills.stream() .map(Skill::getNamespaceId) @@ -65,7 +58,7 @@ public class MySkillAppService { com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); return skills.stream() - .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById)) .toList(); } @@ -81,15 +74,7 @@ public class MySkillAppService { : skillRepository.findByIdIn(skillIds).stream() .collect(Collectors.toMap(Skill::getId, Function.identity())); - List latestVersionIds = skillsById.values().stream() - .map(Skill::getLatestVersionId) - .filter(java.util.Objects::nonNull) - .distinct() - .toList(); - Map versionsById = latestVersionIds.isEmpty() - ? Map.of() - : skillVersionRepository.findByIdIn(latestVersionIds).stream() - .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + Map versionsBySkillId = loadLatestRelevantVersions(skillsById.values()); List namespaceIds = skillsById.values().stream() .map(Skill::getNamespaceId) @@ -106,7 +91,7 @@ public class MySkillAppService { .sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed()) .map(star -> skillsById.get(star.getSkillId())) .filter(java.util.Objects::nonNull) - .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) + .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById)) .toList(); } @@ -130,13 +115,9 @@ public class MySkillAppService { private SkillSummaryResponse toSummaryResponse( Skill skill, - Map versionsById, + Map versionsBySkillId, Map namespaceSlugsById) { - String latestVersion = skill.getLatestVersionId() == null - ? null - : Optional.ofNullable(versionsById.get(skill.getLatestVersionId())) - .map(SkillVersion::getVersion) - .orElse(null); + SkillVersion latestVersion = versionsBySkillId.get(skill.getId()); return new SkillSummaryResponse( skill.getId(), @@ -147,9 +128,52 @@ public class MySkillAppService { skill.getStarCount(), skill.getRatingAvg(), skill.getRatingCount(), - latestVersion, + Optional.ofNullable(latestVersion).map(SkillVersion::getVersion).orElse(null), + Optional.ofNullable(latestVersion).map(SkillVersion::getStatus).map(Enum::name).orElse(null), namespaceSlugsById.get(skill.getNamespaceId()), skill.getUpdatedAt() ); } + + private Map loadLatestRelevantVersions(java.util.Collection skills) { + if (skills.isEmpty()) { + return Map.of(); + } + + List explicitLatestVersionIds = skills.stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versionsById = explicitLatestVersionIds.isEmpty() + ? Map.of() + : skillVersionRepository.findByIdIn(explicitLatestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List skillIdsNeedingFallback = skills.stream() + .filter(skill -> skill.getLatestVersionId() == null || !versionsById.containsKey(skill.getLatestVersionId())) + .map(Skill::getId) + .distinct() + .toList(); + Map fallbackBySkillId = skillIdsNeedingFallback.isEmpty() + ? Map.of() + : skillVersionRepository.findBySkillIdIn(skillIdsNeedingFallback).stream() + .filter(version -> version.getStatus() != SkillVersionStatus.YANKED) + .collect(Collectors.toMap( + SkillVersion::getSkillId, + Function.identity(), + (left, right) -> left.getCreatedAt().isAfter(right.getCreatedAt()) ? left : right + )); + + Map resolvedVersions = new java.util.HashMap<>(); + for (Skill skill : skills) { + SkillVersion resolvedVersion = skill.getLatestVersionId() != null + ? versionsById.get(skill.getLatestVersionId()) + : fallbackBySkillId.get(skill.getId()); + if (resolvedVersion != null) { + resolvedVersions.put(skill.getId(), resolvedVersion); + } + } + return resolvedVersions; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 3a316f7b..3746a9b8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -152,6 +152,7 @@ public class SkillSearchAppService { skill.getRatingAvg(), skill.getRatingCount(), latestVersion, + latestVersion == null ? null : "PUBLISHED", namespaceSlug, skill.getUpdatedAt() ); diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index f79eb483..705cedd1 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -23,6 +23,11 @@ spring: client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder} client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder} +skillhub: + auth: + mock: + enabled: true + logging: level: com.iflytek.skillhub: DEBUG diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index c08d26bc..9655837c 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -60,6 +60,8 @@ spring: skillhub: auth: + mock: + enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} direct: enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false} session-bootstrap: diff --git a/server/skillhub-app/src/main/resources/db/migration/V9__expand_skill_summary_storage.sql b/server/skillhub-app/src/main/resources/db/migration/V9__expand_skill_summary_storage.sql new file mode 100644 index 00000000..66be479a --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V9__expand_skill_summary_storage.sql @@ -0,0 +1,21 @@ +ALTER TABLE skill + ALTER COLUMN summary TYPE TEXT; + +DROP INDEX IF EXISTS idx_search_vector; + +ALTER TABLE skill_search_document + DROP COLUMN search_vector; + +ALTER TABLE skill_search_document + ALTER COLUMN summary TYPE TEXT; + +ALTER TABLE skill_search_document +ADD COLUMN search_vector tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(summary, '')), 'B') || + setweight(to_tsvector('simple', coalesce(keywords, '')), 'B') || + setweight(to_tsvector('simple', coalesce(search_text, '')), 'C') +) STORED; + +CREATE INDEX idx_search_vector ON skill_search_document USING GIN (search_vector); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index ac4ab915..c870d587 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -16,10 +16,15 @@ validation.member.userId.notNull=User ID is required validation.member.role.notNull=Role is required validation.auth.local.username.notBlank=Username cannot be blank validation.auth.local.password.notBlank=Password cannot be blank +validation.auth.local.currentPassword.notBlank=Current password cannot be blank +validation.auth.local.newPassword.notBlank=New password cannot be blank validation.auth.local.email.invalid=Email format is invalid validation.token.name.notBlank=Token name cannot be blank validation.token.name.size=Token name must be at most 64 characters +validation.token.expiresAt.invalid=Expiration time format is invalid +validation.token.expiresAt.future=Expiration time must be in the future error.token.name.duplicate=You already have a token with this name +error.token.notFound=Token not found: {0} error.auth.required=Authentication required error.auth.local.username.exists=Username already exists @@ -72,6 +77,7 @@ error.skill.publish.publisher.notMember=Publisher is not a member of namespace: error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.failed=Pre-publish validation failed: {0} +error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters error.skill.notFound=Skill not found: {0} error.skill.access.denied=Access denied to skill: {0} error.skill.status.notActive=Skill is not active diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index defc9b8b..70fe706e 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -16,10 +16,15 @@ validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 validation.auth.local.username.notBlank=用户名不能为空 validation.auth.local.password.notBlank=密码不能为空 +validation.auth.local.currentPassword.notBlank=当前密码不能为空 +validation.auth.local.newPassword.notBlank=新密码不能为空 validation.auth.local.email.invalid=邮箱格式不正确 validation.token.name.notBlank=Token 名称不能为空 validation.token.name.size=Token 名称最多 64 个字符 +validation.token.expiresAt.invalid=过期时间格式不正确 +validation.token.expiresAt.future=过期时间必须晚于当前时间 error.token.name.duplicate=你已经有同名 Token +error.token.notFound=Token 不存在:{0} error.auth.required=需要先登录 error.auth.local.username.exists=用户名已存在 @@ -72,6 +77,7 @@ error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0} error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.failed=预发布校验失败:{0} +error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符 error.skill.notFound=未找到技能:{0} error.skill.access.denied=没有权限访问技能:{0} error.skill.status.notActive=技能未处于 ACTIVE 状态 @@ -93,8 +99,8 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码 error.deviceAuth.deviceCode.expired=设备验证码已过期 error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 error.deviceAuth.deviceCode.used=设备验证码已被使用 -error.admin.user.notFound=鐢ㄦ埛涓嶅瓨鍦細{0} -error.admin.user.role.invalid=鏃犳晥鐨勮鑹诧細{0} -error.admin.user.role.superAdmin.assignDenied=鍙湁 SUPER_ADMIN 鍙互鍒嗛厤 SUPER_ADMIN 瑙掕壊 -error.admin.user.status.invalid=鏃犳晥鐨勭敤鎴风姸鎬侊細{0} -error.admin.user.status.unsupported=杩欓噷鍙厑璁告寜 ACTIVE 鎴?DISABLED 绠$悊鐢ㄦ埛鐘舵€? +error.admin.user.notFound=用户不存在:{0} +error.admin.user.role.invalid=无效的角色:{0} +error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 +error.admin.user.status.invalid=无效的用户状态:{0} +error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index df671106..b8cdeffe 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -61,6 +61,7 @@ class ClawHubCompatControllerTest { BigDecimal.valueOf(4.5), 2, "1.2.0", + "PUBLISHED", "global", LocalDateTime.of(2026, 3, 13, 9, 0))), 1, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java index cadd26cd..3738a23e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryControllerTest.java @@ -80,6 +80,7 @@ class ClawHubRegistryControllerTest { BigDecimal.ZERO, 0, "1.2.0", + "PUBLISHED", "global", updatedAt ), @@ -93,6 +94,7 @@ class ClawHubRegistryControllerTest { BigDecimal.ONE, 2, "2.0.0", + "PUBLISHED", "team-ai", updatedAt.plusHours(1) ) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java new file mode 100644 index 00000000..f36a3a8d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java @@ -0,0 +1,62 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.ratelimit.RateLimiter; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthRateLimitControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LocalAuthService localAuthService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @MockBean + private RateLimiter rateLimiter; + + @Test + void localLoginShouldReturnTooManyRequestsWhenRateLimitIsExceeded() throws Exception { + given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(false); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"wrong"} + """)) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value(429)) + .andExpect(jsonPath("$.msg").isNotEmpty()); + + verify(localAuthService, never()).login(anyString(), anyString()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index a443d4ba..d88cba1e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -165,4 +166,5 @@ class LocalAuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index 7b133405..86c9e8d3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -30,6 +30,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -78,7 +79,7 @@ class TokenControllerTest { var auth = new UsernamePasswordAuthenticationToken( principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); - given(apiTokenService.createToken(anyString(), anyString(), anyString())) + given(apiTokenService.createToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) .willThrow(new DomainBadRequestException("validation.token.name.size")); mockMvc.perform(post("/api/v1/tokens") @@ -92,6 +93,33 @@ class TokenControllerTest { .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); } + @Test + void create_passesExpirationToService() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var token = new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"); + org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.LocalDateTime.of(2026, 3, 15, 12, 0)); + token.setExpiresAt(java.time.LocalDateTime.of(2026, 4, 15, 12, 0)); + + given(apiTokenService.createToken("user-42", "cli", "[\"skill:read\",\"skill:publish\"]", "2026-04-15T12:00:00")) + .willReturn(new ApiTokenService.TokenCreateResult("sk_raw", token)); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"name":"cli","expiresAt":"2026-04-15T12:00:00"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.expiresAt").value("2026-04-15T12:00")); + } + @Test void list_returns_paginated_tokens() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -128,4 +156,32 @@ class TokenControllerTest { .andExpect(jsonPath("$.data.page").value(1)) .andExpect(jsonPath("$.data.size").value(10)); } + + @Test + void updateExpiration_returnsUpdatedToken() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var token = new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"); + org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0)); + token.setExpiresAt(java.time.LocalDateTime.of(2026, 5, 1, 9, 30)); + + given(apiTokenService.updateExpiration(7L, "user-42", "2026-05-01T09:30")) + .willReturn(token); + + mockMvc.perform(put("/api/v1/tokens/7/expiration") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"expiresAt":"2026-05-01T09:30"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(7)) + .andExpect(jsonPath("$.data.expiresAt").value("2026-05-01T09:30")); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index a1baa691..87362d7c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -47,7 +47,7 @@ class SkillControllerDownloadTest { void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception { given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) .willReturn(new SkillDownloadService.DownloadResult( - null, + new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", 128L, "application/zip", @@ -61,6 +61,25 @@ class SkillControllerDownloadTest { .andExpect(header().string("Location", "https://download.example/presigned")); } + @Test + void downloadVersion_streamsWhenPresignedUrlIsInsecureForHttpsRequest() throws Exception { + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + "http://download.example/presigned" + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download") + .header("X-Forwarded-Proto", "https") + .with(user("test-user")) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")); + } + @Test void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 03b7b853..64f3fbfa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -61,6 +61,20 @@ class AdminUserAppServiceTest { .isEqualTo(List.of("AUDITOR")); } + @Test + void listUsers_defaultsToUserRoleWhenNoExplicitBindingExists() { + UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE); + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + when(adminUserSearchRepository.search(null, null, pageable)) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userRoleBindingRepository.findByUserIdIn(List.of("user-1"))).thenReturn(List.of()); + + PageResponse response = service.listUsers(null, null, 0, 20); + + assertThat(response.items().get(0)).extracting("platformRoles") + .isEqualTo(List.of("USER")); + } + @Test void listUsers_withInvalidStatus_throwsBadRequest() { assertThrows(DomainBadRequestException.class, () -> service.listUsers(null, "BANNED", 0, 20)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index dcaa01d5..d5cef846 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.social.SkillStar; import com.iflytek.skillhub.domain.social.SkillStarRepository; @@ -78,6 +79,7 @@ class MySkillAppServiceTest { ReflectionTestUtils.setField(secondSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 11, 0)); given(skillRepository.findByIdIn(List.of(1L, 2L))).willReturn(List.of(firstSkill, secondSkill)); + given(skillVersionRepository.findBySkillIdIn(List.of(1L, 2L))).willReturn(List.of()); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); var stars = service.listMyStars("user-1"); @@ -86,4 +88,28 @@ class MySkillAppServiceTest { assertThat(stars.get(0).slug()).isEqualTo("second-skill"); assertThat(stars.get(1).slug()).isEqualTo("first-skill"); } + + @Test + void listMySkills_includes_pendingReviewVersionWhenNoPublishedPointerExists() { + Skill skill = new Skill(101L, "draft-skill", "user-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Draft Skill"); + skill.setSummary("pending review"); + ReflectionTestUtils.setField(skill, "id", 1L); + ReflectionTestUtils.setField(skill, "updatedAt", LocalDateTime.of(2026, 3, 15, 10, 0)); + + SkillVersion pendingVersion = new SkillVersion(1L, "1.0.0", "user-1"); + pendingVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); + ReflectionTestUtils.setField(pendingVersion, "id", 11L); + ReflectionTestUtils.setField(pendingVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 9, 30)); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill)); + given(skillVersionRepository.findBySkillIdIn(List.of(1L))).willReturn(List.of(pendingVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); + + var skills = service.listMySkills("user-1"); + + assertThat(skills).hasSize(1); + assertThat(skills.get(0).latestVersion()).isEqualTo("1.0.0"); + assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PENDING_REVIEW"); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index 871f79cb..d716d4f3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.auth.identity; import com.iflytek.skillhub.auth.entity.IdentityBinding; import com.iflytek.skillhub.auth.oauth.OAuthClaims; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; @@ -76,6 +77,7 @@ public class IdentityBindingService { Set roles = roleBindingRepo.findByUserId(user.getId()).stream() .map(rb -> rb.getRole().getCode()) .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); return new PlatformPrincipal( user.getId(), user.getDisplayName(), user.getEmail(), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 574bc418..9aa93de7 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.local; import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -26,6 +27,10 @@ public class LocalAuthService { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final int MAX_FAILED_ATTEMPTS = 5; private static final Duration LOCK_DURATION = Duration.ofMinutes(15); + // Precomputed BCrypt hash for "skillhub-local-auth-dummy". Used to blur timing + // differences between existing and non-existing usernames during login. + private static final String DUMMY_PASSWORD_HASH = + "$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"; private final LocalCredentialRepository credentialRepository; private final UserAccountRepository userAccountRepository; @@ -91,7 +96,12 @@ public class LocalAuthService { public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) - .orElseThrow(() -> invalidCredentials()); + .orElse(null); + + if (credential == null) { + passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); + throw invalidCredentials(); + } UserAccount user = userAccountRepository.findById(credential.getUserId()) .orElseThrow(() -> new IllegalStateException("User not found for local credential")); @@ -134,6 +144,7 @@ public class LocalAuthService { Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() .map(binding -> binding.getRole().getCode()) .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); return new PlatformPrincipal( user.getId(), user.getDisplayName(), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java index f702e66a..a0ab03ae 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.mock; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -10,6 +11,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.annotation.Order; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -21,6 +23,7 @@ import java.util.stream.Collectors; @Component @Profile("local") +@ConditionalOnProperty(name = "skillhub.auth.mock.enabled", havingValue = "true") @Order(-100) public class MockAuthFilter extends OncePerRequestFilter { @@ -48,6 +51,7 @@ public class MockAuthFilter extends OncePerRequestFilter { Set roles = roleBindingRepo.findByUserId(userId).stream() .map(rb -> rb.getRole().getCode()) .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); var principal = new PlatformPrincipal( user.getId(), user.getDisplayName(), user.getEmail(), user.getAvatarUrl(), "mock", roles diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java new file mode 100644 index 00000000..2ccfedee --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformRoleDefaults.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.rbac; + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; + +public final class PlatformRoleDefaults { + + public static final String DEFAULT_USER_ROLE = "USER"; + + private PlatformRoleDefaults() { + } + + public static Set withDefaultUserRole(Collection roles) { + Set resolvedRoles = new TreeSet<>(); + if (roles != null) { + resolvedRoles.addAll(roles); + } + if (resolvedRoles.isEmpty()) { + resolvedRoles.add(DEFAULT_USER_ROLE); + } + return Set.copyOf(resolvedRoles); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java index 3bd9592a..8c94e2ee 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java @@ -23,9 +23,9 @@ public class RbacService { } public Set getUserRoleCodes(String userId) { - return roleBindingRepo.findByUserId(userId).stream() + return PlatformRoleDefaults.withDefaultUserRole(roleBindingRepo.findByUserId(userId).stream() .map(rb -> rb.getRole().getCode()) - .collect(Collectors.toSet()); + .collect(Collectors.toSet())); } public Set getUserPermissions(String userId) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 01632e9b..bba596f5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -57,6 +58,7 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { Set roles = roleBindingRepo.findByUserId(user.getId()).stream() .map(rb -> rb.getRole().getCode()) .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); Set scopes = apiTokenScopeService.parseScopes(token.getScopeJson()); PlatformPrincipal principal = new PlatformPrincipal( user.getId(), user.getDisplayName(), user.getEmail(), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index 8d3cf4d0..5e5c2f83 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.repository.ApiTokenRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.dao.DataIntegrityViolationException; @@ -13,6 +14,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.format.DateTimeParseException; import java.time.LocalDateTime; import java.util.Base64; import java.util.HexFormat; @@ -36,8 +38,14 @@ public class ApiTokenService { @Transactional public TokenCreateResult createToken(String userId, String name, String scopeJson) { + return createToken(userId, name, scopeJson, null); + } + + @Transactional + public TokenCreateResult createToken(String userId, String name, String scopeJson, String expiresAt) { String normalizedName = normalizeName(name); validateTokenName(userId, normalizedName); + LocalDateTime parsedExpiresAt = parseExpiresAt(expiresAt); byte[] randomBytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(randomBytes); @@ -46,6 +54,7 @@ public class ApiTokenService { String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8)); ApiToken token = new ApiToken(userId, normalizedName, prefix, tokenHash, scopeJson); + token.setExpiresAt(parsedExpiresAt); try { token = tokenRepo.save(token); } catch (DataIntegrityViolationException ex) { @@ -69,6 +78,15 @@ public class ApiTokenService { }); } + @Transactional + public ApiToken updateExpiration(Long tokenId, String userId, String expiresAt) { + ApiToken token = tokenRepo.findById(tokenId) + .filter(existing -> existing.getUserId().equals(userId) && existing.getRevokedAt() == null) + .orElseThrow(() -> new DomainNotFoundException("error.token.notFound", tokenId)); + token.setExpiresAt(parseExpiresAt(expiresAt)); + return tokenRepo.save(token); + } + public List listActiveTokens(String userId) { return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId); } @@ -113,4 +131,20 @@ public class ApiTokenService { throw new DomainBadRequestException("error.token.name.duplicate"); } } + + private LocalDateTime parseExpiresAt(String expiresAt) { + if (expiresAt == null || expiresAt.isBlank()) { + return null; + } + + try { + LocalDateTime parsed = LocalDateTime.parse(expiresAt.trim()); + if (!parsed.isAfter(LocalDateTime.now())) { + throw new DomainBadRequestException("validation.token.expiresAt.future"); + } + return parsed; + } catch (DateTimeParseException ex) { + throw new DomainBadRequestException("validation.token.expiresAt.invalid"); + } + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6eaec2b2..42b5cce9 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -3,8 +3,10 @@ package com.iflytek.skillhub.auth.local; 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.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import com.iflytek.skillhub.auth.exception.AuthFlowException; @@ -74,6 +76,7 @@ class LocalAuthServiceTest { assertThat(userCaptor.getValue().getDisplayName()).isEqualTo("alice"); assertThat(principal.displayName()).isEqualTo("alice"); assertThat(principal.email()).isEqualTo("alice@example.com"); + assertThat(principal.platformRoles()).containsExactly("USER"); verify(credentialRepository).save(any(LocalCredential.class)); verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); } @@ -118,6 +121,21 @@ class LocalAuthServiceTest { verify(credentialRepository).save(credential); } + @Test + void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { + given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) + .willReturn(false); + + assertThatThrownBy(() -> service.login("ghost", "bad")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + verify(passwordEncoder).matches("bad", "$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"); + verify(userAccountRepository, never()).findById(any()); + } + @Test void login_withDisabledAccount_fails() { LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java index d23e4c9c..6aa7c5ef 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -9,6 +9,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; @@ -62,4 +63,24 @@ class ApiTokenServiceTest { verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"); verify(tokenRepo, never()).save(any()); } + + @Test + void createToken_setsExpirationWhenProvided() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI")) + .thenReturn(false); + when(tokenRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var result = service.createToken("user-1", "CLI", "[]", "2099-03-20T10:15:00"); + + assertThat(result.entity().getExpiresAt()).isEqualTo(java.time.LocalDateTime.of(2099, 3, 20, 10, 15)); + } + + @Test + void createToken_rejectsPastExpiration() { + assertThatThrownBy(() -> service.createToken("user-1", "CLI", "[]", "2000-01-01T00:00:00")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.expiresAt.future"); + + verify(tokenRepo, never()).save(any()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index 7dce8b83..db7f457c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -6,6 +6,7 @@ import java.util.Optional; public interface SkillVersionRepository { Optional findById(Long id); List findByIdIn(List ids); + List findBySkillIdIn(List skillIds); Optional findBySkillIdAndVersion(Long skillId, String version); List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status); SkillVersion save(SkillVersion version); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 2b4fef49..8d18b875 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -140,7 +140,7 @@ public class SkillDownloadService { ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); String presignedUrl = objectStorageService.generatePresignedUrl(storageKey, Duration.ofMinutes(10)); - InputStream content = presignedUrl == null ? objectStorageService.getObject(storageKey) : null; + InputStream content = objectStorageService.getObject(storageKey); // Publish download event eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 876ea1d6..b077f24a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -88,6 +88,7 @@ class SkillDownloadServiceTest { when(objectStorageService.exists(storageKey)).thenReturn(true); when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); when(objectStorageService.getObject(storageKey)).thenReturn(content); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any())).thenReturn(null); // Act SkillDownloadService.DownloadResult result = service.downloadLatest(namespaceSlug, skillSlug, userId, userNsRoles); @@ -96,6 +97,7 @@ class SkillDownloadServiceTest { assertNotNull(result); assertEquals("test-skill-1.0.0.zip", result.filename()); assertEquals(1000L, result.contentLength()); + assertNotNull(result.content()); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } @@ -129,6 +131,7 @@ class SkillDownloadServiceTest { when(objectStorageService.exists(storageKey)).thenReturn(true); when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); when(objectStorageService.getObject(storageKey)).thenReturn(content); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any())).thenReturn(null); // Act SkillDownloadService.DownloadResult result = service.downloadByTag(namespaceSlug, skillSlug, tagName, userId, userNsRoles); @@ -136,9 +139,45 @@ class SkillDownloadServiceTest { // Assert assertNotNull(result); assertEquals("test-skill-1.0.0.zip", result.filename()); + assertNotNull(result.content()); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_WithPresignedUrlStillProvidesStreamFallback() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + String storageKey = "packages/1/10/bundle.zip"; + InputStream content = new ByteArrayInputStream("test".getBytes()); + ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + when(objectStorageService.exists(storageKey)).thenReturn(true); + when(objectStorageService.getMetadata(storageKey)).thenReturn(metadata); + when(objectStorageService.getObject(storageKey)).thenReturn(content); + when(objectStorageService.generatePresignedUrl(eq(storageKey), any())).thenReturn("http://minio.local/presigned"); + + SkillDownloadService.DownloadResult result = service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles); + + assertEquals("http://minio.local/presigned", result.presignedUrl()); + assertNotNull(result.content()); + } + @Test void testDownloadVersion_ShouldRejectDraftVersion() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 5067dd19..a184de8e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -336,6 +336,53 @@ class SkillPublishServiceTest { verify(namespaceMemberRepository, never()).findByNamespaceIdAndUserId(any(), any()); } + @Test + void testPublishFromEntries_AllowsDescriptionLongerThanPreviousDatabaseLimit() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String longDescription = "x".repeat(513); + String skillMdContent = "---\nname: Too Long Skill\ndescription: ignored\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("Too Long Skill", longDescription, "1.0.0", "Body", Map.of()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + + Skill skill = new Skill(namespace.getId(), "too-long-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 10L); + when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), "too-long-skill")).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.0.0")).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any())).thenAnswer(invocation -> { + SkillVersion version = invocation.getArgument(0); + if (version.getId() == null) { + setId(version, 20L); + } + return version; + }); + + SkillPublishService.PublishResult result = service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + ); + + assertEquals(longDescription, skill.getSummary()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(prePublishValidator).validate(any()); + verify(skillRepository).save(skill); + } + private void setId(Object entity, Long id) throws Exception { Field idField = entity.getClass().getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index 319ec036..d5c95394 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -14,6 +14,7 @@ import java.util.Optional; @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { List findByIdIn(List ids); + List findBySkillIdIn(List skillIds); Optional findBySkillIdAndVersion(Long skillId, String version); @Override diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index b23d5f14..6114e35c 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -9,10 +9,15 @@ import org.springframework.stereotype.Service; import java.util.List; import java.util.Set; +import java.util.regex.Pattern; @Service public class PostgresFullTextQueryService implements SearchQueryService { - private static final int SHORT_KEYWORD_LENGTH = 2; + private static final Pattern QUERY_TERM_SPLITTER = Pattern.compile("[^\\p{L}\\p{N}_]+"); + private static final int MAX_QUERY_TERMS = 8; + private static final int SHORT_PREFIX_LENGTH = 2; + private static final String TITLE_VECTOR_SQL = "to_tsvector('simple', coalesce(title, ''))"; + private static final String TITLE_SQL = "LOWER(title)"; private final EntityManager entityManager; @@ -23,8 +28,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { @Override public SearchResult search(SearchQuery query) { String normalizedKeyword = normalizeKeyword(query.keyword()); - boolean hasKeyword = normalizedKeyword != null; - boolean useShortKeywordFallback = hasKeyword && normalizedKeyword.length() <= SHORT_KEYWORD_LENGTH; + String tsQuery = buildPrefixTsQuery(normalizedKeyword); + boolean hasKeyword = tsQuery != null; + boolean useShortPrefixTitleSearch = hasKeyword && normalizedKeyword.length() <= SHORT_PREFIX_LENGTH; Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); @@ -53,16 +59,14 @@ public class PostgresFullTextQueryService implements SearchQueryService { // Full-text search if (hasKeyword) { - if (useShortKeywordFallback) { - sql.append("AND ("); - sql.append("LOWER(title) LIKE LOWER(:keywordLike) "); - sql.append("OR LOWER(summary) LIKE LOWER(:keywordLike) "); - sql.append("OR LOWER(keywords) LIKE LOWER(:keywordLike) "); - sql.append("OR LOWER(search_text) LIKE LOWER(:keywordLike)"); - sql.append(") "); + sql.append("AND ("); + if (useShortPrefixTitleSearch) { + sql.append(TITLE_VECTOR_SQL).append(" @@ to_tsquery('simple', :tsQuery) "); } else { - sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + sql.append("search_vector @@ to_tsquery('simple', :tsQuery) "); } + sql.append(" OR ").append(TITLE_SQL).append(" LIKE :titleLike"); + sql.append(") "); } // Sorting @@ -72,8 +76,18 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC "); } else if ("newest".equals(query.sortBy())) { sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC "); - } else if ("relevance".equals(query.sortBy()) && hasKeyword && !useShortKeywordFallback) { - sql.append("ORDER BY ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC "); + } else if ("relevance".equals(query.sortBy()) && hasKeyword) { + sql.append("ORDER BY CASE "); + sql.append("WHEN ").append(TITLE_SQL).append(" = :titleExact THEN 4 "); + sql.append("WHEN ").append(TITLE_SQL).append(" LIKE :titlePrefix THEN 3 "); + sql.append("WHEN ").append(TITLE_SQL).append(" LIKE :titleLike THEN 2 "); + sql.append("ELSE 1 END DESC, "); + if (useShortPrefixTitleSearch) { + sql.append("ts_rank_cd(").append(TITLE_VECTOR_SQL) + .append(", to_tsquery('simple', :tsQuery)) DESC, updated_at DESC "); + } else { + sql.append("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery)) DESC, updated_at DESC "); + } } else { sql.append("ORDER BY updated_at DESC "); } @@ -94,11 +108,10 @@ public class PostgresFullTextQueryService implements SearchQueryService { } if (hasKeyword) { - if (useShortKeywordFallback) { - nativeQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); - } else { - nativeQuery.setParameter("keyword", normalizedKeyword); - } + nativeQuery.setParameter("tsQuery", tsQuery); + nativeQuery.setParameter("titleExact", normalizedKeyword.toLowerCase()); + nativeQuery.setParameter("titlePrefix", normalizedKeyword.toLowerCase() + "%"); + nativeQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } nativeQuery.setParameter("limit", query.size()); @@ -133,11 +146,8 @@ public class PostgresFullTextQueryService implements SearchQueryService { } if (hasKeyword) { - if (useShortKeywordFallback) { - countQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); - } else { - countQuery.setParameter("keyword", normalizedKeyword); - } + countQuery.setParameter("tsQuery", tsQuery); + countQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } long total = ((Number) countQuery.getSingleResult()).longValue(); @@ -149,6 +159,28 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (keyword == null || keyword.isBlank()) { return null; } - return keyword.trim(); + return keyword.trim().toLowerCase(); + } + + private String buildPrefixTsQuery(String keyword) { + if (keyword == null) { + return null; + } + + List terms = QUERY_TERM_SPLITTER.splitAsStream(keyword.toLowerCase()) + .map(String::trim) + .filter(term -> !term.isBlank()) + .distinct() + .limit(MAX_QUERY_TERMS) + .toList(); + + if (terms.isEmpty()) { + return null; + } + + return terms.stream() + .map(term -> term + ":*") + .reduce((left, right) -> left + " & " + right) + .orElse(null); } } diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index e91954d9..b3c909a6 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -19,7 +19,7 @@ import static org.mockito.Mockito.when; class PostgresFullTextQueryServiceTest { @Test - void shortKeywordsShouldUseLikeFallback() { + void shortKeywordsShouldUsePrefixTsQuery() { EntityManager entityManager = mock(EntityManager.class); Query nativeQuery = mock(Query.class); Query countQuery = mock(Query.class); @@ -42,14 +42,16 @@ class PostgresFullTextQueryServiceTest { 20 )); - verify(nativeQuery).setParameter("keywordLike", "%ai%"); - verify(countQuery).setParameter("keywordLike", "%ai%"); - verify(nativeQuery, never()).setParameter("keyword", "ai"); - verify(countQuery, never()).setParameter("keyword", "ai"); + verify(nativeQuery).setParameter("tsQuery", "ai:*"); + verify(countQuery).setParameter("tsQuery", "ai:*"); + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("to_tsvector('simple', coalesce(title, '')) @@ to_tsquery('simple', :tsQuery)"); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE :titleLike"); } @Test - void longerKeywordsShouldKeepFullTextSearch() { + void longerKeywordsShouldUsePrefixTsQuery() { EntityManager entityManager = mock(EntityManager.class); Query nativeQuery = mock(Query.class); Query countQuery = mock(Query.class); @@ -72,14 +74,12 @@ class PostgresFullTextQueryServiceTest { 20 )); - verify(nativeQuery).setParameter("keyword", "agent"); - verify(countQuery).setParameter("keyword", "agent"); - verify(nativeQuery, never()).setParameter("keywordLike", "%agent%"); - verify(countQuery, never()).setParameter("keywordLike", "%agent%"); + verify(nativeQuery).setParameter("tsQuery", "agent:*"); + verify(countQuery).setParameter("tsQuery", "agent:*"); } @Test - void shortKeywordSqlShouldAvoidTsRankOrdering() { + void prefixSearchSqlShouldUseVectorRanking() { EntityManager entityManager = mock(EntityManager.class); Query nativeQuery = mock(Query.class); Query countQuery = mock(Query.class); @@ -94,7 +94,7 @@ class PostgresFullTextQueryServiceTest { PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); service.search(new SearchQuery( - "go", + "sel", null, new SearchVisibilityScope(null, Set.of(), Set.of()), "relevance", @@ -104,7 +104,65 @@ class PostgresFullTextQueryServiceTest { var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); - assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE LOWER(:keywordLike)"); - assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ts_rank"); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("search_vector @@ to_tsquery('simple', :tsQuery)"); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery))"); + } + + @Test + void shortPrefixRelevanceShouldRankUsingTitleVectorWithoutDuplicateOrderBy() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "x", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("ts_rank_cd(to_tsvector('simple', coalesce(title, '')), to_tsquery('simple', :tsQuery))"); + assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ORDER BY ORDER BY"); + } + + @Test + void multipleTermsShouldBuildPrefixQueryForEachLexeme() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "self improving", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("tsQuery", "self:* & improving:*"); + verify(countQuery).setParameter("tsQuery", "self:* & improving:*"); } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 54ee7908..eef1dd50 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,7 +13,6 @@ import type { MergeVerifyRequest, ReviewTask, PromotionTask, - AdminUser, AuditLogItem, SkillSummary, AuthMethod, @@ -455,6 +454,16 @@ export const tokenApi = { } }, + async updateTokenExpiration(tokenId: number, expiresAt?: string): Promise { + return fetchJson(`/api/v1/tokens/${tokenId}/expiration`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ expiresAt: expiresAt ?? '' }), + }) + }, + async deleteToken(tokenId: number): Promise { const { error, response } = await client.DELETE('/api/v1/tokens/{id}', { params: { @@ -564,9 +573,34 @@ export const adminApi = { if (params.status) searchParams.set('status', params.status) searchParams.set('page', String(params.page ?? 0)) searchParams.set('size', String(params.size ?? 20)) - return fetchJson<{ items: AdminUser[]; total: number; page: number; size: number }>( + const response = await fetchJson<{ + items: Array<{ + id: string + username: string + email?: string + platformRoles?: string[] + status: string + createdAt: string + }> + total: number + page: number + size: number + }>( `/api/v1/admin/users?${searchParams.toString()}`, ) + return { + ...response, + items: response.items + .filter((user) => user.id && user.username && user.status && user.createdAt) + .map((user) => ({ + userId: user.id, + username: user.username, + email: user.email, + platformRoles: user.platformRoles ?? [], + status: user.status, + createdAt: user.createdAt, + })), + } }, async updateUserRole(userId: string, role: string): Promise { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 5f0b908a..5b5f35a3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -35,6 +35,7 @@ export type ApiToken = Omit & { name: string scopes?: string[] + expiresAt?: string } export type CreateTokenResponse = Omit & { @@ -111,6 +112,7 @@ export interface SkillSummary { ratingAvg?: number ratingCount: number latestVersion?: string + latestVersionStatus?: string namespace: string updatedAt: string } diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 954abbb3..4835ed8e 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -129,7 +129,7 @@ const searchRoute = createRoute({ validateSearch: (search: Record) => { return { q: (search.q as string) || '', - sort: (search.sort as string) || 'relevance', + sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', } diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 4d25d86e..9331ba0a 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Loader2, Search, X } from 'lucide-react' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' @@ -7,11 +8,12 @@ interface SearchBarProps { defaultValue?: string value?: string placeholder?: string + isSearching?: boolean onChange?: (query: string) => void onSearch?: (query: string) => void } -export function SearchBar({ defaultValue = '', value, placeholder, onChange, onSearch }: SearchBarProps) { +export function SearchBar({ defaultValue = '', value, placeholder, isSearching = false, onChange, onSearch }: SearchBarProps) { const { t } = useTranslation() const [query, setQuery] = useState(defaultValue) const isControlled = value !== undefined @@ -37,32 +39,36 @@ export function SearchBar({ defaultValue = '', value, placeholder, onChange, onS } } + const handleClear = () => { + handleChange('') + onSearch?.('') + } + return (
- - - + handleChange(e.target.value)} placeholder={placeholder || t('searchBar.placeholder')} - className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" + className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" /> + {currentQuery ? ( + + ) : null}
-
) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index f9c5fb86..286b6764 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -6,6 +6,7 @@ import { useStar, useToggleStar } from '@/features/social/use-star' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' +import { formatCompactCount } from '@/shared/lib/number-format' import { Bookmark } from 'lucide-react' interface SkillCardProps { @@ -83,7 +84,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard - {skill.downloadCount} + {formatCompactCount(skill.downloadCount)} (null) const [nameError, setNameError] = useState(null) + const [expirationMode, setExpirationMode] = useState('never') + const [customExpiresAt, setCustomExpiresAt] = useState('') + const [expiresAtError, setExpiresAtError] = useState(null) const queryClient = useQueryClient() const normalizedName = name.trim() @@ -43,6 +49,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD setCreatedToken(data) setName('') setNameError(null) + setExpirationMode('never') + setCustomExpiresAt('') + setExpiresAtError(null) queryClient.invalidateQueries({ queryKey: ['tokens'] }) }, }) @@ -61,8 +70,15 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD return } + const expiresAt = resolveTokenExpiresAt(expirationMode, customExpiresAt) + if (expirationMode === 'custom' && !expiresAt) { + setExpiresAtError(t('createToken.expiresAtRequired')) + return + } + setNameError(null) - createMutation.mutate({ name: normalizedName }) + setExpiresAtError(null) + createMutation.mutate({ name: normalizedName, expiresAt }) } const handleClose = () => { @@ -70,6 +86,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD setCreatedToken(null) setName('') setNameError(null) + setExpirationMode('never') + setCustomExpiresAt('') + setExpiresAtError(null) createMutation.reset() } @@ -78,34 +97,31 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD try { await navigator.clipboard.writeText(createdToken.token) - toast.success(t('createToken.copySuccess'), undefined, { - position: 'top-center', - classNames: { - title: 'text-center font-semibold', - description: 'text-center', - }, - }) + toast.success(t('createToken.copySuccess'), undefined, centeredToastOptions()) } catch (error) { console.error('Failed to copy token:', error) - toast.error(t('createToken.copyFailed'), undefined, { - position: 'top-center', - classNames: { - title: 'text-center font-semibold', - description: 'text-center', - }, - }) + toast.error(t('createToken.copyFailed'), undefined, centeredToastOptions()) } } + const formatExpiresAt = (expiresAt?: string) => { + if (!expiresAt) { + return t('token.neverExpires') + } + return formatLocalDateTime(expiresAt, i18n.language) + } + + const minDateTime = toLocalDateTimeInputValue(new Date()) + return ( {children} {!createdToken ? ( <> - - {t('createToken.title')} - + + {t('createToken.title')} + {t('createToken.description')} @@ -139,11 +155,48 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD + +
+ + + {expirationMode === 'custom' ? ( + { + setCustomExpiresAt(e.target.value) + if (expiresAtError) { + setExpiresAtError(null) + } + }} + /> + ) : null} + {expiresAtError ? ( +

{expiresAtError}

+ ) : ( +

{t('createToken.expirationHint')}

+ )} +
{createMutation.error ? (

{createMutation.error.message}

) : null} - + @@ -157,9 +210,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD ) : ( <> - - {t('createToken.successTitle')} - + + {t('createToken.successTitle')} + {t('createToken.successDescription')} @@ -174,8 +227,12 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
{createdToken.name}
+
+ +
{formatExpiresAt(createdToken.expiresAt)}
+
- + diff --git a/web/src/features/token/token-expiration.ts b/web/src/features/token/token-expiration.ts new file mode 100644 index 00000000..12a659dd --- /dev/null +++ b/web/src/features/token/token-expiration.ts @@ -0,0 +1,31 @@ +export type TokenExpirationMode = 'never' | '7d' | '30d' | '90d' | 'custom' + +export function resolveTokenExpiresAt(mode: TokenExpirationMode, customExpiresAt?: string) { + if (mode === 'never') { + return undefined + } + + if (mode === 'custom') { + return customExpiresAt || undefined + } + + const next = new Date() + if (mode === '7d') { + next.setDate(next.getDate() + 7) + } else if (mode === '30d') { + next.setDate(next.getDate() + 30) + } else if (mode === '90d') { + next.setDate(next.getDate() + 90) + } + next.setSeconds(0, 0) + return toLocalDateTimeInputValue(next) +} + +export function toLocalDateTimeInputValue(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` +} diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 0cef270f..561bd5bc 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Pencil } from 'lucide-react' import { tokenApi } from '@/api/client' import { Button } from '@/shared/ui/button' import { @@ -14,9 +15,14 @@ import { import { CreateTokenDialog } from './create-token-dialog' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { Pagination } from '@/shared/components/pagination' -import { toast } from '@/shared/lib/toast' +import { centeredToastOptions, toast } from '@/shared/lib/toast' import { formatLocalDateTime } from '@/shared/lib/date-time' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { Label } from '@/shared/ui/label' +import { Select } from '@/shared/ui/select' +import { Input } from '@/shared/ui/input' import type { ApiToken } from '@/api/types' +import { resolveTokenExpiresAt, toLocalDateTimeInputValue, type TokenExpirationMode } from './token-expiration' const PAGE_SIZE = 10 type TokenPage = { items: ApiToken[]; total: number; page: number; size: number } @@ -28,6 +34,17 @@ export function TokenList() { const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; tokenId?: number; name?: string }>({ open: false, }) + const [expirationDialog, setExpirationDialog] = useState<{ + open: boolean + tokenId?: number + tokenName?: string + mode: TokenExpirationMode + customExpiresAt: string + }>({ + open: false, + mode: 'never', + customExpiresAt: '', + }) const { data: tokenPage, isLoading, isError, error } = useQuery({ queryKey: ['tokens', page, PAGE_SIZE], @@ -37,6 +54,19 @@ export function TokenList() { }, }) + const updateExpirationMutation = useMutation({ + mutationFn: ({ tokenId, expiresAt }: { tokenId: number; expiresAt?: string }) => + tokenApi.updateTokenExpiration(tokenId, expiresAt), + onSuccess: () => { + setExpirationDialog({ open: false, mode: 'never', customExpiresAt: '' }) + queryClient.invalidateQueries({ queryKey: ['tokens'] }) + toast.success(t('token.updateExpirationSuccess')) + }, + onError: () => { + toast.error(t('token.updateExpirationFailed')) + }, + }) + const deleteMutation = useMutation({ mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId), onMutate: async (tokenId) => { @@ -68,13 +98,13 @@ export function TokenList() { } setDeleteDialog({ open: false }) queryClient.invalidateQueries({ queryKey: ['tokens'] }) - toast.success(t('token.deleteSuccess')) + toast.success(t('token.deleteSuccess'), undefined, centeredToastOptions()) }, onError: (_error, _tokenId, context) => { context?.previousPages.forEach(([queryKey, previousPage]) => { queryClient.setQueryData(queryKey, previousPage) }) - toast.error(t('token.deleteFailed')) + toast.error(t('token.deleteFailed'), undefined, centeredToastOptions()) }, }) @@ -82,14 +112,41 @@ export function TokenList() { setDeleteDialog({ open: true, tokenId, name }) } + const handleEditExpiration = (token: ApiToken) => { + setExpirationDialog({ + open: true, + tokenId: token.id, + tokenName: token.name, + mode: token.expiresAt ? 'custom' : 'never', + customExpiresAt: token.expiresAt ? token.expiresAt.slice(0, 16) : '', + }) + } + const confirmDelete = async () => { if (deleteDialog.tokenId) { await deleteMutation.mutateAsync(deleteDialog.tokenId) } } - const formatDate = (dateString?: string | null) => { - if (!dateString) return '-' + const confirmExpirationUpdate = async () => { + if (!expirationDialog.tokenId) { + return + } + + const expiresAt = resolveTokenExpiresAt(expirationDialog.mode, expirationDialog.customExpiresAt) + if (expirationDialog.mode === 'custom' && !expiresAt) { + toast.error(t('createToken.expiresAtRequired')) + return + } + + await updateExpirationMutation.mutateAsync({ + tokenId: expirationDialog.tokenId, + expiresAt, + }) + } + + const formatDate = (dateString?: string | null, emptyLabel = '-') => { + if (!dateString) return emptyLabel return formatLocalDateTime(dateString, i18n.language) } @@ -124,6 +181,7 @@ export function TokenList() { +

{t('token.copyHint')}

{!tokenPage || tokenPage.total === 0 ? (
@@ -140,7 +198,7 @@ export function TokenList() { {t('token.createdAt')} {t('token.lastUsed')} {t('token.expiresAt')} - {t('token.actions')} + {t('token.actions')} @@ -154,16 +212,27 @@ export function TokenList() { {formatDate(token.createdAt)} {formatDate(token.lastUsedAt)} - {formatDate(token.expiresAt)} - - + {formatDate(token.expiresAt, t('token.neverExpires'))} + +
+ + +
))} @@ -185,6 +254,59 @@ export function TokenList() { variant="destructive" onConfirm={confirmDelete} /> + + setExpirationDialog((current) => ({ ...current, open }))}> + + + {t('token.editExpirationTitle')} + + {t('token.editExpirationDescription', { name: expirationDialog.tokenName })} + + +
+
+ + +
+ {expirationDialog.mode === 'custom' ? ( +
+ + setExpirationDialog((current) => ({ + ...current, + customExpiresAt: event.target.value, + }))} + /> +
+ ) : null} +
+ + + + +
+
) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7304dd9b..41f34664 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -103,11 +103,13 @@ "noStarredSkills": "You have not starred any skills yet", "enterKeyword": "Please enter a search keyword", "results": "{{count}} skills found", - "resultCount": "Found <1>{{count}} results" + "resultCount": "Found <1>{{count}} results", + "loadingMore": "Updating search results..." }, "searchBar": { "placeholder": "Search skills...", - "button": "Search" + "button": "Search", + "clear": "Clear search" }, "login": { "title": "Login to SkillHub", @@ -170,6 +172,7 @@ "dashboard": { "title": "Dashboard", "subtitle": "Manage your account and API Tokens", + "backToDashboard": "Back to Dashboard", "userInfo": "User Info", "userInfoDesc": "Your account details", "loginVia": "Logged in via {{provider}}", @@ -185,6 +188,8 @@ "title": "My Skills", "subtitle": "Manage your published skills", "publishNew": "Publish New Skill", + "statusPendingReview": "Pending Review", + "statusPublished": "Published", "emptyTitle": "No skills yet", "emptyDescription": "Start publishing your first skill", "publishSkill": "Publish Skill" @@ -237,7 +242,12 @@ "adminUsers": { "title": "User Management", "subtitle": "Manage platform users and permissions", + "searchLabel": "Search users", "searchPlaceholder": "Search username or email...", + "searchHint": "Enter a username or email, then press Enter or click Search.", + "searchAction": "Search", + "clearSearch": "Clear", + "filterLabel": "Status filter", "filterAll": "All", "filterActive": "Active", "filterPending": "Pending", @@ -299,7 +309,12 @@ "subtitle": "Update your password when local account login is enabled.", "currentPassword": "Current Password", "newPassword": "New Password", + "currentPasswordRequired": "Please enter your current password", + "newPasswordRequired": "Please enter your new password", + "invalidCurrentPassword": "Current password is incorrect", "success": "Password changed successfully", + "successTitle": "Password changed successfully", + "successDescription": "Please sign in again with your new password.", "defaultError": "Failed to change password", "submitting": "Submitting...", "submit": "Update Password" @@ -411,9 +426,18 @@ "creating": "Creating...", "create": "Create", "successTitle": "Token Created", - "successDescription": "Copy and save this token now. It will only be shown once.", + "successDescription": "Copy and save this token now. It is only shown and copyable at creation time. If you need it again later, create a new token.", "tokenLabel": "Token", "nameDisplay": "Name", + "expirationLabel": "Expiration", + "expirationHint": "By default, tokens never expire. You can also choose an automatic expiration time.", + "expirationNever": "Never expires", + "expiration7d": "Expires in 7 days", + "expiration30d": "Expires in 30 days", + "expiration90d": "Expires in 90 days", + "expirationCustom": "Custom date and time", + "expiresAtRequired": "Please choose a custom expiration time", + "expiresAtDisplay": "Expires At", "copyToken": "Copy Token", "copySuccess": "Token copied to clipboard", "copyFailed": "Failed to copy token. Please try again." @@ -479,6 +503,7 @@ "private": "Private" }, "file": "Skill Package File", + "removeSelectedFile": "Remove selected file", "publishing": "Publishing...", "confirm": "Confirm Publish", "success": "Published Successfully", @@ -540,6 +565,7 @@ "token": { "title": "API Tokens", "createNew": "Create Token", + "copyHint": "For security reasons, token plaintext can only be copied once when it is created. It cannot be viewed or copied again later. If you still need it, create a new token.", "empty": "No tokens created yet", "emptyHint": "Click the button above to create your first token", "name": "Name", @@ -547,7 +573,20 @@ "createdAt": "Created At", "lastUsed": "Last Used", "expiresAt": "Expires At", + "neverExpires": "Never expires", "actions": "Actions", + "copy": "Copy", + "copySuccess": "Token copied to clipboard", + "copyFailed": "Failed to copy token. Please try again.", + "copyUnavailableTitle": "Older token plaintext cannot be copied again", + "copyUnavailableDescription": "Token plaintext is only shown once after creation. If you no longer have it, please create a new token.", + "editExpiration": "Edit Expiration", + "editExpirationTitle": "Edit Token Expiration", + "editExpirationDescription": "Set a new expiration time for token \"{{name}}\".", + "saveExpiration": "Save Expiration", + "updatingExpiration": "Saving...", + "updateExpirationSuccess": "Token expiration updated", + "updateExpirationFailed": "Failed to update token expiration", "delete": "Delete", "deleteTitle": "Delete Token", "deleteDescription": "Are you sure you want to delete token \"{{name}}\"? This action cannot be undone.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e45b549b..3e319ff5 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -103,11 +103,13 @@ "noStarredSkills": "你还没有收藏任何技能", "enterKeyword": "请输入搜索关键词", "results": "找到 {{count}} 个技能", - "resultCount": "找到 <1>{{count}} 个结果" + "resultCount": "找到 <1>{{count}} 个结果", + "loadingMore": "正在更新搜索结果..." }, "searchBar": { "placeholder": "搜索技能...", - "button": "搜索" + "button": "搜索", + "clear": "清空搜索" }, "login": { "title": "登录 SkillHub", @@ -170,6 +172,7 @@ "dashboard": { "title": "Dashboard", "subtitle": "管理你的账户和 API Tokens", + "backToDashboard": "返回控制台", "userInfo": "用户信息", "userInfoDesc": "你的账户详情", "loginVia": "通过 {{provider}} 登录", @@ -185,6 +188,8 @@ "title": "我的技能", "subtitle": "管理你发布的技能", "publishNew": "发布新技能", + "statusPendingReview": "审核中", + "statusPublished": "已发布", "emptyTitle": "还没有技能", "emptyDescription": "开始发布你的第一个技能吧", "publishSkill": "发布技能" @@ -237,7 +242,12 @@ "adminUsers": { "title": "用户管理", "subtitle": "管理平台用户和权限", + "searchLabel": "搜索用户", "searchPlaceholder": "搜索用户名或邮箱...", + "searchHint": "输入用户名或邮箱后,按回车或点击搜索。", + "searchAction": "搜索", + "clearSearch": "清空", + "filterLabel": "状态筛选", "filterAll": "全部", "filterActive": "活跃", "filterPending": "待审批", @@ -299,7 +309,12 @@ "subtitle": "已启用本地账号密码登录时,可以在这里更新密码。", "currentPassword": "当前密码", "newPassword": "新密码", + "currentPasswordRequired": "请输入当前密码", + "newPasswordRequired": "请输入新密码", + "invalidCurrentPassword": "当前密码错误", "success": "密码修改成功", + "successTitle": "密码修改成功", + "successDescription": "请使用新密码重新登录。", "defaultError": "修改密码失败", "submitting": "提交中...", "submit": "更新密码" @@ -411,9 +426,18 @@ "creating": "创建中...", "create": "创建", "successTitle": "Token 创建成功", - "successDescription": "请立即复制并保存此 Token,它只会显示一次", + "successDescription": "请立即复制并保存此 Token。它只会在创建成功时展示和复制这一回,后续若仍需使用,请重新创建。", "tokenLabel": "Token", "nameDisplay": "名称", + "expirationLabel": "过期时间", + "expirationHint": "默认永不过期,也可以选择一个自动过期时间。", + "expirationNever": "永不过期", + "expiration7d": "7 天后过期", + "expiration30d": "30 天后过期", + "expiration90d": "90 天后过期", + "expirationCustom": "自定义时间", + "expiresAtRequired": "请选择自定义过期时间", + "expiresAtDisplay": "过期时间", "copyToken": "复制 Token", "copySuccess": "Token 已复制到剪贴板", "copyFailed": "复制 Token 失败,请重试" @@ -479,6 +503,7 @@ "private": "私有" }, "file": "技能包文件", + "removeSelectedFile": "删除已选文件", "publishing": "发布中...", "confirm": "确认发布", "success": "发布成功", @@ -540,6 +565,7 @@ "token": { "title": "API Tokens", "createNew": "创建新 Token", + "copyHint": "出于安全原因,Token 明文只在创建成功时可复制一次,后续无法再次查看或复制。若仍需使用,请重新创建新的 Token。", "empty": "还没有创建任何 Token", "emptyHint": "点击上方按钮创建第一个 Token", "name": "名称", @@ -547,7 +573,20 @@ "createdAt": "创建时间", "lastUsed": "最后使用", "expiresAt": "过期时间", + "neverExpires": "永不过期", "actions": "操作", + "copy": "复制", + "copySuccess": "Token 已复制到剪贴板", + "copyFailed": "复制 Token 失败,请重试", + "copyUnavailableTitle": "无法再次复制旧 Token", + "copyUnavailableDescription": "Token 明文只会在创建成功时展示一次。若你已经丢失它,请重新创建一个新的 Token。", + "editExpiration": "修改过期时间", + "editExpirationTitle": "修改 Token 过期时间", + "editExpirationDescription": "为 Token \"{{name}}\" 设置新的过期时间。", + "saveExpiration": "保存过期时间", + "updatingExpiration": "保存中...", + "updateExpirationSuccess": "Token 过期时间已更新", + "updateExpirationFailed": "更新过期时间失败", "delete": "删除", "deleteTitle": "删除 Token", "deleteDescription": "确定要删除 Token \"{{name}}\" 吗?此操作无法撤销。", diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index a2c51263..b4486520 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { KeyboardEvent, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' @@ -26,7 +26,15 @@ import type { AdminUser } from '@/features/admin/use-admin-users' export function AdminUsersPage() { const { t, i18n } = useTranslation() + const roleOptions = [ + { value: 'USER', label: t('adminUsers.roleUser') }, + { value: 'SKILL_ADMIN', label: t('adminUsers.roleReviewer') }, + { value: 'USER_ADMIN', label: t('adminUsers.roleUserAdmin') }, + { value: 'AUDITOR', label: t('adminUsers.roleAuditor') }, + { value: 'SUPER_ADMIN', label: t('adminUsers.roleSuperAdmin') }, + ] const [search, setSearch] = useState('') + const [searchInput, setSearchInput] = useState('') const [statusFilter, setStatusFilter] = useState('') const [page, setPage] = useState(0) const [selectedUser, setSelectedUser] = useState(null) @@ -51,9 +59,28 @@ export function AdminUsersPage() { return new Date(dateString).toLocaleString(i18n.language) } + useEffect(() => { + setPage(0) + }, [search, statusFilter]) + + const applySearch = () => { + setSearch(searchInput.trim()) + } + + const clearSearch = () => { + setSearchInput('') + setSearch('') + } + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + applySearch() + } + } + const handleChangeRole = (user: AdminUser) => { setSelectedUser(user) - setNewRole(user.platformRoles[0] || '') + setNewRole(user.platformRoles[0] || 'USER') setRoleDialogOpen(true) } @@ -64,7 +91,7 @@ export function AdminUsersPage() { } const confirmRoleChange = async () => { - if (!selectedUser) return + if (!selectedUser || !newRole || newRole === (selectedUser.platformRoles[0] || 'USER')) return try { await updateRoleMutation.mutateAsync({ userId: selectedUser.userId, role: newRole }) setRoleDialogOpen(false) @@ -97,19 +124,36 @@ export function AdminUsersPage() { -
- setSearch(e.target.value)} - className="flex-1" - /> - +
+
+ +
+ setSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="flex-1" + /> + + +
+

{t('adminUsers.searchHint')}

+
+
+ + +
@@ -238,12 +282,11 @@ export function AdminUsersPage() {
@@ -251,7 +294,15 @@ export function AdminUsersPage() { -
diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 46210b30..b7641c26 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -4,6 +4,7 @@ import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { EmptyState } from '@/shared/components/empty-state' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { useMyNamespaces } from '@/shared/hooks/use-skill-queries' export function MyNamespacesPage() { @@ -37,13 +38,11 @@ export function MyNamespacesPage() { return (
-
-
-

{t('myNamespaces.title')}

-

{t('myNamespaces.subtitle')}

-
- -
+ {t('myNamespaces.create')}} + /> {namespaces && namespaces.length > 0 ? (
diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index c2f6d98b..5a94b3d4 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -3,7 +3,9 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { EmptyState } from '@/shared/components/empty-state' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { useMySkills } from '@/shared/hooks/use-skill-queries' +import { formatCompactCount } from '@/shared/lib/number-format' export function MySkillsPage() { const navigate = useNavigate() @@ -14,6 +16,26 @@ export function MySkillsPage() { navigate({ to: `/space/${namespace}/${slug}` }) } + const resolveStatusLabel = (status?: string) => { + if (status === 'PENDING_REVIEW') { + return t('mySkills.statusPendingReview') + } + if (status === 'PUBLISHED') { + return t('mySkills.statusPublished') + } + return status + } + + const resolveStatusClassName = (status?: string) => { + if (status === 'PENDING_REVIEW') { + return 'bg-amber-500/10 text-amber-500 border-amber-500/20' + } + if (status === 'PUBLISHED') { + return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' + } + return 'bg-secondary/60 text-muted-foreground border-border/40' + } + if (isLoading) { return (
@@ -26,15 +48,15 @@ export function MySkillsPage() { return (
-
-
-

{t('mySkills.title')}

-

{t('mySkills.subtitle')}

-
- -
+ + )} + /> {skills && skills.length > 0 ? (
@@ -57,11 +79,16 @@ export function MySkillsPage() { {skill.latestVersion && ( v{skill.latestVersion} )} + {skill.latestVersionStatus ? ( + + {resolveStatusLabel(skill.latestVersionStatus)} + + ) : null} - {skill.downloadCount} + {formatCompactCount(skill.downloadCount)}
diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 6081a7f1..b7af1374 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { NamespaceHeader } from '@/features/namespace/namespace-header' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { useNamespaceDetail, useNamespaceMembers } from '@/shared/hooks/use-skill-queries' export function NamespaceMembersPage() { @@ -34,11 +35,14 @@ export function NamespaceMembersPage() { return (
+
-
-

{t('members.title')}

+
diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 250e8471..16f4cf7a 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -4,6 +4,7 @@ import { Card } from '@/shared/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries' import { useReviewList } from '@/features/review/use-review-list' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' function ReviewListSection({ namespaceId }: { namespaceId?: number }) { const { t } = useTranslation() @@ -56,12 +57,10 @@ export function NamespaceReviewsPage() { return (
-
-

{t('nsReviews.title')}

-

- {namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')} -

-
+
) diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx index 446ad898..d3a2e5d5 100644 --- a/web/src/pages/dashboard/promotions.tsx +++ b/web/src/pages/dashboard/promotions.tsx @@ -5,6 +5,7 @@ import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) { const { t, i18n } = useTranslation() @@ -70,10 +71,7 @@ export function PromotionsPage() { const { t } = useTranslation() return (
-
-

{t('promotions.title')}

-

{t('promotions.subtitle')}

-
+ {t('promotions.tabPending')} diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 5230524c..febfb8e0 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -7,6 +7,7 @@ import { Select } from '@/shared/ui/select' import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queries' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { toast } from '@/shared/lib/toast' import { ApiError } from '@/api/client' @@ -30,6 +31,10 @@ export function PublishPage() { const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() + const handleRemoveSelectedFile = () => { + setSelectedFile(null) + } + const handlePublish = async () => { if (!selectedFile || !namespaceSlug) { toast.error(t('publish.selectRequired')) @@ -69,10 +74,7 @@ export function PublishPage() { return (
-
-

{t('publish.title')}

-

{t('publish.subtitle')}

-
+
@@ -123,15 +125,29 @@ export function PublishPage() {
{selectedFile && ( -
- - - - {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) +
+
+ + + + + {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) + +
+
)}
diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 5ce4225b..da5b9fc5 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -11,6 +11,7 @@ import { TableRow, } from '@/shared/ui/table' import { useReviewList } from '@/features/review/use-review-list' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' export function ReviewsPage() { const { t, i18n } = useTranslation() @@ -94,10 +95,7 @@ export function ReviewsPage() { return (
-
-

{t('reviews.title')}

-

{t('reviews.subtitle')}

-
+ diff --git a/web/src/pages/dashboard/stars.tsx b/web/src/pages/dashboard/stars.tsx index 5a570dc1..7e0e59f5 100644 --- a/web/src/pages/dashboard/stars.tsx +++ b/web/src/pages/dashboard/stars.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { SkillCard } from '@/features/skill/skill-card' import { useMyStars } from '@/shared/hooks/use-skill-queries' import { Card } from '@/shared/ui/card' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' export function MyStarsPage() { const { t } = useTranslation() @@ -21,10 +22,7 @@ export function MyStarsPage() { return (
-
-

{t('stars.title')}

-

{t('stars.subtitle')}

-
+ {!skills || skills.length === 0 ? ( {t('stars.empty')} diff --git a/web/src/pages/dashboard/tokens.tsx b/web/src/pages/dashboard/tokens.tsx index 034e0f96..5f8a478e 100644 --- a/web/src/pages/dashboard/tokens.tsx +++ b/web/src/pages/dashboard/tokens.tsx @@ -1,14 +1,15 @@ import { useTranslation } from 'react-i18next' import { TokenList } from '@/features/token/token-list' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' export function TokensPage() { const { t } = useTranslation() return (
-
-

{t('tokens.pageTitle')}

-

{t('tokens.pageSubtitle')}

-
+
) diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 053669b9..5556dfeb 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -1,6 +1,7 @@ import { startTransition, useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { Loader2 } from 'lucide-react' import type { SkillSummary } from '@/api/types' import { useAuth } from '@/features/auth/use-auth' import { SearchBar } from '@/features/search/search-bar' @@ -44,7 +45,7 @@ export function SearchPage() { const { isAuthenticated } = useAuth() const q = searchParams.q || '' - const sort = searchParams.sort || 'relevance' + const sort = searchParams.sort || 'newest' const page = searchParams.page ?? 0 const starredOnly = searchParams.starredOnly ?? false const [queryInput, setQueryInput] = useState(q) @@ -53,14 +54,18 @@ export function SearchPage() { setQueryInput(q) }, [q]) - const { data, isLoading } = useSearchSkills({ + const { data, isLoading, isFetching } = useSearchSkills({ q, sort, page, size: PAGE_SIZE, starredOnly, }) - const { data: starredSkills, isLoading: isLoadingStarred } = useMyStars(starredOnly && isAuthenticated) + const { + data: starredSkills, + isLoading: isLoadingStarred, + isFetching: isFetchingStarred, + } = useMyStars(starredOnly && isAuthenticated) useEffect(() => { const normalizedQuery = queryInput.trim() @@ -131,13 +136,19 @@ export function SearchPage() { : 0 const displayItems = starredOnly ? starredPageItems : (data?.items ?? []) const isPageLoading = starredOnly ? isLoadingStarred : isLoading + const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0) return (
{/* Search Bar */}
- +
{/* Sort And Filters */} @@ -177,6 +188,13 @@ export function SearchPage() { )}
+ {isUpdatingResults ? ( +
+ + {t('search.loadingMore')} +
+ ) : null} +
{t('search.filters.label')}
- {statusMessage ?

{statusMessage}

: null} {errorMessage ?

{errorMessage}

: null} diff --git a/web/src/shared/components/dashboard-page-header.tsx b/web/src/shared/components/dashboard-page-header.tsx new file mode 100644 index 00000000..33715403 --- /dev/null +++ b/web/src/shared/components/dashboard-page-header.tsx @@ -0,0 +1,31 @@ +import { useNavigate } from '@tanstack/react-router' +import { ArrowLeft } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/ui/button' + +interface DashboardPageHeaderProps { + title: string + subtitle?: string + actions?: React.ReactNode +} + +export function DashboardPageHeader({ title, subtitle, actions }: DashboardPageHeaderProps) { + const { t } = useTranslation() + const navigate = useNavigate() + + return ( +
+ +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ {actions} +
+
+ ) +} diff --git a/web/src/shared/components/toaster.tsx b/web/src/shared/components/toaster.tsx index 68fe8e35..b405dae2 100644 --- a/web/src/shared/components/toaster.tsx +++ b/web/src/shared/components/toaster.tsx @@ -1,14 +1,21 @@ import { Toaster as Sonner } from 'sonner' +import { CENTER_TOASTER_ID } from '@/shared/lib/toast' export function Toaster() { return ( = 1_000_000) { + const millions = value / 1_000_000 + return `${stripTrailingZero(millions)}M` + } + + if (value >= 1_000) { + const thousands = value / 1_000 + return `${stripTrailingZero(thousands)}K` + } + + return String(value) +} + +function stripTrailingZero(value: number): string { + const formatted = value >= 10 ? value.toFixed(0) : value.toFixed(1) + return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted +} diff --git a/web/src/shared/lib/toast.ts b/web/src/shared/lib/toast.ts index a4c9f7f1..8a9254f3 100644 --- a/web/src/shared/lib/toast.ts +++ b/web/src/shared/lib/toast.ts @@ -1,17 +1,38 @@ import { toast as sonnerToast, type ExternalToast } from 'sonner' +export const CENTER_TOASTER_ID = 'top-center' + +export function centeredToastOptions(options?: ExternalToast): ExternalToast { + return { + toasterId: CENTER_TOASTER_ID, + classNames: { + title: 'text-center font-semibold', + description: 'text-center', + ...options?.classNames, + }, + ...options, + } +} + +function withDefaultToaster(options?: ExternalToast): ExternalToast { + return { + toasterId: CENTER_TOASTER_ID, + ...options, + } +} + export const toast = { success: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.success(message, { description, ...options }) + sonnerToast.success(message, { description, ...withDefaultToaster(options) }) }, error: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.error(message, { description, ...options }) + sonnerToast.error(message, { description, ...withDefaultToaster(options) }) }, warning: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.warning(message, { description, ...options }) + sonnerToast.warning(message, { description, ...withDefaultToaster(options) }) }, info: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.info(message, { description, ...options }) + sonnerToast.info(message, { description, ...withDefaultToaster(options) }) }, promise: ( promise: Promise, @@ -21,6 +42,6 @@ export const toast = { error: string | ((error: Error) => string) } ) => { - return sonnerToast.promise(promise, options) + return sonnerToast.promise(promise, { ...options, toasterId: CENTER_TOASTER_ID }) }, } diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx index 1512c46d..d029b6f2 100644 --- a/web/src/shared/ui/dialog.tsx +++ b/web/src/shared/ui/dialog.tsx @@ -113,7 +113,7 @@ const DialogContent = React.forwardRef e.stopPropagation()} @@ -149,25 +149,25 @@ const DialogContent = React.forwardRef) => ( -
+
) DialogHeader.displayName = 'DialogHeader' const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
+
) DialogFooter.displayName = 'DialogFooter' const DialogTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -

+

) ) DialogTitle.displayName = 'DialogTitle' const DialogDescription = React.forwardRef>( ({ className, ...props }, ref) => ( -

+

) ) DialogDescription.displayName = 'DialogDescription'