Merge pull request #33 from iflytek/feature/project-local

feat: improve token, download, admin, and search workflows
This commit is contained in:
yun-zhi-ztl 2026-03-14 23:21:51 -07:00 • committed by GitHub
commit 9d5154cedf
70 changed files with 1428 additions and 255 deletions

View file

@ -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<AuthMeResponse> 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<AuthMeResponse> directLogin(@Valid @RequestBody DirectLoginRequest request,
HttpServletRequest httpRequest) {
return ok(

View file

@ -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<AuthMeResponse> 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<AuthMeResponse> 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<Void> changePassword(@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody ChangePasswordRequest request) {
if (principal == null) {

View file

@ -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<TokenSummaryResponse> 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() : ""
));
}
}

View file

@ -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<InputStreamResource> downloadLatest(
@PathVariable String namespace,
@PathVariable String slug,
HttpServletRequest request,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag(
namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of());
return buildDownloadResponse(result);
return buildDownloadResponse(request, result);
}
private ResponseEntity<InputStreamResource> buildDownloadResponse(SkillDownloadService.DownloadResult result) {
if (result.presignedUrl() != null) {
private ResponseEntity<InputStreamResource> 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();
}
}

View file

@ -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
) {}

View file

@ -13,6 +13,7 @@ public record SkillSummaryResponse(
BigDecimal ratingAvg,
Integer ratingCount,
String latestVersion,
String latestVersionStatus,
String namespace,
LocalDateTime updatedAt
) {}

View file

@ -9,5 +9,6 @@ public record TokenCreateRequest(
@NotBlank(message = "{validation.token.name.notBlank}")
@Size(max = 64, message = "{validation.token.name.size}")
String name,
List<String> scopes
List<String> scopes,
String expiresAt
) {}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.dto;
public record TokenExpirationUpdateRequest(
String expiresAt
) {}

View file

@ -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<String, List<String>> 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<String> withDefaultUserRole(List<String> roles) {
Set<String> resolvedRoles = new TreeSet<>();
if (roles != null) {
resolvedRoles.addAll(roles);
}
if (resolvedRoles.isEmpty()) {
resolvedRoles.add("USER");
}
return Set.copyOf(resolvedRoles);
}
private UserAccount loadUser(String userId) {

View file

@ -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<String> withDefaultUserRole(Set<String> roles) {
Set<String> 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;

View file

@ -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<Long> latestVersionIds = skills.stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
Map<Long, SkillVersion> versionsBySkillId = loadLatestRelevantVersions(skills);
List<Long> 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<Long> latestVersionIds = skillsById.values().stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
Map<Long, SkillVersion> versionsBySkillId = loadLatestRelevantVersions(skillsById.values());
List<Long> 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<Long, SkillVersion> versionsById,
Map<Long, SkillVersion> versionsBySkillId,
Map<Long, String> 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<Long, SkillVersion> loadLatestRelevantVersions(java.util.Collection<Skill> skills) {
if (skills.isEmpty()) {
return Map.of();
}
List<Long> explicitLatestVersionIds = skills.stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = explicitLatestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(explicitLatestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> skillIdsNeedingFallback = skills.stream()
.filter(skill -> skill.getLatestVersionId() == null || !versionsById.containsKey(skill.getLatestVersionId()))
.map(Skill::getId)
.distinct()
.toList();
Map<Long, SkillVersion> 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<Long, SkillVersion> 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;
}
}

View file

@ -152,6 +152,7 @@ public class SkillSearchAppService {
skill.getRatingAvg(),
skill.getRatingCount(),
latestVersion,
latestVersion == null ? null : "PUBLISHED",
namespaceSlug,
skill.getUpdatedAt()
);

View file

@ -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

View file

@ -60,6 +60,8 @@ spring:
skillhub:
auth:
mock:
enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false}
direct:
enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false}
session-bootstrap:

View file

@ -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);

View file

@ -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

View file

@ -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 状态的用户

View file

@ -61,6 +61,7 @@ class ClawHubCompatControllerTest {
BigDecimal.valueOf(4.5),
2,
"1.2.0",
"PUBLISHED",
"global",
LocalDateTime.of(2026, 3, 13, 9, 0))),
1,

View file

@ -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)
)

View file

@ -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());
}
}

View file

@ -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));
}
}

View file

@ -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"));
}
}

View file

@ -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()))

View file

@ -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));

View file

@ -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");
}
}

View file

@ -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<String> 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(),

View file

@ -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<String> 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(),

View file

@ -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<String> 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

View file

@ -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<String> withDefaultUserRole(Collection<String> roles) {
Set<String> resolvedRoles = new TreeSet<>();
if (roles != null) {
resolvedRoles.addAll(roles);
}
if (resolvedRoles.isEmpty()) {
resolvedRoles.add(DEFAULT_USER_ROLE);
}
return Set.copyOf(resolvedRoles);
}
}

View file

@ -23,9 +23,9 @@ public class RbacService {
}
public Set<String> 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<String> getUserPermissions(String userId) {

View file

@ -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<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
.map(rb -> rb.getRole().getCode())
.collect(Collectors.toSet());
roles = PlatformRoleDefaults.withDefaultUserRole(roles);
Set<String> scopes = apiTokenScopeService.parseScopes(token.getScopeJson());
PlatformPrincipal principal = new PlatformPrincipal(
user.getId(), user.getDisplayName(), user.getEmail(),

View file

@ -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<ApiToken> 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");
}
}
}

View file

@ -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");

View file

@ -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());
}
}

View file

@ -6,6 +6,7 @@ import java.util.Optional;
public interface SkillVersionRepository {
Optional<SkillVersion> findById(Long id);
List<SkillVersion> findByIdIn(List<Long> ids);
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
SkillVersion save(SkillVersion version);

View file

@ -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()));

View file

@ -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<Long, NamespaceRole> 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";

View file

@ -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<PackageEntry> 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);

View file

@ -14,6 +14,7 @@ import java.util.Optional;
@Repository
public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, Long>, SkillVersionRepository {
List<SkillVersion> findByIdIn(List<Long> ids);
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
@Override

View file

@ -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<Long> 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<String> 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);
}
}

View file

@ -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:*");
}
}

View file

@ -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<ApiToken> {
return fetchJson<ApiToken>(`/api/v1/tokens/${tokenId}/expiration`, {
method: 'PUT',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ expiresAt: expiresAt ?? '' }),
})
},
async deleteToken(tokenId: number): Promise<void> {
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<void> {

View file

@ -35,6 +35,7 @@ export type ApiToken = Omit<components['schemas']['TokenSummaryResponse'], 'id'
export type CreateTokenRequest = Omit<components['schemas']['TokenCreateRequest'], 'name'> & {
name: string
scopes?: string[]
expiresAt?: string
}
export type CreateTokenResponse = Omit<components['schemas']['TokenCreateResponse'], 'token' | 'id' | 'name' | 'tokenPrefix' | 'createdAt'> & {
@ -111,6 +112,7 @@ export interface SkillSummary {
ratingAvg?: number
ratingCount: number
latestVersion?: string
latestVersionStatus?: string
namespace: string
updatedAt: string
}

View file

@ -129,7 +129,7 @@ const searchRoute = createRoute({
validateSearch: (search: Record<string, unknown>) => {
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',
}

View file

@ -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 (
<form onSubmit={handleSubmit} className="flex gap-3 glass-strong p-2 rounded-xl">
<div className="relative flex-1">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="text"
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"
className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
/>
{currentQuery ? (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
aria-label={t('searchBar.clear')}
title={t('searchBar.clear')}
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<Button type="submit" size="lg" className="px-8">
{t('searchBar.button')}
<Button type="submit" size="lg" className="px-8 min-w-28" disabled={isSearching}>
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : t('searchBar.button')}
</Button>
</form>
)

View file

@ -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
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
{skill.downloadCount}
{formatCompactCount(skill.downloadCount)}
</span>
<span
className={`flex items-center gap-1 ${showStarredBadge ? 'font-semibold text-primary' : ''}`}

View file

@ -14,8 +14,11 @@ import {
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/lib/toast'
import { Select } from '@/shared/ui/select'
import { centeredToastOptions, toast } from '@/shared/lib/toast'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types'
import { resolveTokenExpiresAt, toLocalDateTimeInputValue, type TokenExpirationMode } from './token-expiration'
interface CreateTokenDialogProps {
children: React.ReactNode
@ -25,11 +28,14 @@ interface CreateTokenDialogProps {
const MAX_TOKEN_NAME_LENGTH = 64
export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenDialogProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null)
const [nameError, setNameError] = useState<string | null>(null)
const [expirationMode, setExpirationMode] = useState<TokenExpirationMode>('never')
const [customExpiresAt, setCustomExpiresAt] = useState('')
const [expiresAtError, setExpiresAtError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
{!createdToken ? (
<>
<DialogHeader>
<DialogTitle>{t('createToken.title')}</DialogTitle>
<DialogDescription>
<DialogHeader className="text-center sm:text-center">
<DialogTitle className="text-center">{t('createToken.title')}</DialogTitle>
<DialogDescription className="text-center">
{t('createToken.description')}
</DialogDescription>
</DialogHeader>
@ -139,11 +155,48 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="token-expiration">{t('createToken.expirationLabel')}</Label>
<Select
id="token-expiration"
value={expirationMode}
onChange={(e) => {
setExpirationMode(e.target.value as TokenExpirationMode)
setExpiresAtError(null)
}}
>
<option value="never">{t('createToken.expirationNever')}</option>
<option value="7d">{t('createToken.expiration7d')}</option>
<option value="30d">{t('createToken.expiration30d')}</option>
<option value="90d">{t('createToken.expiration90d')}</option>
<option value="custom">{t('createToken.expirationCustom')}</option>
</Select>
{expirationMode === 'custom' ? (
<Input
id="token-custom-expiration"
type="datetime-local"
value={customExpiresAt}
min={minDateTime}
onChange={(e) => {
setCustomExpiresAt(e.target.value)
if (expiresAtError) {
setExpiresAtError(null)
}
}}
/>
) : null}
{expiresAtError ? (
<p className="text-xs text-red-600">{expiresAtError}</p>
) : (
<p className="text-xs text-muted-foreground">{t('createToken.expirationHint')}</p>
)}
</div>
</div>
{createMutation.error ? (
<p className="text-sm text-red-600">{createMutation.error.message}</p>
) : null}
<DialogFooter>
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button variant="outline" onClick={handleClose}>
{t('dialog.cancel')}
</Button>
@ -157,9 +210,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
</>
) : (
<>
<DialogHeader>
<DialogTitle>{t('createToken.successTitle')}</DialogTitle>
<DialogDescription>
<DialogHeader className="text-center sm:text-center">
<DialogTitle className="text-center">{t('createToken.successTitle')}</DialogTitle>
<DialogDescription className="text-center">
{t('createToken.successDescription')}
</DialogDescription>
</DialogHeader>
@ -174,8 +227,12 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
<Label>{t('createToken.nameDisplay')}</Label>
<div className="text-sm">{createdToken.name}</div>
</div>
<div className="space-y-2">
<Label>{t('createToken.expiresAtDisplay')}</Label>
<div className="text-sm">{formatExpiresAt(createdToken.expiresAt)}</div>
</div>
</div>
<DialogFooter>
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button onClick={handleCopyToken}>
{t('createToken.copyToken')}
</Button>

View file

@ -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}`
}

View file

@ -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<TokenPage>({
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() {
<Button>{t('token.createNew')}</Button>
</CreateTokenDialog>
</div>
<p className="text-sm text-muted-foreground">{t('token.copyHint')}</p>
{!tokenPage || tokenPage.total === 0 ? (
<div className="text-center py-12 text-muted-foreground">
@ -140,7 +198,7 @@ export function TokenList() {
<TableHead>{t('token.createdAt')}</TableHead>
<TableHead>{t('token.lastUsed')}</TableHead>
<TableHead>{t('token.expiresAt')}</TableHead>
<TableHead className="text-right">{t('token.actions')}</TableHead>
<TableHead className="text-center">{t('token.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -154,16 +212,27 @@ export function TokenList() {
</TableCell>
<TableCell>{formatDate(token.createdAt)}</TableCell>
<TableCell>{formatDate(token.lastUsedAt)}</TableCell>
<TableCell>{formatDate(token.expiresAt)}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(token.id, token.name)}
disabled={deleteMutation.isPending}
>
{t('token.delete')}
</Button>
<TableCell>{formatDate(token.expiresAt, t('token.neverExpires'))}</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditExpiration(token)}
disabled={updateExpirationMutation.isPending}
>
<Pencil className="mr-1 h-4 w-4" />
{t('token.editExpiration')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(token.id, token.name)}
disabled={deleteMutation.isPending}
>
{t('token.delete')}
</Button>
</div>
</TableCell>
</TableRow>
))}
@ -185,6 +254,59 @@ export function TokenList() {
variant="destructive"
onConfirm={confirmDelete}
/>
<Dialog open={expirationDialog.open} onOpenChange={(open) => setExpirationDialog((current) => ({ ...current, open }))}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('token.editExpirationTitle')}</DialogTitle>
<DialogDescription>
{t('token.editExpirationDescription', { name: expirationDialog.tokenName })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="token-expiration-mode">{t('createToken.expirationLabel')}</Label>
<Select
id="token-expiration-mode"
value={expirationDialog.mode}
onChange={(event) => setExpirationDialog((current) => ({
...current,
mode: event.target.value as TokenExpirationMode,
}))}
>
<option value="never">{t('createToken.expirationNever')}</option>
<option value="7d">{t('createToken.expiration7d')}</option>
<option value="30d">{t('createToken.expiration30d')}</option>
<option value="90d">{t('createToken.expiration90d')}</option>
<option value="custom">{t('createToken.expirationCustom')}</option>
</Select>
</div>
{expirationDialog.mode === 'custom' ? (
<div className="space-y-2">
<Label htmlFor="token-expiration-custom">{t('createToken.expirationCustom')}</Label>
<Input
id="token-expiration-custom"
type="datetime-local"
value={expirationDialog.customExpiresAt}
min={toLocalDateTimeInputValue(new Date())}
onChange={(event) => setExpirationDialog((current) => ({
...current,
customExpiresAt: event.target.value,
}))}
/>
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setExpirationDialog({ open: false, mode: 'never', customExpiresAt: '' })}>
{t('dialog.cancel')}
</Button>
<Button onClick={confirmExpirationUpdate} disabled={updateExpirationMutation.isPending}>
{updateExpirationMutation.isPending ? t('token.updatingExpiration') : t('token.saveExpiration')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -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}}</1> results"
"resultCount": "Found <1>{{count}}</1> 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.",

View file

@ -103,11 +103,13 @@
"noStarredSkills": "你还没有收藏任何技能",
"enterKeyword": "请输入搜索关键词",
"results": "找到 {{count}} 个技能",
"resultCount": "找到 <1>{{count}}</1> 个结果"
"resultCount": "找到 <1>{{count}}</1> 个结果",
"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}}\" 吗?此操作无法撤销。",

View file

@ -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<string>('')
const [page, setPage] = useState(0)
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(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<HTMLInputElement>) => {
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() {
</div>
<Card className="p-5">
<div className="flex gap-4">
<Input
placeholder={t('adminUsers.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{t('adminUsers.filterAll')}</option>
<option value="ACTIVE">{t('adminUsers.filterActive')}</option>
<option value="PENDING">{t('adminUsers.filterPending')}</option>
<option value="DISABLED">{t('adminUsers.filterDisabled')}</option>
</Select>
<div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_220px]">
<div className="space-y-2">
<Label htmlFor="admin-user-search">{t('adminUsers.searchLabel')}</Label>
<div className="flex gap-2">
<Input
id="admin-user-search"
placeholder={t('adminUsers.searchPlaceholder')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="flex-1"
/>
<Button type="button" onClick={applySearch}>
{t('adminUsers.searchAction')}
</Button>
<Button type="button" variant="outline" onClick={clearSearch} disabled={!searchInput && !search}>
{t('adminUsers.clearSearch')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('adminUsers.searchHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="admin-user-status">{t('adminUsers.filterLabel')}</Label>
<Select id="admin-user-status" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{t('adminUsers.filterAll')}</option>
<option value="ACTIVE">{t('adminUsers.filterActive')}</option>
<option value="PENDING">{t('adminUsers.filterPending')}</option>
<option value="DISABLED">{t('adminUsers.filterDisabled')}</option>
</Select>
</div>
</div>
</Card>
@ -238,12 +282,11 @@ export function AdminUsersPage() {
<div className="space-y-2">
<Label htmlFor="role">{t('adminUsers.roleLabel')}</Label>
<Select id="role" value={newRole} onChange={(e) => setNewRole(e.target.value)}>
<option value="">{t('adminUsers.selectRole')}</option>
<option value="USER">{t('adminUsers.roleUser')}</option>
<option value="REVIEWER">{t('adminUsers.roleReviewer')}</option>
<option value="USER_ADMIN">{t('adminUsers.roleUserAdmin')}</option>
<option value="AUDITOR">{t('adminUsers.roleAuditor')}</option>
<option value="SUPER_ADMIN">{t('adminUsers.roleSuperAdmin')}</option>
{roleOptions.map((roleOption) => (
<option key={roleOption.value} value={roleOption.value}>
{roleOption.label}
</option>
))}
</Select>
</div>
</div>
@ -251,7 +294,15 @@ export function AdminUsersPage() {
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
{t('dialog.cancel')}
</Button>
<Button onClick={confirmRoleChange} disabled={updateRoleMutation.isPending}>
<Button
onClick={confirmRoleChange}
disabled={
updateRoleMutation.isPending
|| !selectedUser
|| !newRole
|| newRole === (selectedUser.platformRoles[0] || 'USER')
}
>
{t('dialog.confirm')}
</Button>
</DialogFooter>

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('myNamespaces.title')}</h1>
<p className="text-muted-foreground text-lg">{t('myNamespaces.subtitle')}</p>
</div>
<Button disabled>{t('myNamespaces.create')}</Button>
</div>
<DashboardPageHeader
title={t('myNamespaces.title')}
subtitle={t('myNamespaces.subtitle')}
actions={<Button disabled>{t('myNamespaces.create')}</Button>}
/>
{namespaces && namespaces.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">

View file

@ -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 (
<div className="space-y-4 animate-fade-up">
@ -26,15 +48,15 @@ export function MySkillsPage() {
return (
<div className="space-y-8 animate-fade-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('mySkills.title')}</h1>
<p className="text-muted-foreground text-lg">{t('mySkills.subtitle')}</p>
</div>
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
<DashboardPageHeader
title={t('mySkills.title')}
subtitle={t('mySkills.subtitle')}
actions={(
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('mySkills.publishNew')}
</Button>
</div>
</Button>
)}
/>
{skills && skills.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
@ -57,11 +79,16 @@ export function MySkillsPage() {
{skill.latestVersion && (
<span className="font-mono text-xs">v{skill.latestVersion}</span>
)}
{skill.latestVersionStatus ? (
<span className={`rounded-full border px-2.5 py-0.5 text-xs ${resolveStatusClassName(skill.latestVersionStatus)}`}>
{resolveStatusLabel(skill.latestVersionStatus)}
</span>
) : null}
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
{skill.downloadCount}
{formatCompactCount(skill.downloadCount)}
</span>
</div>
</div>

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader
title={t('members.title')}
subtitle={namespace ? `@${namespace.slug}` : undefined}
/>
<NamespaceHeader namespace={namespace} />
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold font-heading">{t('members.title')}</h2>
<div className="flex items-center justify-end">
<Button disabled>{t('members.addMember')}</Button>
</div>

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('nsReviews.title')}</h1>
<p className="text-muted-foreground text-lg">
{namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')}
</p>
</div>
<DashboardPageHeader
title={t('nsReviews.title')}
subtitle={namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')}
/>
<ReviewListSection namespaceId={namespace?.id} />
</div>
)

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('promotions.title')}</h1>
<p className="text-muted-foreground text-lg">{t('promotions.subtitle')}</p>
</div>
<DashboardPageHeader title={t('promotions.title')} subtitle={t('promotions.subtitle')} />
<Tabs defaultValue="PENDING">
<TabsList>
<TabsTrigger value="PENDING">{t('promotions.tabPending')}</TabsTrigger>

View file

@ -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 (
<div className="max-w-2xl mx-auto space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('publish.title')}</h1>
<p className="text-muted-foreground text-lg">{t('publish.subtitle')}</p>
</div>
<DashboardPageHeader title={t('publish.title')} subtitle={t('publish.subtitle')} />
<Card className="p-4 bg-blue-500/5 border-blue-500/20">
<div className="flex items-start gap-3">
@ -123,15 +125,29 @@ export function PublishPage() {
<div className="space-y-3">
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
<UploadZone
key={selectedFile ? `${selectedFile.name}-${selectedFile.lastModified}` : 'empty'}
onFileSelect={setSelectedFile}
disabled={publishMutation.isPending}
/>
{selectedFile && (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/30 px-4 py-3">
<div className="min-w-0 text-sm text-muted-foreground flex items-center gap-2">
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="truncate">
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveSelectedFile}
disabled={publishMutation.isPending}
>
{t('publish.removeSelectedFile')}
</Button>
</div>
)}
</div>

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('reviews.title')}</h1>
<p className="text-muted-foreground text-lg">{t('reviews.subtitle')}</p>
</div>
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
<Tabs defaultValue="PENDING">
<TabsList>

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('stars.title')}</h1>
<p className="text-muted-foreground text-lg">{t('stars.subtitle')}</p>
</div>
<DashboardPageHeader title={t('stars.title')} subtitle={t('stars.subtitle')} />
{!skills || skills.length === 0 ? (
<Card className="p-12 text-center text-muted-foreground">{t('stars.empty')}</Card>

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('tokens.pageTitle')}</h1>
<p className="text-muted-foreground text-lg">{t('tokens.pageSubtitle')}</p>
</div>
<DashboardPageHeader
title={t('tokens.pageTitle')}
subtitle={t('tokens.pageSubtitle')}
/>
<TokenList />
</div>
)

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
{/* Search Bar */}
<div className="max-w-3xl mx-auto">
<SearchBar value={queryInput} onChange={setQueryInput} onSearch={handleSearch} />
<SearchBar
value={queryInput}
isSearching={isUpdatingResults}
onChange={setQueryInput}
onSearch={handleSearch}
/>
</div>
{/* Sort And Filters */}
@ -177,6 +188,13 @@ export function SearchPage() {
)}
</div>
{isUpdatingResults ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t('search.loadingMore')}</span>
</div>
) : null}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
<Button

View file

@ -1,30 +1,56 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { authApi } from '@/api/client'
import { ApiError, authApi } from '@/api/client'
import { toast } from '@/shared/lib/toast'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
export function SecuritySettingsPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setStatusMessage('')
setErrorMessage('')
if (!currentPassword.trim()) {
setErrorMessage(t('security.currentPasswordRequired'))
return
}
if (!newPassword.trim()) {
setErrorMessage(t('security.newPasswordRequired'))
return
}
setIsSubmitting(true)
try {
await authApi.changePassword({ currentPassword, newPassword })
setStatusMessage(t('security.success'))
toast.success(t('security.successTitle'), t('security.successDescription'))
setCurrentPassword('')
setNewPassword('')
try {
await authApi.logout()
} catch (error) {
console.error('Logout after password change failed:', error)
} finally {
queryClient.setQueryData(['auth', 'me'], null)
}
await navigate({ to: '/login', search: { returnTo: '' } })
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : t('security.defaultError'))
if (error instanceof ApiError && error.status === 401) {
setErrorMessage(t('security.invalidCurrentPassword'))
} else {
setErrorMessage(error instanceof Error ? error.message : t('security.defaultError'))
}
} finally {
setIsSubmitting(false)
}
@ -59,7 +85,6 @@ export function SecuritySettingsPage() {
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
{statusMessage ? <p className="text-sm text-emerald-600">{statusMessage}</p> : null}
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('security.submitting') : t('security.submit')}

View file

@ -10,6 +10,7 @@ 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 { formatCompactCount } from '@/shared/lib/number-format'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
@ -228,7 +229,7 @@ export function SkillDetailPage() {
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">{t('skillDetail.downloads')}</div>
<div className="font-semibold text-foreground">{skill.downloadCount.toLocaleString()}</div>
<div className="font-semibold text-foreground">{formatCompactCount(skill.downloadCount)}</div>
</div>
<div className="h-px bg-border/40" />

View file

@ -42,11 +42,11 @@ export function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
<DialogHeader className="text-center sm:text-center">
<DialogTitle className="text-center">{title}</DialogTitle>
{description && <DialogDescription className="text-center">{description}</DialogDescription>}
</DialogHeader>
<DialogFooter>
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{resolvedCancelText}
</Button>

View file

@ -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 (
<div className="space-y-4">
<Button variant="ghost" className="px-0 text-muted-foreground hover:text-foreground" onClick={() => navigate({ to: '/dashboard' })}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('dashboard.backToDashboard')}
</Button>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{title}</h1>
{subtitle ? <p className="text-muted-foreground text-lg">{subtitle}</p> : null}
</div>
{actions}
</div>
</div>
)
}

View file

@ -1,14 +1,21 @@
import { Toaster as Sonner } from 'sonner'
import { CENTER_TOASTER_ID } from '@/shared/lib/toast'
export function Toaster() {
return (
<Sonner
position="top-right"
id={CENTER_TOASTER_ID}
position="top-center"
className="!left-1/2 !right-auto !top-4 !-translate-x-1/2"
offset={16}
mobileOffset={16}
toastOptions={{
toasterId: CENTER_TOASTER_ID,
classNames: {
toast: 'glass-strong border border-border/40',
title: 'text-foreground font-semibold',
description: 'text-muted-foreground',
toast: 'glass-strong mx-auto w-fit max-w-[min(100vw-2rem,32rem)] border border-border/40',
title: 'text-foreground font-semibold text-center',
description: 'text-muted-foreground text-center',
content: 'w-full text-center',
actionButton: 'bg-primary text-primary-foreground',
cancelButton: 'bg-muted text-muted-foreground',
error: 'border-destructive/40',

View file

@ -0,0 +1,18 @@
export function formatCompactCount(value: number): string {
if (value >= 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
}

View file

@ -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: <T,>(
promise: Promise<T>,
@ -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 })
},
}

View file

@ -113,7 +113,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
<div
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card animate-fade-up',
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
className
)}
onClick={(e) => e.stopPropagation()}
@ -149,25 +149,25 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
DialogContent.displayName = 'DialogContent'
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
<div className={cn('flex flex-col space-y-1.5 text-center', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
<div className={cn('flex flex-col-reverse items-center justify-center gap-2 sm:flex-row', className)} {...props} />
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2 ref={ref} className={cn('text-xl font-bold font-heading leading-none tracking-tight', className)} {...props} />
<h2 ref={ref} className={cn('text-center text-xl font-bold font-heading leading-none tracking-tight', className)} {...props} />
)
)
DialogTitle.displayName = 'DialogTitle'
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
<p ref={ref} className={cn('text-center text-sm text-muted-foreground', className)} {...props} />
)
)
DialogDescription.displayName = 'DialogDescription'