Merge pull request #961 from iflytek/fix/issue-959-social-access-20261015

fix(social): enforce skill access for stars and ratings
This commit is contained in:
XiaoSeS 2026-10-16 12:37:47 +08:00 • committed by GitHub
commit 911b0544cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 597 additions and 102 deletions

View file

@ -199,15 +199,28 @@ Public API 的可见性规则:
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/skills/{namespace}/{slug}/star` | 收藏 |
| DELETE | `/api/v1/skills/{namespace}/{slug}/star` | 取消收藏 |
| POST | `/api/v1/skills/{namespace}/{slug}/rating` | 评分 |
| PUT | `/api/v1/skills/{skillId}/star` | 收藏 |
| GET | `/api/v1/skills/{skillId}/star` | 当前用户收藏状态 |
| DELETE | `/api/v1/skills/{skillId}/star` | 取消本人收藏(幂等) |
| PUT | `/api/v1/skills/{skillId}/rating` | 评分 |
| GET | `/api/v1/skills/{skillId}/rating` | 当前用户评分状态 |
| GET | `/api/v1/skills/{skillId}/reviews/me` | 当前用户的评分与文字评价 |
| PUT | `/api/v1/skills/{skillId}/reviews/me` | 新增或更新当前用户评价(`score` 1-5,`reviewText` 最长 2000) |
| DELETE | `/api/v1/skills/{skillId}/reviews/me` | 删除文字评价并保留星级评分 |
| GET | `/api/v1/me/stars` | 我的收藏列表 |
| GET | `/api/v1/me/skills` | 我发布的技能列表 |
收藏与评分同样支持 `/api/web` 路由。上述 `GET` / `PUT star`、`GET` / `PUT rating`
在读取状态或写入前,按技能详情的对象可见性策略检查当前用户、空间角色和平台角色。
存在但不可访问的技能返回 `403`,不存在返回 `404`;拒绝不会新增收藏、评分或统计事件。
合法的所有者、空间管理员及超级管理员仍按现有访问策略处理,不统一禁止私有技能互动。
`GET /me/stars` 在数据库计数、排序与分页之前过滤当前无权访问的技能。
技能转私有、被隐藏或用户失去空间角色后,旧收藏不能继续返回技能名称、摘要等信息。
收藏关系仍保留,恢复访问后可再次显示;不因当前不可访问而自动删除历史收藏或评分。
`DELETE star` 只操作当前用户自己的收藏,可在失去访问权限后继续幂等清理,不返回目标技能信息。
本次不改变归档技能和未发布技能的既有可见性规则,也不增加数据库业务约束。
### 已上传版本与审核提交
| 方法 | 路径 | 说明 |

View file

@ -8,6 +8,9 @@ import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.service.MySkillAppService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import java.util.Map;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -51,12 +54,13 @@ public class MeController extends BaseApiController {
public ApiResponse<PageResponse<SkillSummaryResponse>> listMyStars(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size,
@AuthenticationPrincipal PlatformPrincipal principal) {
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size));
return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size, namespaceRoles, principal.platformRoles()));
}
@GetMapping("/subscriptions")

View file

@ -6,7 +6,9 @@ import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillRatingRequest;
import com.iflytek.skillhub.dto.SkillRatingStatusResponse;
import com.iflytek.skillhub.domain.social.SkillRatingService;
import com.iflytek.skillhub.service.SkillSocialAppService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import java.util.Map;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@ -19,31 +21,32 @@ import java.util.Optional;
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillRatingController extends BaseApiController {
private final SkillRatingService skillRatingService;
private final SkillSocialAppService socialAppService;
public SkillRatingController(ApiResponseFactory responseFactory,
SkillRatingService skillRatingService) {
SkillSocialAppService socialAppService) {
super(responseFactory);
this.skillRatingService = skillRatingService;
this.socialAppService = socialAppService;
}
@PutMapping("/{skillId}/rating")
public ApiResponse<Void> rateSkill(
@PathVariable Long skillId,
@Valid @RequestBody SkillRatingRequest request,
@AuthenticationPrincipal PlatformPrincipal principal) {
skillRatingService.rate(skillId, principal.userId(), request.score());
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
socialAppService.rate(skillId, principal.userId(), request.score(), namespaceRoles, principal.platformRoles());
return ok("response.success.updated", null);
}
@GetMapping("/{skillId}/rating")
public ApiResponse<SkillRatingStatusResponse> getUserRating(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
return ok("response.success.read", new SkillRatingStatusResponse((short) 0, false));
}
Optional<Short> rating = skillRatingService.getUserRating(skillId, principal.userId());
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
Optional<Short> rating = socialAppService.getUserRating(skillId,
principal != null ? principal.userId() : null, namespaceRoles,
principal != null ? principal.platformRoles() : null);
return ok(
"response.success.read",
new SkillRatingStatusResponse(

View file

@ -5,6 +5,9 @@ import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.service.SkillSocialAppService;
import java.util.Map;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@ -16,18 +19,22 @@ import org.springframework.web.bind.annotation.*;
public class SkillStarController extends BaseApiController {
private final SkillStarService skillStarService;
private final SkillSocialAppService socialAppService;
public SkillStarController(ApiResponseFactory responseFactory,
SkillStarService skillStarService) {
SkillStarService skillStarService,
SkillSocialAppService socialAppService) {
super(responseFactory);
this.skillStarService = skillStarService;
this.socialAppService = socialAppService;
}
@PutMapping("/{skillId}/star")
public ApiResponse<Void> starSkill(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal) {
skillStarService.star(skillId, principal.userId());
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
socialAppService.star(skillId, principal.userId(), namespaceRoles, principal.platformRoles());
return ok("response.success.updated", null);
}
@ -42,11 +49,11 @@ public class SkillStarController extends BaseApiController {
@GetMapping("/{skillId}/star")
public ApiResponse<Boolean> checkStarred(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
return ok("response.success.read", false);
}
boolean starred = skillStarService.isStarred(skillId, principal.userId());
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles) {
boolean starred = socialAppService.isStarred(skillId,
principal != null ? principal.userId() : null, namespaceRoles,
principal != null ? principal.platformRoles() : null);
return ok("response.success.read", starred);
}
}

View file

@ -0,0 +1,73 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.social.SkillStar;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
@Repository
public class JpaStarredSkillQueryRepository implements StarredSkillQueryRepository {
private final EntityManager entityManager;
public JpaStarredSkillQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public Page<Skill> findAccessible(String userId, Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles, Pageable pageable) {
var builder = entityManager.getCriteriaBuilder();
var query = builder.createQuery(Skill.class);
var skill = query.from(Skill.class);
var star = query.from(SkillStar.class);
query.select(skill).where(predicate(builder, skill, star, userId, namespaceRoles, platformRoles))
.orderBy(builder.desc(star.get("createdAt")), builder.desc(star.get("id")));
var result = entityManager.createQuery(query)
.setFirstResult(Math.toIntExact(pageable.getOffset()))
.setMaxResults(pageable.getPageSize()).getResultList();
var countQuery = builder.createQuery(Long.class);
var countSkill = countQuery.from(Skill.class);
var countStar = countQuery.from(SkillStar.class);
countQuery.select(builder.count(countSkill))
.where(predicate(builder, countSkill, countStar, userId, namespaceRoles, platformRoles));
return new PageImpl<>(result, pageable, entityManager.createQuery(countQuery).getSingleResult());
}
private Predicate predicate(CriteriaBuilder builder, Root<Skill> skill, Root<SkillStar> star,
String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
Predicate relation = builder.and(builder.equal(star.get("userId"), userId),
builder.equal(star.get("skillId"), skill.get("id")));
if (platformRoles != null && platformRoles.contains("SUPER_ADMIN")) return relation;
// Keep this SQL projection equivalent to VisibilityChecker, including unpublished and hidden skills.
Map<Long, NamespaceRole> roles = namespaceRoles != null ? namespaceRoles : Map.of();
List<Long> adminNamespaces = roles.entrySet().stream()
.filter(entry -> entry.getValue() == NamespaceRole.ADMIN || entry.getValue() == NamespaceRole.OWNER)
.map(Map.Entry::getKey).toList();
Predicate owner = builder.equal(skill.get("ownerId"), userId);
Predicate admin = adminNamespaces.isEmpty() ? builder.disjunction() : skill.get("namespaceId").in(adminNamespaces);
Predicate member = roles.isEmpty() ? builder.disjunction() : skill.get("namespaceId").in(roles.keySet());
Predicate visibility = builder.or(
builder.equal(skill.get("visibility"), SkillVisibility.PUBLIC),
builder.and(builder.equal(skill.get("visibility"), SkillVisibility.NAMESPACE_ONLY), member),
builder.and(builder.equal(skill.get("visibility"), SkillVisibility.PRIVATE), builder.or(owner, admin)));
Predicate accessible = builder.or(
builder.and(builder.isTrue(skill.get("hidden")), builder.or(owner, admin)),
builder.and(builder.isFalse(skill.get("hidden")), builder.or(
builder.and(builder.isNull(skill.get("latestVersionId")), owner),
builder.and(builder.isNotNull(skill.get("latestVersionId")), visibility))));
return builder.and(relation, accessible);
}
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import java.util.Map;
import java.util.Set;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/** Starred skills filtered by current object access before counting and paging. */
public interface StarredSkillQueryRepository {
Page<Skill> findAccessible(String userId, Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles, Pageable pageable);
}

View file

@ -1,12 +1,15 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.repository.StarredSkillQueryRepository;
import java.util.Map;
import java.util.Set;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
@ -35,7 +38,7 @@ public class MySkillAppService {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillStarRepository skillStarRepository;
private final StarredSkillQueryRepository starredSkillQueryRepository;
private final SkillSubscriptionRepository skillSubscriptionRepository;
private final MySkillQueryRepository mySkillQueryRepository;
private final HiddenSkillQueryRepository hiddenSkillQueryRepository;
@ -45,7 +48,7 @@ public class MySkillAppService {
public MySkillAppService(
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillStarRepository skillStarRepository,
StarredSkillQueryRepository starredSkillQueryRepository,
SkillSubscriptionRepository skillSubscriptionRepository,
MySkillQueryRepository mySkillQueryRepository,
HiddenSkillQueryRepository hiddenSkillQueryRepository,
@ -53,7 +56,7 @@ public class MySkillAppService {
NamespaceRepository namespaceRepository) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillStarRepository = skillStarRepository;
this.starredSkillQueryRepository = starredSkillQueryRepository;
this.skillSubscriptionRepository = skillSubscriptionRepository;
this.mySkillQueryRepository = mySkillQueryRepository;
this.hiddenSkillQueryRepository = hiddenSkillQueryRepository;
@ -113,28 +116,13 @@ public class MySkillAppService {
return hiddenSkillQueryRepository.search(normalizeKeyword(keyword), namespaceId, pageable);
}
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size) {
Page<com.iflytek.skillhub.domain.social.SkillStar> starPage = skillStarRepository.findByUserId(
userId,
PageRequest.of(page, size)
);
List<com.iflytek.skillhub.domain.social.SkillStar> stars = starPage.getContent();
List<Long> skillIds = stars.stream()
.map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId)
.distinct()
.toList();
java.util.Map<Long, Skill> skillsById = skillIds.isEmpty()
? java.util.Map.of()
: skillRepository.findByIdIn(skillIds).stream()
.collect(java.util.stream.Collectors.toMap(Skill::getId, java.util.function.Function.identity()));
List<Skill> orderedSkills = stars.stream()
.map(star -> skillsById.get(star.getSkillId()))
.filter(java.util.Objects::nonNull)
.toList();
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId);
return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize());
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
Page<Skill> skills = starredSkillQueryRepository.findAccessible(
userId, namespaceRoles, platformRoles, PageRequest.of(page, size));
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(skills.getContent(), userId);
return new PageResponse<>(items, skills.getTotalElements(), skills.getNumber(), skills.getSize());
}
public PageResponse<SkillSummaryResponse> listMySubscriptions(String userId, int page, int size) {

View file

@ -0,0 +1,59 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.social.SkillRatingService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.springframework.stereotype.Service;
/** Object-authorized entry points for skill stars and ratings. */
@Service
public class SkillSocialAppService {
private final SkillRepository skills;
private final VisibilityChecker visibility;
private final SkillStarService stars;
private final SkillRatingService ratings;
public SkillSocialAppService(SkillRepository skills, VisibilityChecker visibility,
SkillStarService stars, SkillRatingService ratings) {
this.skills = skills;
this.visibility = visibility;
this.stars = stars;
this.ratings = ratings;
}
public void star(Long skillId, String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
requireAccess(skillId, userId, namespaceRoles, platformRoles);
stars.star(skillId, userId);
}
public boolean isStarred(Long skillId, String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
requireAccess(skillId, userId, namespaceRoles, platformRoles);
return userId != null && stars.isStarred(skillId, userId);
}
public void rate(Long skillId, String userId, short score, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
requireAccess(skillId, userId, namespaceRoles, platformRoles);
ratings.rate(skillId, userId, score);
}
public Optional<Short> getUserRating(Long skillId, String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
requireAccess(skillId, userId, namespaceRoles, platformRoles);
return userId != null ? ratings.getUserRating(skillId, userId) : Optional.empty();
}
private void requireAccess(Long skillId, String userId, Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
Skill skill = skills.findById(skillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId));
if (!visibility.canAccess(skill, userId, namespaceRoles, platformRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skillId);
}
}
}

View file

@ -125,7 +125,7 @@ class MeControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
given(mySkillAppService.listMyStars("user-42", 0, 12))
given(mySkillAppService.listMyStars("user-42", 0, 12, java.util.Map.of(), Set.of("USER")))
.willReturn(new PageResponse<>(List.of(), 0, 0, 12));
mockMvc.perform(get("/api/v1/me/stars").with(authentication(auth)))

View file

@ -4,6 +4,12 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.social.SkillRatingService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@ -40,6 +46,16 @@ class SkillRatingControllerTest {
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private SkillRepository skills;
@BeforeEach
void targetExists() {
Skill skill = new Skill(1L, "restricted-target", "owner", SkillVisibility.PRIVATE);
skill.setLatestVersionId(11L);
when(skills.findById(10L)).thenReturn(Optional.of(skill));
}
@Test
void rate_skill_returns_envelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -115,4 +131,35 @@ class SkillRatingControllerTest {
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void inaccessibleSkillCannotBeReadOrMutated(String prefix) throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("outsider", "Outsider", null, null, "local", Set.of("USER"));
var auth = new UsernamePasswordAuthenticationToken(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
mockMvc.perform(get(prefix + "/skills/10/rating").with(authentication(auth)))
.andExpect(status().isForbidden())
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target"))));
mockMvc.perform(put(prefix + "/skills/10/rating").with(authentication(auth)).with(csrf())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}"))
.andExpect(status().isForbidden())
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target"))));
org.mockito.Mockito.verifyNoInteractions(skillRatingService);
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void namespaceAdministratorUsesTrustedMembership(String prefix) throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("ns-admin", "Admin", null, null, "local", Set.of("USER"));
var auth = new UsernamePasswordAuthenticationToken(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
when(namespaceMemberRepository.findByUserId("ns-admin")).thenReturn(List.of(
new com.iflytek.skillhub.domain.namespace.NamespaceMember(1L, "ns-admin",
com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN)));
mockMvc.perform(put(prefix + "/skills/10/rating").with(authentication(auth)).with(csrf())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}"))
.andExpect(status().isOk());
}
}

View file

@ -4,6 +4,13 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.social.SkillStarService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@ -38,6 +45,16 @@ class SkillStarControllerTest {
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private SkillRepository skills;
@BeforeEach
void targetExists() {
Skill skill = new Skill(1L, "restricted-target", "owner", SkillVisibility.PRIVATE);
skill.setLatestVersionId(11L);
when(skills.findById(10L)).thenReturn(Optional.of(skill));
}
@Test
void star_skill_returns_envelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -159,4 +176,47 @@ class SkillStarControllerTest {
verify(skillStarService).star(eq(10L), eq("user-42"));
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void inaccessibleSkillCannotBeReadOrMutated(String prefix) throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("outsider", "Outsider", null, null, "local", Set.of("USER"));
var auth = new UsernamePasswordAuthenticationToken(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
mockMvc.perform(get(prefix + "/skills/10/star").with(authentication(auth)))
.andExpect(status().isForbidden())
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target"))));
mockMvc.perform(put(prefix + "/skills/10/star").with(authentication(auth)).with(csrf())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}"))
.andExpect(status().isForbidden())
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("restricted-target"))));
org.mockito.Mockito.verifyNoInteractions(skillStarService);
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void losingAccessStillAllowsRemovingOwnStar(String prefix) throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("outsider", "Outsider", null, null, "local", Set.of("USER"));
var auth = new UsernamePasswordAuthenticationToken(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
mockMvc.perform(delete(prefix + "/skills/10/star").with(authentication(auth)).with(csrf()))
.andExpect(status().isOk());
verify(skillStarService).unstar(10L, "outsider");
org.mockito.Mockito.verifyNoInteractions(skills);
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1", "/api/web"})
void namespaceAdministratorUsesTrustedMembership(String prefix) throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("ns-admin", "Admin", null, null, "local", Set.of("USER"));
var auth = new UsernamePasswordAuthenticationToken(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
when(namespaceMemberRepository.findByUserId("ns-admin")).thenReturn(List.of(
new com.iflytek.skillhub.domain.namespace.NamespaceMember(1L, "ns-admin",
com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN)));
mockMvc.perform(put(prefix + "/skills/10/star").with(authentication(auth)).with(csrf())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"score\":4}"))
.andExpect(status().isOk());
}
}

View file

@ -0,0 +1,139 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.social.SkillStar;
import com.iflytek.skillhub.domain.user.UserAccount;
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
@Import(JpaStarredSkillQueryRepository.class)
class StarredSkillAccessPostgresTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void postgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add("spring.flyway.enabled", () -> true);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired private EntityManager em;
@Autowired private JpaStarredSkillQueryRepository query;
private Long namespaceId;
private final List<Skill> fixtures = new ArrayList<>();
@BeforeEach
void setup() {
em.persist(new UserAccount("owner", "Owner", null, null));
em.persist(new UserAccount("viewer", "Viewer", null, null));
Namespace namespace = new Namespace("social-access", "Social access", "owner");
em.persist(namespace);
em.flush();
namespaceId = namespace.getId();
for (SkillVisibility visibility : SkillVisibility.values()) {
for (boolean published : List.of(false, true)) {
for (boolean hidden : List.of(false, true)) {
Skill skill = new Skill(namespaceId, visibility.name().toLowerCase() + "-" + published + "-" + hidden,
"owner", visibility);
skill.setDisplayName("Secret metadata");
skill.setSummary("Must not leak through a historical star");
skill.setHidden(hidden);
em.persist(skill);
em.flush();
if (published) {
SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", "owner");
version.setStatus(SkillVersionStatus.PUBLISHED);
em.persist(version);
em.flush();
skill.setLatestVersionId(version.getId());
}
em.persist(new SkillStar(skill.getId(), "viewer"));
em.persist(new SkillStar(skill.getId(), "owner"));
fixtures.add(skill);
}
}
}
em.flush();
}
@Test
void databasePolicyMatchesVisibilityCheckerForEveryLifecycleAndRole() {
assertPolicy("viewer", Map.of(), Set.of());
assertPolicy("owner", Map.of(), Set.of());
for (NamespaceRole role : NamespaceRole.values()) {
assertPolicy("viewer", Map.of(namespaceId, role), Set.of());
}
assertPolicy("viewer", Map.of(), Set.of("SUPER_ADMIN"));
assertPolicy("viewer", Map.of(), Set.of("SKILL_ADMIN"));
}
@Test
void filteringPrecedesCountAndPaginationWithoutDeletingHistoricalStars() {
var first = query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(0, 1));
assertThat(first.getTotalElements()).isEqualTo(1);
assertThat(first.getContent()).extracting(Skill::getVisibility).containsExactly(SkillVisibility.PUBLIC);
var beyond = query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(1, 1));
assertThat(beyond.getContent()).isEmpty();
assertThat(beyond.getTotalElements()).isEqualTo(1);
assertThat(em.createQuery("select count(s) from SkillStar s where s.userId = 'viewer'", Long.class)
.getSingleResult()).isEqualTo(12);
}
@Test
void membershipRevocationAndVisibilityReductionRemoveMetadataImmediately() {
assertThat(query.findAccessible("viewer", Map.of(namespaceId, NamespaceRole.MEMBER), Set.of(), PageRequest.of(0, 50))
.getTotalElements()).isEqualTo(2);
assertThat(query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(0, 50)).getTotalElements()).isEqualTo(1);
fixtures.stream().filter(skill -> skill.getVisibility() == SkillVisibility.PUBLIC)
.forEach(skill -> skill.setVisibility(SkillVisibility.PRIVATE));
em.flush();
assertThat(query.findAccessible("viewer", Map.of(), Set.of(), PageRequest.of(0, 50)).getTotalElements()).isZero();
}
private void assertPolicy(String user, Map<Long, NamespaceRole> roles, Set<String> platformRoles) {
var checker = new VisibilityChecker();
List<Long> expected = fixtures.stream().filter(skill -> checker.canAccess(skill, user, roles, platformRoles))
.map(Skill::getId).toList();
var result = query.findAccessible(user, roles, platformRoles, PageRequest.of(0, 50));
assertThat(result.getTotalElements()).isEqualTo(expected.size());
assertThat(result.getContent()).extracting(Skill::getId).containsExactlyInAnyOrderElementsOf(expected);
for (int i = 0; i < expected.size(); i++) {
var page = query.findAccessible(user, roles, platformRoles, PageRequest.of(i, 1));
assertThat(page.getTotalElements()).isEqualTo(expected.size());
assertThat(page.getContent()).hasSize(1);
assertThat(page.getContent().getFirst().getId()).isEqualTo(result.getContent().get(i).getId());
}
}
}

View file

@ -13,8 +13,8 @@ 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.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.social.SkillStar;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.repository.StarredSkillQueryRepository;
import java.util.Map;
import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository;
import com.iflytek.skillhub.repository.JpaMySkillQueryRepository;
import com.iflytek.skillhub.repository.HiddenSkillQueryRepository;
@ -50,7 +50,7 @@ class MySkillAppServiceTest {
private SkillVersionRepository skillVersionRepository;
@Mock
private SkillStarRepository skillStarRepository;
private StarredSkillQueryRepository starredSkillQueryRepository;
@Mock
private SkillSubscriptionRepository skillSubscriptionRepository;
@ -76,7 +76,7 @@ class MySkillAppServiceTest {
service = new MySkillAppService(
skillRepository,
skillVersionRepository,
skillStarRepository,
starredSkillQueryRepository,
skillSubscriptionRepository,
mySkillQueryRepository,
hiddenSkillQueryRepository,
@ -87,22 +87,6 @@ class MySkillAppServiceTest {
@Test
void listMyStars_loadsAllPagesOfStars() {
SkillStar firstStar = new SkillStar(1L, "user-1");
ReflectionTestUtils.setField(firstStar, "createdAt", Instant.parse("2026-03-14T10:00:00Z"));
SkillStar secondStar = new SkillStar(2L, "user-1");
ReflectionTestUtils.setField(secondStar, "createdAt", Instant.parse("2026-03-14T11:00:00Z"));
given(skillStarRepository.findByUserId("user-1", PageRequest.of(1, 1)))
.willReturn(new PageImpl<>(List.of(secondStar), PageRequest.of(1, 1), 2));
Skill firstSkill = new Skill(1L, "first-skill", "user-1", SkillVisibility.PUBLIC);
firstSkill.setDisplayName("First Skill");
firstSkill.setSummary("first summary");
ReflectionTestUtils.setField(firstSkill, "id", 1L);
ReflectionTestUtils.setField(firstSkill, "starCount", 1);
ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L);
ReflectionTestUtils.setField(firstSkill, "updatedAt", Instant.parse("2026-03-14T10:00:00Z"));
Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC);
secondSkill.setDisplayName("Second Skill");
secondSkill.setSummary("second summary");
@ -111,12 +95,13 @@ class MySkillAppServiceTest {
ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L);
ReflectionTestUtils.setField(secondSkill, "updatedAt", Instant.parse("2026-03-14T11:00:00Z"));
given(skillRepository.findByIdIn(List.of(2L))).willReturn(List.of(secondSkill));
given(starredSkillQueryRepository.findAccessible("user-1", Map.of(), Set.of(), PageRequest.of(1, 1)))
.willReturn(new PageImpl<>(List.of(secondSkill), PageRequest.of(1, 1), 2));
given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of());
given(skillVersionRepository.findBySkillId(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", 1, 1);
var stars = service.listMyStars("user-1", 1, 1, Map.of(), Set.of());
assertThat(stars.total()).isEqualTo(2);
assertThat(stars.page()).isEqualTo(1);

View file

@ -0,0 +1,92 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.social.SkillRatingService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class SkillSocialAppServiceTest {
private final SkillRepository skills = mock(SkillRepository.class);
private final SkillStarService stars = mock(SkillStarService.class);
private final SkillRatingService ratings = mock(SkillRatingService.class);
private final SkillSocialAppService service = new SkillSocialAppService(skills, new VisibilityChecker(), stars, ratings);
private Skill skill;
@BeforeEach
void setup() {
skill = new Skill(7L, "secret", "owner", SkillVisibility.PRIVATE);
skill.setLatestVersionId(11L);
when(skills.findById(1L)).thenReturn(Optional.of(skill));
}
@ParameterizedTest
@EnumSource(SkillVisibility.class)
void deniedAccessNeverReadsOrWritesSocialData(SkillVisibility visibility) {
skill.setVisibility(visibility);
if (visibility == SkillVisibility.PUBLIC) skill.setLatestVersionId(null);
assertRejected("outsider", Map.of(), Set.of());
}
@Test
void hiddenSkillDeniesOrdinaryMember() {
skill.setVisibility(SkillVisibility.PUBLIC);
skill.setHidden(true);
assertRejected("member", Map.of(7L, NamespaceRole.MEMBER), Set.of());
}
@Test
void missingObjectHasNoSocialSideEffects() {
when(skills.findById(1L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.star(1L, "viewer", Map.of(), Set.of())).isInstanceOf(DomainNotFoundException.class);
verifyNoInteractions(stars, ratings);
}
@Test
void legitimateRolesRetainAccess() {
allow("owner", Map.of(), Set.of());
allow("admin", Map.of(7L, NamespaceRole.ADMIN), Set.of());
allow("ns-owner", Map.of(7L, NamespaceRole.OWNER), Set.of());
allow("platform-admin", Map.of(), Set.of("SUPER_ADMIN"));
}
@Test
void publishedPublicAndNamespaceMemberAccessRetained() {
skill.setVisibility(SkillVisibility.PUBLIC);
allow("viewer", Map.of(), Set.of());
skill.setVisibility(SkillVisibility.NAMESPACE_ONLY);
allow("member", Map.of(7L, NamespaceRole.MEMBER), Set.of());
}
private void allow(String user, Map<Long, NamespaceRole> roles, Set<String> platformRoles) {
service.star(1L, user, roles, platformRoles);
service.rate(1L, user, (short) 4, roles, platformRoles);
service.isStarred(1L, user, roles, platformRoles);
service.getUserRating(1L, user, roles, platformRoles);
verify(stars).star(1L, user);
verify(stars).isStarred(1L, user);
verify(ratings).rate(1L, user, (short) 4);
verify(ratings).getUserRating(1L, user);
}
private void assertRejected(String user, Map<Long, NamespaceRole> roles, Set<String> platformRoles) {
assertThatThrownBy(() -> service.star(1L, user, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class);
assertThatThrownBy(() -> service.rate(1L, user, (short) 4, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class);
assertThatThrownBy(() -> service.isStarred(1L, user, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class);
assertThatThrownBy(() -> service.getUserRating(1L, user, roles, platformRoles)).isInstanceOf(DomainForbiddenException.class);
verifyNoInteractions(stars, ratings);
}
}

View file

@ -34,7 +34,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
return (
<Card
className="group relative h-full cursor-pointer overflow-hidden rounded-md border border-border/60 bg-card p-4 text-card-foreground transition-[transform,box-shadow,border-color] duration-150 ease-out card-hover focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15"
className="group relative h-full min-w-0 cursor-pointer overflow-hidden rounded-md border border-border/60 bg-card p-4 text-card-foreground transition-[transform,box-shadow,border-color] duration-150 ease-out card-hover focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15"
style={{ borderColor: 'hsl(var(--border-card))', boxShadow: 'var(--shadow-card)' }}
onClick={onClick}
onKeyDown={(event) => {
@ -51,15 +51,24 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
tabIndex={isInteractive ? 0 : undefined}
>
<div className="flex h-full flex-col">
<div className="flex items-start justify-between mb-3">
<div className="flex min-w-0 items-center gap-3">
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
<h3 className="font-semibold text-base group-hover:text-primary transition-colors duration-150" style={{ color: 'hsl(var(--foreground))' }}>
<div className="mb-3 flex min-w-0 items-start gap-3">
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
<div className="min-w-0 flex-1">
<h3
className="line-clamp-2 text-base font-semibold [overflow-wrap:anywhere] group-hover:text-primary transition-colors duration-150"
style={{ color: 'hsl(var(--foreground))' }}
title={skill.displayName}
>
{skill.displayName}
</h3>
</div>
<div className="flex items-center gap-2">
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
<div className="mt-2 grid justify-items-start">
<NamespaceBadge
className="block max-w-full truncate"
type="TEAM"
name={`@${skill.namespace}`}
title={`@${skill.namespace}`}
/>
</div>
</div>
</div>
@ -77,11 +86,11 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
{complianceItems.slice(0, 2).map((item, index) => (
<span
key={`${item.standard ?? 'standard'}-${item.controlId ?? index}`}
className="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"
className="inline-flex max-w-full items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"
title={item.title}
>
<ShieldCheck className="h-3 w-3" />
{[item.standard, item.controlId].filter(Boolean).join(' · ')}
<ShieldCheck className="h-3 w-3 shrink-0" />
<span className="truncate">{[item.standard, item.controlId].filter(Boolean).join(' · ')}</span>
</span>
))}
{complianceItems.length > 2 ? (
@ -92,9 +101,9 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
</div>
) : null}
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
{headlineVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono text-[11px] font-semibold" style={{ color: 'hsl(215 30% 35%)' }}>
<span title={`v${headlineVersion.version}`} className="max-w-full truncate px-2.5 py-1 rounded-full bg-secondary/60 font-mono text-[11px] font-semibold text-secondary-foreground">
v{headlineVersion.version}
</span>
)}
@ -122,15 +131,15 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
)}
</div>
{(skill.ownerDisplayName || skill.updatedAt) && (
<div className="mt-2 pt-2 border-t border-border/50 flex items-center gap-3 text-xs text-muted-foreground">
<div className="mt-2 pt-2 border-t border-border/50 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
{skill.ownerDisplayName && (
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
{skill.ownerDisplayName}
<span className="flex min-w-0 flex-1 items-center gap-1" title={skill.ownerDisplayName}>
<User className="h-3 w-3 shrink-0" />
<span className="truncate">{skill.ownerDisplayName}</span>
</span>
)}
{skill.updatedAt && (
<span className="flex items-center gap-1">
<span className="flex shrink-0 items-center gap-1 whitespace-nowrap">
<Clock className="w-3 h-3" />
{formatRelativeTime(skill.updatedAt, i18n.language)}
</span>

View file

@ -10,7 +10,7 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;
const isSuite = resource.resourceType === 'SUITE'
return (
<Card
className="group h-full cursor-pointer p-5 transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70"
className="group h-full min-w-0 cursor-pointer p-5 transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70"
role="link"
tabIndex={0}
onClick={onClick}
@ -22,22 +22,24 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;
}}
>
<div className="flex h-full flex-col gap-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0">
<span className="mb-2 inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-1 text-xs font-medium">
{isSuite ? <Boxes className="h-3.5 w-3.5" /> : <Wrench className="h-3.5 w-3.5" />}
{t(isSuite ? 'suite.resourceTypeSuite' : 'suite.resourceTypeSkill')}
</span>
<h3 className="break-words text-lg font-semibold group-hover:text-primary">{resource.displayName}</h3>
<h3 className="line-clamp-2 text-lg font-semibold [overflow-wrap:anywhere] group-hover:text-primary" title={resource.displayName}>{resource.displayName}</h3>
</div>
<div className="mt-2 grid justify-items-start">
<NamespaceBadge
className="block max-w-full truncate"
type={resource.namespace === 'global' ? 'GLOBAL' : 'TEAM'}
name={`@${resource.namespace}`}
title={`@${resource.namespace}`}
/>
</div>
<NamespaceBadge
className="max-w-[50%] shrink-0 truncate"
type={resource.namespace === 'global' ? 'GLOBAL' : 'TEAM'}
name={`@${resource.namespace}`}
title={`@${resource.namespace}`}
/>
</div>
<p className="line-clamp-2 min-h-10 text-sm text-muted-foreground">
<p className="line-clamp-2 min-h-10 text-sm [overflow-wrap:anywhere] text-muted-foreground">
{resource.summary || t('suite.noSummary')}
</p>
{isSuite && resource.labels?.length ? (
@ -53,7 +55,7 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;
))}
</div>
) : null}
<div className="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
<span
className="max-w-[50%] truncate rounded-full bg-secondary/70 px-2.5 py-1 font-mono"
title={`v${resource.version}`}