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 cb2af098..f0978f65 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 @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.dto.ApiResponse; 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.TokenSummaryResponse; @@ -45,17 +46,20 @@ public class TokenController extends BaseApiController { } @GetMapping - public ApiResponse> list(@AuthenticationPrincipal PlatformPrincipal principal) { - var tokens = apiTokenService.listActiveTokens(principal.userId()); - var result = tokens.stream().map(t -> new TokenSummaryResponse( + public ApiResponse> list( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + var tokens = apiTokenService.listActiveTokens(principal.userId(), page, size); + var result = tokens.map(t -> new TokenSummaryResponse( t.getId(), t.getName(), t.getTokenPrefix(), t.getCreatedAt().toString(), t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" - )).toList(); - return ok("response.success.read", result); + )); + return ok("response.success.read", PageResponse.from(result)); } @DeleteMapping("/{id}") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java index 1f560ea8..5a4a2749 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -4,10 +4,10 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record LocalRegisterRequest( - @NotBlank(message = "用户名不能为空") + @NotBlank(message = "{validation.auth.local.username.notBlank}") String username, - @NotBlank(message = "密码不能为空") + @NotBlank(message = "{validation.auth.local.password.notBlank}") String password, - @Email(message = "邮箱格式不正确") + @Email(message = "{validation.auth.local.email.invalid}") String email ) {} 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 63eadf18..7b821bf4 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 @@ -1,11 +1,13 @@ package com.iflytek.skillhub.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; public record TokenCreateRequest( @NotBlank(message = "{validation.token.name.notBlank}") + @Size(max = 64, message = "{validation.token.name.size}") String name, List scopes ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 822039e8..a2a4a028 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -1,16 +1,19 @@ package com.iflytek.skillhub.exception; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -27,39 +30,44 @@ public class GlobalExceptionHandler { } @ExceptionHandler(LocalizedException.class) - public ResponseEntity> handleLocalizedError(LocalizedException ex) { + public ResponseEntity> handleLocalizedError(LocalizedException ex, HttpServletRequest request) { HttpStatus status = ex.status(); + logHandledException(status, ex.messageCode(), request); return ResponseEntity.status(status).body( apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(AuthFlowException.class) - public ResponseEntity> handleAuthFlowException(AuthFlowException ex) { + public ResponseEntity> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) { HttpStatus status = ex.getStatus(); + logHandledException(status, ex.getMessageCode(), request); return ResponseEntity.status(status).body( apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs())); } @ExceptionHandler(DomainBadRequestException.class) - public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex) { + public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex, HttpServletRequest request) { + logHandledException(HttpStatus.BAD_REQUEST, ex.messageCode(), request); return ResponseEntity.badRequest().body( apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(DomainForbiddenException.class) - public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex) { + public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex, HttpServletRequest request) { + logHandledException(HttpStatus.FORBIDDEN, ex.messageCode(), request); return ResponseEntity.status(HttpStatus.FORBIDDEN).body( apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(DomainNotFoundException.class) - public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex) { + public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex, HttpServletRequest request) { + logHandledException(HttpStatus.NOT_FOUND, ex.messageCode(), request); return ResponseEntity.status(HttpStatus.NOT_FOUND).body( apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { String msg = ex.getBindingResult().getFieldErrors().stream() .findFirst() .map(FieldError::getDefaultMessage) @@ -67,6 +75,7 @@ public class GlobalExceptionHandler { .findFirst() .map(error -> error.getDefaultMessage()) .orElse(null)); + logHandledException(HttpStatus.BAD_REQUEST, "validation.request.invalid", request); if (msg == null || msg.isBlank()) { return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest")); } @@ -74,22 +83,52 @@ public class GlobalExceptionHandler { } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { + public ResponseEntity> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) { + logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request); return ResponseEntity.badRequest().body( apiResponseFactory.error(400, "error.badRequest")); } @ExceptionHandler(SecurityException.class) - public ResponseEntity> handleForbidden(SecurityException ex) { + public ResponseEntity> handleForbidden(SecurityException ex, HttpServletRequest request) { + logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request); return ResponseEntity.status(HttpStatus.FORBIDDEN).body( apiResponseFactory.error(403, "error.forbidden")); } @ExceptionHandler(Exception.class) - public ResponseEntity> handleGlobalException(Exception ex) { - String requestId = MDC.get("requestId"); - logger.error("Unhandled exception [requestId={}]", requestId, ex); + public ResponseEntity> handleGlobalException(Exception ex, HttpServletRequest request) { + logger.error( + "Unhandled API exception [requestId={}, method={}, path={}, userId={}]", + MDC.get("requestId"), + request.getMethod(), + request.getRequestURI(), + resolveUserId(request), + ex + ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( apiResponseFactory.error(500, "error.internal")); } + + private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) { + logger.info( + "API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]", + MDC.get("requestId"), + status.value(), + request.getMethod(), + request.getRequestURI(), + resolveUserId(request), + messageCode + ); + } + + private String resolveUserId(HttpServletRequest request) { + if (!(request.getUserPrincipal() instanceof Authentication authentication)) { + return "anonymous"; + } + if (authentication.getPrincipal() instanceof PlatformPrincipal principal) { + return principal.userId(); + } + return authentication.getName(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index a5d36a73..4987bf9d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; @@ -15,6 +18,7 @@ import java.io.IOException; @Component public class ApiAccessDeniedHandler implements AccessDeniedHandler { + private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class); private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; @@ -27,6 +31,13 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler { public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + logger.info( + "Forbidden API request [requestId={}, method={}, path={}, reason={}]", + MDC.get("requestId"), + request.getMethod(), + request.getRequestURI(), + accessDeniedException.getClass().getSimpleName() + ); ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index d9bfe089..bac1e010 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -15,6 +18,7 @@ import java.io.IOException; @Component public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class); private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; @@ -27,6 +31,13 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + logger.info( + "Unauthorized API request [requestId={}, method={}, path={}, reason={}]", + MDC.get("requestId"), + request.getMethod(), + request.getRequestURI(), + authException.getClass().getSimpleName() + ); ApiResponse body = apiResponseFactory.error(401, "error.auth.required"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); 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 6f2684f0..219a5727 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 @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.data.domain.PageRequest; @@ -19,6 +20,7 @@ import java.util.stream.Collectors; @Service public class MySkillAppService { + private static final int STAR_PAGE_SIZE = 200; private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; @@ -68,10 +70,7 @@ public class MySkillAppService { } public List listMyStars(String userId) { - List stars = skillStarRepository.findByUserId( - userId, - PageRequest.of(0, 200) - ).getContent(); + List stars = loadAllStars(userId); List skillIds = stars.stream() .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) @@ -111,6 +110,24 @@ public class MySkillAppService { .toList(); } + private List loadAllStars(String userId) { + List stars = new java.util.ArrayList<>(); + int pageNumber = 0; + + while (true) { + Page page = skillStarRepository.findByUserId( + userId, + PageRequest.of(pageNumber, STAR_PAGE_SIZE) + ); + stars.addAll(page.getContent()); + + if (!page.hasNext()) { + return stars; + } + pageNumber++; + } + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsById, diff --git a/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql new file mode 100644 index 00000000..2038bc4c --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql @@ -0,0 +1,6 @@ +ALTER TABLE api_token + ALTER COLUMN name TYPE VARCHAR(64); + +CREATE UNIQUE INDEX uk_api_token_user_active_name + ON api_token (user_id, LOWER(name)) + WHERE revoked_at IS NULL; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 329db1c0..ac4ab915 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -14,7 +14,12 @@ validation.namespace.displayName.size=Display name must not exceed 128 character validation.namespace.description.size=Description must not exceed 512 characters 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.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 +error.token.name.duplicate=You already have a token with this name error.auth.required=Authentication required error.auth.local.username.exists=Username already exists diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 6d839aea..defc9b8b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -14,7 +14,12 @@ validation.namespace.displayName.size=显示名称长度不能超过 128 个字 validation.namespace.description.size=描述长度不能超过 512 个字符 validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 +validation.auth.local.username.notBlank=用户名不能为空 +validation.auth.local.password.notBlank=密码不能为空 +validation.auth.local.email.invalid=邮箱格式不正确 validation.token.name.notBlank=Token 名称不能为空 +validation.token.name.size=Token 名称最多 64 个字符 +error.token.name.duplicate=你已经有同名 Token error.auth.required=需要先登录 error.auth.local.username.exists=用户名已存在 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 b0d84d8e..a443d4ba 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 @@ -95,6 +95,23 @@ class LocalAuthControllerTest { verify(skillHubMetrics).incrementUserRegister(); } + @Test + void register_rejectsInvalidEmailFormat() throws Exception { + given(localAuthService.register("bob", "Abcd123!", "not-an-email")) + .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid")); + + mockMvc.perform(post("/api/v1/auth/local/register") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"bob","password":"Abcd123!","email":"not-an-email"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value("邮箱格式不正确")); + + verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); + } + @Test void login_failure_recordsFailureMetric() throws Exception { given(localAuthService.login("alice", "wrong")) 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 52b98554..7b133405 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 @@ -4,6 +4,7 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -18,11 +19,18 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; 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.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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; @@ -61,4 +69,63 @@ class TokenControllerTest { verify(apiTokenService).revokeToken(7L, "user-42"); } + + @Test + void create_rejectsNamesLongerThan64Characters() 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")) + ); + given(apiTokenService.createToken(anyString(), anyString(), anyString())) + .willThrow(new DomainBadRequestException("validation.token.name.size")); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); + } + + @Test + void list_returns_paginated_tokens() 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 tokenPage = new PageImpl<>( + List.of( + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"), + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "deploy", "sk_654321", "hash-2", "[]") + ), + PageRequest.of(1, 10), + 12 + ); + var first = tokenPage.getContent().get(0); + var second = tokenPage.getContent().get(1); + org.springframework.test.util.ReflectionTestUtils.setField(first, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0)); + org.springframework.test.util.ReflectionTestUtils.setField(second, "id", 8L); + org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 11, 0)); + + given(apiTokenService.listActiveTokens("user-42", 1, 10)).willReturn(tokenPage); + + mockMvc.perform(get("/api/v1/tokens") + .with(authentication(auth)) + .param("page", "1") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].name").value("cli")) + .andExpect(jsonPath("$.data.items[1].name").value("deploy")) + .andExpect(jsonPath("$.data.total").value(12)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(10)); + } } 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 new file mode 100644 index 00000000..dcaa01d5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +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.SkillVisibility; +import com.iflytek.skillhub.domain.social.SkillStar; +import com.iflytek.skillhub.domain.social.SkillStarRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class MySkillAppServiceTest { + + @Mock + private SkillRepository skillRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private SkillStarRepository skillStarRepository; + + private MySkillAppService service; + + @BeforeEach + void setUp() { + service = new MySkillAppService(skillRepository, namespaceRepository, skillVersionRepository, skillStarRepository); + } + + @Test + void listMyStars_loadsAllPagesOfStars() { + SkillStar firstStar = new SkillStar(1L, "user-1"); + ReflectionTestUtils.setField(firstStar, "createdAt", LocalDateTime.of(2026, 3, 14, 10, 0)); + SkillStar secondStar = new SkillStar(2L, "user-1"); + ReflectionTestUtils.setField(secondStar, "createdAt", LocalDateTime.of(2026, 3, 14, 11, 0)); + + given(skillStarRepository.findByUserId("user-1", PageRequest.of(0, 200))) + .willReturn(new PageImpl<>(List.of(firstStar), PageRequest.of(0, 200), 201)); + given(skillStarRepository.findByUserId("user-1", PageRequest.of(1, 200))) + .willReturn(new PageImpl<>(List.of(secondStar), PageRequest.of(1, 200), 201)); + + Skill firstSkill = new Skill(1L, "first-skill", "user-1", SkillVisibility.PUBLIC); + firstSkill.setDisplayName("First Skill"); + firstSkill.setSummary("first summary"); + ReflectionTestUtils.setField(firstSkill, "id", 1L); + ReflectionTestUtils.setField(firstSkill, "starCount", 1); + ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L); + ReflectionTestUtils.setField(firstSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 10, 0)); + + Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC); + secondSkill.setDisplayName("Second Skill"); + secondSkill.setSummary("second summary"); + ReflectionTestUtils.setField(secondSkill, "id", 2L); + ReflectionTestUtils.setField(secondSkill, "starCount", 2); + ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L); + ReflectionTestUtils.setField(secondSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 11, 0)); + + given(skillRepository.findByIdIn(List.of(1L, 2L))).willReturn(List.of(firstSkill, secondSkill)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); + + var stars = service.listMyStars("user-1"); + + assertThat(stars).hasSize(2); + assertThat(stars.get(0).slug()).isEqualTo("second-skill"); + assertThat(stars.get(1).slug()).isEqualTo("first-skill"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java index 8fb654d2..ae1213d3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -21,7 +21,7 @@ public class ApiToken { @Column(name = "user_id", nullable = false) private String userId; - @Column(nullable = false, length = 128) + @Column(nullable = false, length = 64) private String name; @Column(name = "token_prefix", nullable = false, length = 16) 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 1addd5f7..574bc418 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 @@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; public class LocalAuthService { private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); + 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); @@ -57,6 +58,7 @@ public class LocalAuthService { } String normalizedEmail = normalizeEmail(email); + validateEmail(normalizedEmail); if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) { throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists"); } @@ -190,4 +192,13 @@ public class LocalAuthService { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid"); } } + + private void validateEmail(String email) { + if (email == null) { + return; + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"); + } + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index cc77c92a..ba7f919f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.ApiToken; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -11,4 +13,6 @@ public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); + Page findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable); + boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name); } 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 f51b60a0..8d3cf4d0 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 @@ -2,6 +2,10 @@ 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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +24,7 @@ public class ApiTokenService { private static final String TOKEN_PREFIX = "sk_"; private static final int TOKEN_BYTES = 32; + private static final int MAX_NAME_LENGTH = 64; private final SecureRandom secureRandom = new SecureRandom(); private final ApiTokenRepository tokenRepo; @@ -31,14 +36,21 @@ public class ApiTokenService { @Transactional public TokenCreateResult createToken(String userId, String name, String scopeJson) { + String normalizedName = normalizeName(name); + validateTokenName(userId, normalizedName); + byte[] randomBytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(randomBytes); String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); String tokenHash = sha256(rawToken); String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8)); - ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson); - token = tokenRepo.save(token); + ApiToken token = new ApiToken(userId, normalizedName, prefix, tokenHash, scopeJson); + try { + token = tokenRepo.save(token); + } catch (DataIntegrityViolationException ex) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } return new TokenCreateResult(rawToken, token); } @@ -61,6 +73,12 @@ public class ApiTokenService { return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId); } + public Page listActiveTokens(String userId, int page, int size) { + int resolvedPage = Math.max(page, 0); + int resolvedSize = Math.max(size, 1); + return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId, PageRequest.of(resolvedPage, resolvedSize)); + } + @Transactional public void touchLastUsed(ApiToken token) { token.setLastUsedAt(LocalDateTime.now()); @@ -76,4 +94,23 @@ public class ApiTokenService { throw new RuntimeException("SHA-256 not available", e); } } + + private String normalizeName(String name) { + if (name == null) { + return ""; + } + return name.trim(); + } + + private void validateTokenName(String userId, String name) { + if (name.isBlank()) { + throw new DomainBadRequestException("validation.token.name.notBlank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DomainBadRequestException("validation.token.name.size"); + } + if (tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(userId, name)) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } + } } 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 8fc505cc..6eaec2b2 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 @@ -131,4 +131,13 @@ class LocalAuthServiceTest { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("error.auth.local.accountDisabled"); } + + @Test + void register_rejectsInvalidEmailFormat() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + + assertThatThrownBy(() -> service.register("Alice", "Abcd123!", "not-an-email")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("validation.auth.local.email.invalid"); + } } 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 new file mode 100644 index 00000000..d23e4c9c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.token; + +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ApiTokenServiceTest { + + @Mock + private ApiTokenRepository tokenRepo; + + private ApiTokenService service; + + @BeforeEach + void setUp() { + service = new ApiTokenService(tokenRepo); + } + + @Test + void createToken_rejectsNamesLongerThan64Characters() { + String longName = "a".repeat(65); + + assertThatThrownBy(() -> service.createToken("user-1", longName, "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.name.size"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_rejectsDuplicateActiveNamesIgnoringCase() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.token.name.duplicate"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_trimsNameBeforeCheckingDuplicates() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class); + + verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"); + verify(tokenRepo, never()).save(any()); + } +} diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml index 573a4869..222d2980 100644 --- a/server/skillhub-search/pom.xml +++ b/server/skillhub-search/pom.xml @@ -27,5 +27,10 @@ org.springframework spring-context + + org.springframework.boot + spring-boot-starter-test + test + 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 12727810..b23d5f14 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 @@ -12,6 +12,7 @@ import java.util.Set; @Service public class PostgresFullTextQueryService implements SearchQueryService { + private static final int SHORT_KEYWORD_LENGTH = 2; private final EntityManager entityManager; @@ -21,6 +22,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; Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); @@ -48,8 +52,17 @@ public class PostgresFullTextQueryService implements SearchQueryService { } // Full-text search - if (query.keyword() != null && !query.keyword().isBlank()) { - sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + 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(") "); + } else { + sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + } } // Sorting @@ -59,7 +72,7 @@ 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()) && query.keyword() != null && !query.keyword().isBlank()) { + } else if ("relevance".equals(query.sortBy()) && hasKeyword && !useShortKeywordFallback) { sql.append("ORDER BY ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC "); } else { sql.append("ORDER BY updated_at DESC "); @@ -80,8 +93,12 @@ public class PostgresFullTextQueryService implements SearchQueryService { nativeQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - nativeQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (useShortKeywordFallback) { + nativeQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); + } else { + nativeQuery.setParameter("keyword", normalizedKeyword); + } } nativeQuery.setParameter("limit", query.size()); @@ -115,12 +132,23 @@ public class PostgresFullTextQueryService implements SearchQueryService { countQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - countQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (useShortKeywordFallback) { + countQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); + } else { + countQuery.setParameter("keyword", normalizedKeyword); + } } long total = ((Number) countQuery.getSingleResult()).longValue(); return new SearchResult(skillIds, total, query.page(), query.size()); } + + private String normalizeKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } } 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 new file mode 100644 index 00000000..e91954d9 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.search.SearchQuery; +import com.iflytek.skillhub.search.SearchVisibilityScope; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PostgresFullTextQueryServiceTest { + + @Test + void shortKeywordsShouldUseLikeFallback() { + 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(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "ai", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("keywordLike", "%ai%"); + verify(countQuery).setParameter("keywordLike", "%ai%"); + verify(nativeQuery, never()).setParameter("keyword", "ai"); + verify(countQuery, never()).setParameter("keyword", "ai"); + } + + @Test + void longerKeywordsShouldKeepFullTextSearch() { + 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(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "agent", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("keyword", "agent"); + verify(countQuery).setParameter("keyword", "agent"); + verify(nativeQuery, never()).setParameter("keywordLike", "%agent%"); + verify(countQuery, never()).setParameter("keywordLike", "%agent%"); + } + + @Test + void shortKeywordSqlShouldAvoidTsRankOrdering() { + 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( + "go", + 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("LOWER(title) LIKE LOWER(:keywordLike)"); + assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ts_rank"); + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 465dfddc..54ee7908 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -170,15 +170,54 @@ type ApiEnvelope = { requestId: string } -export async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { +type RequestWithTimeout = RequestInit & { + timeoutMs?: number +} + +function createRequestSignal(init?: RequestWithTimeout): { signal?: AbortSignal, cleanup: () => void } { + if (!init?.timeoutMs && !init?.signal) { + return { signal: init?.signal ?? undefined, cleanup: () => {} } + } + + const controller = new AbortController() + const timeoutId = init?.timeoutMs ? window.setTimeout(() => controller.abort('timeout'), init.timeoutMs) : undefined + const abortListener = () => controller.abort() + + if (init?.signal) { + if (init.signal.aborted) { + controller.abort() + } else { + init.signal.addEventListener('abort', abortListener, { once: true }) + } + } + + return { + signal: controller.signal, + cleanup: () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + init?.signal?.removeEventListener('abort', abortListener) + }, + } +} + +export async function fetchJson(input: RequestInfo | URL, init?: RequestWithTimeout): Promise { + const { signal, cleanup } = createRequestSignal(init) let response: Response try { response = await fetch(withBaseUrl(input), { ...init, + signal, headers: withRequestHeaders(init?.headers), }) - } catch { + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new ApiError('error.request.timeout', 408) + } throw new ApiError('Network error', 0) + } finally { + cleanup() } let json: ApiEnvelope | null = null @@ -372,19 +411,28 @@ export const accountApi = { } export const tokenApi = { - async getTokens(): Promise { - const tokens = await unwrap(client.GET('/api/v1/tokens', { + async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> { + const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', { + params: { + query: { + page: params?.page ?? 0, + size: params?.size ?? 10, + }, + }, headers: withRequestHeaders(), } as never) as never) - return tokens - .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) - .map((token) => ({ - ...token, - id: token.id!, - name: token.name!, - tokenPrefix: token.tokenPrefix!, - createdAt: token.createdAt!, - })) + return { + ...page, + items: page.items + .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) + .map((token) => ({ + ...token, + id: token.id!, + name: token.name!, + tokenPrefix: token.tokenPrefix!, + createdAt: token.createdAt!, + })), + } }, async createToken(request: CreateTokenRequest): Promise { @@ -408,14 +456,23 @@ export const tokenApi = { }, async deleteToken(tokenId: number): Promise { - await unwrap(client.DELETE('/api/v1/tokens/{id}', { + const { error, response } = await client.DELETE('/api/v1/tokens/{id}', { params: { path: { id: tokenId, }, }, headers: withCsrf(), - }) as never) + } as never) + + if (response.status === 204) { + return + } + + const envelope = (error && isApiEnvelope(error) ? error : null) as { msg?: string } | null + if (!response.ok || error) { + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + } }, } diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx index 53adaa5e..bdba8277 100644 --- a/web/src/app/providers.tsx +++ b/web/src/app/providers.tsx @@ -19,12 +19,18 @@ const queryClient = new QueryClient({ }, }, queryCache: new QueryCache({ - onError: (error) => { + onError: (error, query) => { + if (query.meta?.skipGlobalErrorHandler) { + return + } handleApiError(error) }, }), mutationCache: new MutationCache({ onError: (error, _variables, _context, mutation) => { + if (mutation.meta?.skipGlobalErrorHandler) { + return + } // Only auto-handle if the mutation doesn't have its own onError if (!mutation.options.onError) { handleApiError(error) diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 60c045f1..4d25d86e 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,22 +1,39 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' interface SearchBarProps { defaultValue?: string + value?: string placeholder?: string + onChange?: (query: string) => void onSearch?: (query: string) => void } -export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBarProps) { +export function SearchBar({ defaultValue = '', value, placeholder, onChange, onSearch }: SearchBarProps) { const { t } = useTranslation() const [query, setQuery] = useState(defaultValue) + const isControlled = value !== undefined + const currentQuery = isControlled ? value : query + + useEffect(() => { + if (!isControlled) { + setQuery(defaultValue) + } + }, [defaultValue, isControlled]) + + const handleChange = (nextQuery: string) => { + if (!isControlled) { + setQuery(nextQuery) + } + onChange?.(nextQuery) + } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (onSearch) { - onSearch(query) + onSearch(currentQuery) } } @@ -38,8 +55,8 @@ export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBa setQuery(e.target.value)} + value={currentQuery} + onChange={(e) => 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" /> diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 6b164983..5e01e119 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -9,13 +9,48 @@ interface MarkdownRendererProps { } export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { - const containerClassName = [className, 'prose prose-sm max-w-none dark:prose-invert'] + const containerClassName = [ + className, + 'prose prose-sm max-w-none break-words [overflow-wrap:anywhere] dark:prose-invert', + ] .filter(Boolean) .join(' ') return (
- + ( +
+
{children}
+
+ ), + code: ({ className: codeClassName, children, ...props }) => { + const isInline = !codeClassName?.includes('language-') + + if (isInline) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) + }, + table: ({ children }) => ( +
+ {children}
+
+ ), + }} + > {content}
diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index e688abc5..c1b6a9d8 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -10,13 +10,13 @@ interface SkillCardProps { export function SkillCard({ skill, onClick }: SkillCardProps) { return ( {/* Hover gradient border effect */}
-
+

{skill.displayName} @@ -30,7 +30,7 @@ export function SkillCard({ skill, onClick }: SkillCardProps) {

)} -
+
{skill.latestVersion && ( v{skill.latestVersion} diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index c4106756..f5ab55e1 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -47,6 +47,7 @@ export function useToggleStar(skillId: number) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'star'] }) queryClient.invalidateQueries({ queryKey: ['skills'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'stars'] }) }, }) } diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 9d93b543..d23e9959 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -19,33 +19,57 @@ import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types' interface CreateTokenDialogProps { children: React.ReactNode + existingNames?: string[] } -export function CreateTokenDialog({ children }: CreateTokenDialogProps) { +const MAX_TOKEN_NAME_LENGTH = 64 + +export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenDialogProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [name, setName] = useState('') const [createdToken, setCreatedToken] = useState(null) + const [nameError, setNameError] = useState(null) const queryClient = useQueryClient() + const normalizedName = name.trim() + const hasDuplicateName = existingNames.some( + (existingName) => existingName.trim().toLocaleLowerCase() === normalizedName.toLocaleLowerCase() + ) + const createMutation = useMutation({ mutationFn: (request: CreateTokenRequest) => tokenApi.createToken(request), onSuccess: (data) => { setCreatedToken(data) setName('') + setNameError(null) queryClient.invalidateQueries({ queryKey: ['tokens'] }) }, }) const handleCreate = () => { - if (!name.trim()) return - createMutation.mutate({ name: name.trim() }) + if (!normalizedName) { + setNameError(t('createToken.nameRequired')) + return + } + if (normalizedName.length > MAX_TOKEN_NAME_LENGTH) { + setNameError(t('createToken.nameTooLong', { max: MAX_TOKEN_NAME_LENGTH })) + return + } + if (hasDuplicateName) { + setNameError(t('createToken.nameDuplicate')) + return + } + + setNameError(null) + createMutation.mutate({ name: normalizedName }) } const handleClose = () => { setOpen(false) setCreatedToken(null) setName('') + setNameError(null) createMutation.reset() } @@ -54,10 +78,22 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) { try { await navigator.clipboard.writeText(createdToken.token) - toast.success(t('createToken.copySuccess')) + toast.success(t('createToken.copySuccess'), undefined, { + position: 'top-center', + classNames: { + title: 'text-center font-semibold', + description: 'text-center', + }, + }) } catch (error) { console.error('Failed to copy token:', error) - toast.error(t('createToken.copyFailed')) + toast.error(t('createToken.copyFailed'), undefined, { + position: 'top-center', + classNames: { + title: 'text-center font-semibold', + description: 'text-center', + }, + }) } } @@ -80,22 +116,40 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) { id="token-name" placeholder={t('createToken.namePlaceholder')} value={name} - onChange={(e) => setName(e.target.value)} + maxLength={MAX_TOKEN_NAME_LENGTH} + onChange={(e) => { + setName(e.target.value) + if (nameError) { + setNameError(null) + } + }} onKeyDown={(e) => { if (e.key === 'Enter') { handleCreate() } }} + aria-invalid={nameError || hasDuplicateName ? 'true' : 'false'} /> +
+ + {nameError ?? (hasDuplicateName && normalizedName ? t('createToken.nameDuplicate') : '')} + + + {normalizedName.length}/{MAX_TOKEN_NAME_LENGTH} + +
+ {createMutation.error ? ( +

{createMutation.error.message}

+ ) : null} diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 9e9a53fb..0cef270f 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -13,28 +13,67 @@ import { } from '@/shared/ui/table' 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 { formatLocalDateTime } from '@/shared/lib/date-time' import type { ApiToken } from '@/api/types' +const PAGE_SIZE = 10 +type TokenPage = { items: ApiToken[]; total: number; page: number; size: number } + export function TokenList() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const queryClient = useQueryClient() + const [page, setPage] = useState(0) const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; tokenId?: number; name?: string }>({ open: false, }) - const { data: tokens, isLoading } = useQuery({ - queryKey: ['tokens'], - queryFn: tokenApi.getTokens, + const { data: tokenPage, isLoading, isError, error } = useQuery({ + queryKey: ['tokens', page, PAGE_SIZE], + queryFn: () => tokenApi.getTokens({ page, size: PAGE_SIZE }), + meta: { + skipGlobalErrorHandler: true, + }, }) const deleteMutation = useMutation({ mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId), + onMutate: async (tokenId) => { + await queryClient.cancelQueries({ queryKey: ['tokens'] }) + + const previousPages = queryClient.getQueriesData({ queryKey: ['tokens'] }) + queryClient.setQueriesData({ queryKey: ['tokens'] }, (current) => { + if (!current) { + return current + } + + const nextItems = current.items.filter((token) => token.id !== tokenId) + if (nextItems.length === current.items.length) { + return current + } + + return { + ...current, + items: nextItems, + total: Math.max(current.total - 1, 0), + } + }) + + return { previousPages } + }, onSuccess: () => { + if (tokenPage && tokenPage.items.length === 1 && page > 0) { + setPage(page - 1) + } + setDeleteDialog({ open: false }) queryClient.invalidateQueries({ queryKey: ['tokens'] }) toast.success(t('token.deleteSuccess')) }, - onError: () => { + onError: (_error, _tokenId, context) => { + context?.previousPages.forEach(([queryKey, previousPage]) => { + queryClient.setQueryData(queryKey, previousPage) + }) toast.error(t('token.deleteFailed')) }, }) @@ -45,29 +84,48 @@ export function TokenList() { const confirmDelete = async () => { if (deleteDialog.tokenId) { - deleteMutation.mutate(deleteDialog.tokenId) + await deleteMutation.mutateAsync(deleteDialog.tokenId) } } const formatDate = (dateString?: string | null) => { if (!dateString) return '-' - return new Date(dateString).toLocaleString('zh-CN') + return formatLocalDateTime(dateString, i18n.language) } + const tokens = tokenPage?.items ?? [] + const totalPages = tokenPage ? Math.max(Math.ceil(tokenPage.total / tokenPage.size), 1) : 1 + if (isLoading) { return
{t('token.loading')}
} + if (isError) { + return ( +
+
+

{t('token.title')}

+ + + +
+
+ {error instanceof Error ? error.message : t('apiError.unknown')} +
+
+ ) + } + return (

{t('token.title')}

- + token.name)}>
- {!tokens || tokens.length === 0 ? ( + {!tokenPage || tokenPage.total === 0 ? (

{t('token.empty')}

{t('token.emptyHint')}

@@ -114,6 +172,10 @@ export function TokenList() {
)} + {tokenPage && tokenPage.total > PAGE_SIZE ? ( + + ) : null} + setDeleteDialog({ ...deleteDialog, open })} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8ac9a317..1f10da9f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -111,6 +111,10 @@ "password": "Password", "usernamePlaceholder": "Enter username", "passwordPlaceholder": "Enter password", + "usernameRequired": "Username is required", + "passwordRequired": "Password is required", + "showPassword": "Show password", + "hidePassword": "Hide password", "submitting": "Logging in...", "submit": "Login", "noAccount": "Don't have an account?", @@ -322,6 +326,7 @@ "emptyDescription": "No skills have been published in this namespace yet" }, "skillDetail": { + "back": "Back", "notFound": "Skill not found", "notFoundDesc": "This skill may have been deleted or never existed", "loginRequired": "Login Required", @@ -393,6 +398,9 @@ "description": "Create a new API Token for CLI or API access", "nameLabel": "Token Name", "namePlaceholder": "e.g.: my-cli-token", + "nameRequired": "Token name is required", + "nameTooLong": "Token name must be at most {{max}} characters", + "nameDuplicate": "You already have a token with this name", "creating": "Creating...", "create": "Create", "successTitle": "Token Created", @@ -462,6 +470,10 @@ "success": "Published Successfully", "successDescription": "{{skill}} has been submitted for review and will be available after admin approval", "error": "Publish Failed", + "timeoutTitle": "Publish timed out", + "timeoutDescription": "The publish request took too long. Please check the skill list later or try again.", + "versionExistsTitle": "Version already exists", + "versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.", "selectRequired": "Please select namespace and file" }, "toast": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 031e3d41..ee3fd5af 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -111,6 +111,10 @@ "password": "密码", "usernamePlaceholder": "输入用户名", "passwordPlaceholder": "输入密码", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", "submitting": "登录中...", "submit": "登录", "noAccount": "还没有账号?", @@ -322,6 +326,7 @@ "emptyDescription": "该命名空间下还没有发布任何技能" }, "skillDetail": { + "back": "返回上一页", "notFound": "技能不存在", "notFoundDesc": "该技能可能已被删除或从未存在", "loginRequired": "需要登录", @@ -393,6 +398,9 @@ "description": "创建一个新的 API Token 用于 CLI 或 API 访问", "nameLabel": "Token 名称", "namePlaceholder": "例如: my-cli-token", + "nameRequired": "请输入 Token 名称", + "nameTooLong": "Token 名称最多 {{max}} 个字符", + "nameDuplicate": "你已经有同名 Token", "creating": "创建中...", "create": "创建", "successTitle": "Token 创建成功", @@ -462,6 +470,10 @@ "success": "发布成功", "successDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用", "error": "发布失败", + "timeoutTitle": "发布请求超时", + "timeoutDescription": "本次发布等待时间过长,请稍后到技能列表确认结果,或重新尝试发布。", + "versionExistsTitle": "版本号已存在", + "versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。", "selectRequired": "请选择命名空间和文件" }, "toast": { diff --git a/web/src/main.tsx b/web/src/main.tsx index b5d7863f..6ae2314f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,8 @@ -import React from 'react' import ReactDOM from 'react-dom/client' import { App } from './app/providers' import './i18n/config' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - - - , + , ) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index faa3cf88..5230524c 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -8,6 +8,17 @@ import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queries' import { toast } from '@/shared/lib/toast' +import { ApiError } from '@/api/client' + +function isVersionExistsMessage(message?: string): boolean { + if (!message) { + return false + } + + return message.includes('error.skill.version.exists') + || message.includes('Version already exists') + || message.includes('版本已存在') +} export function PublishPage() { const { t } = useTranslation() @@ -39,6 +50,19 @@ export function PublishPage() { ) navigate({ to: '/dashboard/skills' }) } catch (error) { + if (error instanceof ApiError && error.status === 408) { + toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription')) + return + } + + if (error instanceof ApiError && isVersionExistsMessage(error.serverMessage || error.message)) { + toast.error( + t('publish.versionExistsTitle'), + t('publish.versionExistsDescription'), + ) + return + } + toast.error(t('publish.error'), error instanceof Error ? error.message : '') } } @@ -113,7 +137,7 @@ export function PublishPage() {

) -} \ No newline at end of file +} diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 8ddd4ba4..5a394212 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { Eye, EyeOff } from 'lucide-react' import { getDirectAuthRuntimeConfig } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry' @@ -18,6 +19,8 @@ export function LoginPage() { const directAuthConfig = getDirectAuthRuntimeConfig() const [username, setUsername] = useState('') const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({}) const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh' const { data: authMethods } = useAuthMethods(search.returnTo) @@ -30,8 +33,23 @@ export function LoginPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault() + const trimmedUsername = username.trim() + const nextFieldErrors: { username?: string, password?: string } = {} + + if (!trimmedUsername) { + nextFieldErrors.username = t('login.usernameRequired') + } + if (!password) { + nextFieldErrors.password = t('login.passwordRequired') + } + if (nextFieldErrors.username || nextFieldErrors.password) { + setFieldErrors(nextFieldErrors) + return + } + + setFieldErrors({}) try { - await loginMutation.mutateAsync({ username, password }) + await loginMutation.mutateAsync({ username: trimmedUsername, password }) await navigate({ to: returnTo }) } catch { // mutation state drives the error UI @@ -79,20 +97,50 @@ export function LoginPage() { id="username" autoComplete="username" value={username} - onChange={(event) => setUsername(event.target.value)} + onChange={(event) => { + setUsername(event.target.value) + if (fieldErrors.username) { + setFieldErrors((current) => ({ ...current, username: undefined })) + } + }} placeholder={t('login.usernamePlaceholder')} + aria-invalid={fieldErrors.username ? 'true' : 'false'} /> + {fieldErrors.username ? ( +

{fieldErrors.username}

+ ) : null}
- setPassword(event.target.value)} - placeholder={t('login.passwordPlaceholder')} - /> +
+ { + setPassword(event.target.value) + if (fieldErrors.password) { + setFieldErrors((current) => ({ ...current, password: undefined })) + } + }} + placeholder={t('login.passwordPlaceholder')} + className="pr-12" + aria-invalid={fieldErrors.password ? 'true' : 'false'} + /> + +
+ {fieldErrors.password ? ( +

{fieldErrors.password}

+ ) : null}
{loginMutation.error ? (

{loginMutation.error.message}

diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index f2fda1ed..334c4cb8 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -1,3 +1,4 @@ +import { startTransition, useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { SearchBar } from '@/features/search/search-bar' @@ -16,6 +17,11 @@ export function SearchPage() { const q = searchParams.q || '' const sort = searchParams.sort || 'relevance' const page = searchParams.page ?? 0 + const [queryInput, setQueryInput] = useState(q) + + useEffect(() => { + setQueryInput(q) + }, [q]) const { data, isLoading } = useSearchSkills({ q, @@ -24,8 +30,34 @@ export function SearchPage() { size: 12, }) + useEffect(() => { + const normalizedQuery = queryInput.trim() + if (normalizedQuery === q) { + return + } + + if (!normalizedQuery) { + startTransition(() => { + navigate({ to: '/search', search: { q: '', sort, page: 0 }, replace: page === 0 }) + }) + return + } + + const timeoutId = window.setTimeout(() => { + startTransition(() => { + navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true }) + }) + }, 250) + + return () => window.clearTimeout(timeoutId) + }, [navigate, page, q, queryInput, sort]) + const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort, page: 0 } }) + const normalizedQuery = query.trim() + setQueryInput(query) + startTransition(() => { + navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true }) + }) } const handleSortChange = (newSort: string) => { @@ -46,7 +78,7 @@ export function SearchPage() {
{/* Search Bar */}
- +
{/* Sort Selector */} @@ -92,7 +124,7 @@ export function SearchPage() { <>
{data.items.map((skill, idx) => ( -
+
handleSkillClick(skill.namespace, skill.slug)} diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 67223007..cd539d1d 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { ArrowLeft } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' import { InstallCommand } from '@/features/skill/install-command' @@ -8,6 +9,7 @@ import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' import { useAuth } from '@/features/auth/use-auth' import { adminApi } from '@/api/client' +import { formatLocalDateTime } from '@/shared/lib/date-time' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs' import { Button } from '@/shared/ui/button' @@ -77,6 +79,14 @@ export function SkillDetailPage() { }) } + const handleBack = () => { + if (window.history.length > 1) { + window.history.back() + return + } + navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } }) + } + if (isLoadingSkill) { return (
@@ -122,6 +132,15 @@ export function SkillDetailPage() { {/* Main Content */}
+
@@ -173,7 +192,7 @@ export function SkillDetailPage() { - {new Date(version.publishedAt).toLocaleDateString(i18n.language)} + {formatLocalDateTime(version.publishedAt, i18n.language)}
{version.changelog && ( diff --git a/web/src/shared/components/empty-state.tsx b/web/src/shared/components/empty-state.tsx index 796811e7..58f03bf3 100644 --- a/web/src/shared/components/empty-state.tsx +++ b/web/src/shared/components/empty-state.tsx @@ -26,7 +26,9 @@ export function EmptyState({ title, description, action }: EmptyStateProps) {

{title}

{description && ( -

{description}

+

+ {description} +

)} {action &&
{action}
}
diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index e3b77692..38d7d2e4 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -2,6 +2,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client' +const PUBLISH_REQUEST_TIMEOUT_MS = 60_000 + async function searchSkills(params: SearchParams): Promise> { const queryParams = new URLSearchParams() if (params.q) queryParams.append('q', params.q) @@ -75,6 +77,7 @@ async function publishSkill(params: { namespace: string; file: File; visibility: method: 'POST', headers: getCsrfHeaders(), body: formData, + timeoutMs: PUBLISH_REQUEST_TIMEOUT_MS, }) } diff --git a/web/src/shared/lib/date-time.ts b/web/src/shared/lib/date-time.ts new file mode 100644 index 00000000..ff229610 --- /dev/null +++ b/web/src/shared/lib/date-time.ts @@ -0,0 +1,21 @@ +function parseServerDateTime(value: string): Date { + if (/[zZ]$|[+-]\d{2}:\d{2}$/.test(value)) { + return new Date(value) + } + + const [datePart, timePart = '00:00:00'] = value.split('T') + const [year, month, day] = datePart.split('-').map(Number) + const [rawTime, fractional = ''] = timePart.split('.') + const [hours = 0, minutes = 0, seconds = 0] = rawTime.split(':').map(Number) + const milliseconds = Number((fractional + '000').slice(0, 3)) + + return new Date(year, (month || 1) - 1, day || 1, hours, minutes, seconds, milliseconds) +} + +export function formatLocalDateTime( + value: string, + locale: string, + options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }, +) { + return new Intl.DateTimeFormat(locale, options).format(parseServerDateTime(value)) +} diff --git a/web/src/shared/lib/toast.ts b/web/src/shared/lib/toast.ts index a5b9607b..a4c9f7f1 100644 --- a/web/src/shared/lib/toast.ts +++ b/web/src/shared/lib/toast.ts @@ -1,17 +1,17 @@ -import { toast as sonnerToast } from 'sonner' +import { toast as sonnerToast, type ExternalToast } from 'sonner' export const toast = { - success: (message: string, description?: string) => { - sonnerToast.success(message, { description }) + success: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.success(message, { description, ...options }) }, - error: (message: string, description?: string) => { - sonnerToast.error(message, { description }) + error: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.error(message, { description, ...options }) }, - warning: (message: string, description?: string) => { - sonnerToast.warning(message, { description }) + warning: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.warning(message, { description, ...options }) }, - info: (message: string, description?: string) => { - sonnerToast.info(message, { description }) + info: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.info(message, { description, ...options }) }, promise: ( promise: Promise, diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx index 913984d7..1512c46d 100644 --- a/web/src/shared/ui/dialog.tsx +++ b/web/src/shared/ui/dialog.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { createPortal } from 'react-dom' import { cn } from '@/shared/lib/utils' interface DialogContextValue { @@ -27,6 +28,20 @@ const Dialog = ({ open: controlledOpen, onOpenChange, children }: DialogProps) = const open = controlledOpen ?? uncontrolledOpen const handleOpenChange = onOpenChange ?? setUncontrolledOpen + React.useEffect(() => { + if (!open || typeof document === 'undefined') { + return undefined + } + + const { body } = document + const previousOverflow = body.style.overflow + body.style.overflow = 'hidden' + + return () => { + body.style.overflow = previousOverflow + } + }, [open]) + return ( {children} @@ -65,7 +80,10 @@ DialogTrigger.displayName = 'DialogTrigger' const DialogPortal = ({ children }: { children: React.ReactNode }) => { const { open } = useDialog() if (!open) return null - return <>{children} + if (typeof document === 'undefined') { + return null + } + return createPortal(children, document.body) } const DialogOverlay = React.forwardRef>( @@ -92,39 +110,37 @@ const DialogContent = React.forwardRef -
-
e.stopPropagation()} - {...props} +
e.stopPropagation()} + {...props} + > + {children} + -
+ + + +
)