mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
Merge pull request #33 from iflytek/feature/project-local
feat: improve token, download, admin, and search workflows
This commit is contained in:
commit
9d5154cedf
70 changed files with 1428 additions and 255 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() : ""
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ public record SkillSummaryResponse(
|
|||
BigDecimal ratingAvg,
|
||||
Integer ratingCount,
|
||||
String latestVersion,
|
||||
String latestVersionStatus,
|
||||
String namespace,
|
||||
LocalDateTime updatedAt
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record TokenExpirationUpdateRequest(
|
||||
String expiresAt
|
||||
) {}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ public class SkillSearchAppService {
|
|||
skill.getRatingAvg(),
|
||||
skill.getRatingCount(),
|
||||
latestVersion,
|
||||
latestVersion == null ? null : "PUBLISHED",
|
||||
namespaceSlug,
|
||||
skill.getUpdatedAt()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ spring:
|
|||
|
||||
skillhub:
|
||||
auth:
|
||||
mock:
|
||||
enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false}
|
||||
direct:
|
||||
enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false}
|
||||
session-bootstrap:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 状态的用户
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class ClawHubCompatControllerTest {
|
|||
BigDecimal.valueOf(4.5),
|
||||
2,
|
||||
"1.2.0",
|
||||
"PUBLISHED",
|
||||
"global",
|
||||
LocalDateTime.of(2026, 3, 13, 9, 0))),
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:*");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' : ''}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
web/src/features/token/token-expiration.ts
Normal file
31
web/src/features/token/token-expiration.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}}\" 吗?此操作无法撤销。",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
web/src/shared/components/dashboard-page-header.tsx
Normal file
31
web/src/shared/components/dashboard-page-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
18
web/src/shared/lib/number-format.ts
Normal file
18
web/src/shared/lib/number-format.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue