feat(namespace): add one-time invitation links

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 11:55:47 +08:00
parent b1d5391102
commit 9c997f7eca
46 changed files with 1028 additions and 54 deletions

View file

@ -73,6 +73,15 @@ Discover a security issue in the namespace and temporarily freeze all publish op
5. Select a role (Owner / Admin / Member)
6. Confirm to add
**Invite with a link**:
1. An Owner or Admin opens the Members page of an ACTIVE team space and selects "Invite member".
2. Share the copied link securely. It expires after 24 hours and can be redeemed once by one person who is not already a member.
3. The recipient must first pass the usual SkillHub login and account access rules. The page shows the space, current account, and MEMBER permissions. Joining happens only after explicit confirmation.
4. "Revoke invitation" invalidates the link immediately. Creating a new link asks for confirmation before replacing a still usable one; existing members remain members.
Anyone who obtains the link can use it after normal account admission. Do not post it in a public channel. The login return URL does not contain the invitation token; the current browser tab stores it temporarily until joining or terminal invalidation.
**Manage Permissions**:
1. Find the target user in the member list

View file

@ -73,6 +73,15 @@
5. 选择角色(Owner / Admin / Member)
6. 确认添加
**通过链接邀请成员**:
1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。
2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。
3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。
4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。
邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。
**管理权限**:
1. 在成员列表中找到目标用户

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12

View file

@ -0,0 +1,19 @@
## Context
现有 Namespace 成员只能由管理员直接添加。`NamespaceMemberService`、平台管理服务、生命周期服务和账号合并均会修改同一 Namespace 的状态或成员,但目前没有共同的 Namespace 行锁。前端已有 Session/CSRF、登录 returnTo 与 BASE_PATH 逻辑;当前 returnTo helper 会保留 fragment,所以 `/join` 必须先移除 Token 再触发登录跳转。
## Decisions
1. 每个 Namespace 只保存一条当前邀请:Token SHA-256、签发人和时间、过期时间、消费人和时间。原文只在生成响应中返回一次。作废删除当前邀请记录或等价地使其不可匹配,写审计;不删除已加入成员。
2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废锁操作者后锁 Namespace;接受锁接收者后锁 Namespace。纯 Namespace 生命周期与删除操作锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。
3. 前端在 `/join` 入口同步读取 fragment,放入当前标签页 sessionStorage,保留 path/query 清除 fragment,再应用原登录逻辑。登录 `returnTo=/join` 不传 Token。分享链接复用 `resolvePublicRegistryUrl` 构造并兼容 BASE_PATH。
4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。
5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。
6. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。
## Risks and verification
- 账号合并已先锁 UserAccount;邀请创建/替换/作废/接受、直接成员变更、平台成员管理及账号合并要按相同的用户后 Namespace 顺序加锁,避免与成员或审计外键检查形成环。
- 并发兑换、直接添加与兑换、替换/作废与兑换、冻结/归档/删除与兑换均需事务级验证。
- 根路径及子路径部署都要验证分享 URL、登录回跳和 fragment 清理。
- API Token、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。

View file

@ -0,0 +1,30 @@
## Why
Namespace 管理员目前只能逐个选择现有用户添加成员。短期、单次邀请链接能让成员自行加入,同时让管理员控制链接失效和成员权限边界。
## What Changes
- ACTIVE TEAM 的 OWNER/ADMIN 可以生成一条 24 小时内有效、仅一名非成员可兑换的邀请链接;替换未使用链接前须确认。
- 管理员可以单独作废当前邀请,不需要生成新链接;不影响已经加入的成员。
- 持链接者先按现有注册、登录和账号准入策略获得可用账号,再看到空间、当前账号和 MEMBER 权限,明确确认后加入。
- Token 不存明文、不进 URL path/query 或登录回跳参数;创建、预览、兑换、作废均受权限及事务约束。
- 邀请兑换与成员及 Namespace 生命周期操作并发时保持一致,不出现重复成员或部分消费。
## Capabilities
### New Capabilities
- `namespace-invitations`: 邀请创建、替换、作废、预览、确认加入、安全与并发规则。
### Modified Capabilities
无。
## Impact
- Namespace 领域、成员管理、账号合并事务、数据库迁移、审计、Session/CSRF 接口和 OpenAPI 类型。
- Namespace 管理页面、邀请页、登录回跳与子路径部署、多语言文案及浏览器验证。
## 相对 Issue #911 的调整
原 Issue 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。

View file

@ -0,0 +1,96 @@
## Purpose
让 Namespace 管理员使用可控的短期单次链接邀请新成员。
## ADDED Requirements
### Requirement: REQ-INV-01 管理员 SHALL 创建、替换及作废邀请
有权管理员 SHALL 可创建、替换及作废邀请。
只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认;管理员可以单独作废当前邀请,且已加入成员不受影响。
#### Scenario: 替换未使用链接
- **WHEN** 管理员再次点击邀请且当前链接仍可兑换
- **THEN** 页面先说明旧链接将失效并要求确认
- **AND** 仅确认后生成新链接,旧链接立即失效
#### Scenario: 单独作废
- **WHEN** 管理员点击作废并确认
- **THEN** 当前链接立即失效,不会创建新链接或移除成员
### Requirement: REQ-INV-02 邀请 SHALL 服从现有账号准入
邀请 SHALL 服从现有账号准入规则。
邀请只授予目标 Namespace 的 MEMBER 权限,不使用户绕过现有注册、登录、外部身份准入和账号审批。接受者必须先拥有可用登录会话,看到目标 Namespace、当前账号和 MEMBER 可访问及发布资源的说明,再明确确认加入。页面加载不得自动兑换。
#### Scenario: 新用户未通过准入
- **WHEN** 持链接者无法按既有策略获得可用账号
- **THEN** 邀请不能使其进入目标 Namespace
#### Scenario: 已登录非成员
- **WHEN** 非成员打开有效链接
- **THEN** 页面展示 Namespace、账号和权限,只有点击确认才新增 MEMBER
### Requirement: REQ-INV-03 Token SHALL 避免意外泄露
Token SHALL 避免出现在日志、持久化明文和登录 URL 中。
Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#token`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。
Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。
#### Scenario: 经过登录回跳
- **WHEN** 未登录者打开邀请后登录
- **THEN** Token 不出现在登录回跳 URL,回到原标签页后才从 sessionStorage 恢复邀请
#### Scenario: 子路径部署
- **WHEN** 应用部署在 `/skillhub/`
- **THEN** 分享链接指向公开基地址下 `/skillhub/join#token`,应用内 returnTo 仍为 `/join`
### Requirement: REQ-INV-04 服务端 SHALL 在接受时重新判断状态
服务端 SHALL 在接受邀请时重新判断全部状态。
预览不消费邀请。接受时重新检查 Token 是否仍为当前、用户是否已有成员关系、Namespace 是否 ACTIVE TEAM、邀请是否未消费且未过期。Token 匹配当前邀请且用户已是成员时返回 ALREADY_MEMBER,不再检查过期、消费或 Namespace 冻结归档状态,也不消费。未知、过期、替换、作废或已用 Token 对非成员返回统一失效响应。冻结或归档时非成员不能加入也不消费,恢复后仅在原有效期内可兑换。
统一失效响应使用 HTTP 404、envelope `code=404` 与 `data=null`;前端只按状态处理,不解析本地化文案。
#### Scenario: 预览后链接被替换
- **WHEN** 用户看到预览后管理员替换或作废了链接
- **THEN** 接受请求失败且不新增成员
#### Scenario: 已有成员打开当前链接
- **WHEN** Token 仍是当前邀请且当前用户已经是成员
- **THEN** 返回 ALREADY_MEMBER,直接进入 Namespace,不消耗邀请
#### Scenario: 被移除者重用已消费链接
- **WHEN** 曾兑换的成员后来被移除
- **THEN** 原链接仍保持已消费,不能再次加入
### Requirement: REQ-INV-05 并发操作 SHALL 保持成员和邀请原子一致
并发操作 SHALL 保持成员和邀请原子一致。
邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废锁操作者,接受锁接收者;纯 Namespace 生命周期与删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。
#### Scenario: 两人同时兑换
- **WHEN** 两个非成员同时确认同一链接
- **THEN** 最多一人新增 MEMBER,另一人收到统一失效结果
#### Scenario: 管理员直接添加与邀请并发
- **WHEN** 管理员直接添加同一用户和该用户兑换邀请同时发生
- **THEN** 不出现未处理的唯一约束异常、重复成员或错误消费
### Requirement: REQ-INV-06 前端 SHALL 完成链接分享和状态闭环
前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。
管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
#### Scenario: 剪贴板写入失败
- **WHEN** 创建成功但浏览器拒绝写剪贴板
- **THEN** 页面显示同一链接供手动复制,不再次生成邀请
#### Scenario: 网络错误
- **WHEN** 预览或接受遇到可重试网络错误
- **THEN** 页面保留 Token,用户可重试而无需新邀请

View file

@ -0,0 +1,17 @@
## 1. 持久化与事务
- [ ] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。
- [ ] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。
- [ ] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。
## 2. API 与页面
- [ ] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。
- [ ] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。
- [ ] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。
## 3. 验证与交付
- [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。
- [ ] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。

View file

@ -0,0 +1,88 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest;
import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest;
import com.iflytek.skillhub.exception.UnauthorizedException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping({"/api/v1", "/api/web"})
public class NamespaceInvitationController extends BaseApiController {
private final NamespaceInvitationService invitations;
public NamespaceInvitationController(NamespaceInvitationService invitations, ApiResponseFactory responseFactory) {
super(responseFactory);
this.invitations = invitations;
}
@GetMapping("/namespaces/{slug}/invitation")
public ApiResponse<InvitationStatus> status(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request, HttpServletResponse response) {
noStore(response);
requireSession(principal, request);
return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId())));
}
@PostMapping("/namespaces/{slug}/invitation")
public ApiResponse<NamespaceInvitationService.Created> create(@PathVariable String slug,
@RequestBody(required = false) NamespaceInvitationCreateRequest body,
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
noStore(response);
requireSession(principal, request);
return ok("response.success.created", invitations.create(slug, principal.userId(), body != null && body.replace()));
}
@DeleteMapping("/namespaces/{slug}/invitation")
public ApiResponse<MessageResponse> revoke(@PathVariable String slug,
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
noStore(response);
requireSession(principal, request);
invitations.revoke(slug, principal.userId());
return ok("response.success.deleted", new MessageResponse("Invitation revoked"));
}
@PostMapping("/invitations/preview")
public ApiResponse<NamespaceInvitationService.Preview> preview(@Valid @RequestBody NamespaceInvitationTokenRequest body,
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
noStore(response);
requireSession(principal, request);
return ok("response.success.read", invitations.preview(body.token(), principal.userId()));
}
@PostMapping("/invitations/accept")
public ApiResponse<NamespaceInvitationService.Preview> accept(@Valid @RequestBody NamespaceInvitationTokenRequest body,
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
noStore(response);
requireSession(principal, request);
return ok("response.success.updated", invitations.accept(body.token(), principal.userId()));
}
private void requireSession(PlatformPrincipal principal, HttpServletRequest request) {
if (principal == null || "api_token".equals(principal.oauthProvider()) || request.getSession(false) == null) {
throw new UnauthorizedException("error.auth.required");
}
}
private void noStore(HttpServletResponse response) {
response.setHeader("Cache-Control", "no-store, private");
response.setHeader("Pragma", "no-cache");
}
public record InvitationStatus(boolean active) {}
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.dto;
public record NamespaceInvitationCreateRequest(boolean replace) {}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record NamespaceInvitationTokenRequest(@NotBlank String token) {}

View file

@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
@ -48,6 +49,7 @@ public class AdminNamespaceAppService {
private final NamespaceGovernanceService namespaceGovernanceService;
private final NamespaceMemberService namespaceMemberService;
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberCandidateService namespaceMemberCandidateService;
private final NamespaceAccessPolicy namespaceAccessPolicy;
private final UserAccountRepository userAccountRepository;
@ -57,6 +59,7 @@ public class AdminNamespaceAppService {
NamespaceGovernanceService namespaceGovernanceService,
NamespaceMemberService namespaceMemberService,
NamespaceMemberRepository namespaceMemberRepository,
NamespaceRepository namespaceRepository,
NamespaceMemberCandidateService namespaceMemberCandidateService,
NamespaceAccessPolicy namespaceAccessPolicy,
UserAccountRepository userAccountRepository) {
@ -65,6 +68,7 @@ public class AdminNamespaceAppService {
this.namespaceGovernanceService = namespaceGovernanceService;
this.namespaceMemberService = namespaceMemberService;
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceRepository = namespaceRepository;
this.namespaceMemberCandidateService = namespaceMemberCandidateService;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.userAccountRepository = userAccountRepository;
@ -139,7 +143,7 @@ public class AdminNamespaceAppService {
@Transactional
public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) {
Namespace namespace = requireMutableTeamNamespace(slug);
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(request.userId()));
if (request.role() == NamespaceRole.OWNER) {
throw new DomainBadRequestException("error.namespace.member.owner.assignDirect");
}
@ -152,7 +156,7 @@ public class AdminNamespaceAppService {
@Transactional
public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) {
Namespace namespace = requireMutableTeamNamespace(slug);
Namespace namespace = lockedMutableTeamNamespace(slug, request.members().stream().map(MemberRequest::userId).toList());
List<BatchMemberResult> results = new ArrayList<>();
int successCount = 0;
int failureCount = 0;
@ -178,7 +182,7 @@ public class AdminNamespaceAppService {
@Transactional
public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) {
Namespace namespace = requireMutableTeamNamespace(slug);
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId));
if (request.role() == NamespaceRole.OWNER) {
throw new DomainBadRequestException("error.namespace.member.owner.setDirect");
}
@ -194,7 +198,7 @@ public class AdminNamespaceAppService {
@Transactional
public MessageResponse removeMember(String slug, String userId, String actorUserId) {
Namespace namespace = requireMutableTeamNamespace(slug);
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId));
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound"));
if (member.getRole() == NamespaceRole.OWNER) {
@ -206,7 +210,12 @@ public class AdminNamespaceAppService {
@Transactional
public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) {
Namespace namespace = requireMutableTeamNamespace(slug);
Long namespaceId = namespaceRepository.findIdBySlug(slug)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
String oldOwnerId = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespaceId, List.of(NamespaceRole.OWNER))
.stream().findFirst().map(NamespaceMember::getUserId)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound"));
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(oldOwnerId, newOwnerId));
NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER))
.stream()
.findFirst()
@ -295,15 +304,19 @@ public class AdminNamespaceAppService {
.collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole));
}
private Namespace requireMutableTeamNamespace(String slug) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
if (namespaceAccessPolicy.isImmutable(namespace)) {
throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug());
private Namespace lockedMutableTeamNamespace(String slug, List<String> userIds) {
Long namespaceId = namespaceRepository.findIdBySlug(slug)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
userIds.stream().distinct().sorted().forEach(userAccountRepository::findLockedById);
Namespace locked = namespaceRepository.findLockedById(namespaceId)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
if (namespaceAccessPolicy.isImmutable(locked)) {
throw new DomainBadRequestException("error.namespace.system.immutable", slug);
}
if (!namespaceAccessPolicy.canManageMembers(namespace)) {
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
if (!namespaceAccessPolicy.canManageMembers(locked)) {
throw new DomainBadRequestException("error.namespace.readonly", slug);
}
return namespace;
return locked;
}
private NamespaceStatus parseStatus(String status) {

View file

@ -84,8 +84,7 @@ public class NamespacePortalCommandAppService {
@Transactional
public MessageResponse deleteNamespace(String slug, String userId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
namespaceService.deleteNamespace(namespace.getId(), userId);
namespaceService.deleteNamespace(namespaceId(slug), userId);
return new MessageResponse("Namespace deleted successfully");
}
@ -147,9 +146,8 @@ public class NamespacePortalCommandAppService {
@Transactional
public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
NamespaceMember member = namespaceMemberService.addMember(
namespace.getId(),
namespaceId(slug),
memberUserId,
role,
operatorUserId
@ -161,8 +159,7 @@ public class NamespacePortalCommandAppService {
// Intentionally not @Transactional: each addMember runs in its own transaction
// so partial success is possible (some members added even if others fail).
public BatchMemberResponse batchAddMembers(String slug, List<MemberRequest> members, String operatorUserId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
Long namespaceId = namespace.getId();
Long namespaceId = namespaceId(slug);
List<BatchMemberResult> results = new ArrayList<>();
int successCount = 0;
@ -195,8 +192,7 @@ public class NamespacePortalCommandAppService {
@Transactional
public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId);
namespaceMemberService.removeMember(namespaceId(slug), memberUserId, operatorUserId);
return new MessageResponse("Member removed successfully");
}
@ -205,9 +201,8 @@ public class NamespacePortalCommandAppService {
String userId,
UpdateMemberRoleRequest request,
String operatorUserId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
NamespaceMember member = namespaceMemberService.updateMemberRole(
namespace.getId(),
namespaceId(slug),
userId,
request.role(),
operatorUserId
@ -217,8 +212,7 @@ public class NamespacePortalCommandAppService {
@Transactional
public MessageResponse transferOwnership(String slug, String newOwnerId, String currentOwnerId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
namespaceMemberService.transferOwnership(namespace.getId(), currentOwnerId, newOwnerId);
namespaceMemberService.transferOwnership(namespaceId(slug), currentOwnerId, newOwnerId);
return new MessageResponse("Ownership transferred successfully");
}
@ -226,4 +220,10 @@ public class NamespacePortalCommandAppService {
return principal.platformRoles().contains("SKILL_ADMIN")
|| principal.platformRoles().contains("SUPER_ADMIN");
}
private Long namespaceId(String slug) {
return namespaceRepository.findIdBySlug(slug)
.orElseThrow(() -> new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException(
"error.namespace.slug.notFound", slug));
}
}

View file

@ -1,6 +1,6 @@
CREATE TABLE namespace_invitation (
namespace_id BIGINT PRIMARY KEY REFERENCES namespace(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL UNIQUE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
issued_by VARCHAR(128) NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,

View file

@ -88,6 +88,8 @@ label.translation.locale.conflict=Duplicate label translation locale
error.namespace.slug.exists=Namespace slug ''{0}'' already exists
error.namespace.id.notFound=Namespace not found: {0}
error.namespace.slug.notFound=Namespace not found: {0}
error.namespace.invite.invalid=Invitation not found or expired
error.namespace.invite.replaceConfirmationRequired=Confirm replacement of the current invitation
error.namespace.membership.required=Namespace membership required
error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members
error.namespace.admin.required=Namespace owner or admin role required

View file

@ -85,6 +85,8 @@ label.translation.locale.conflict=Дублирующаяся локаль пер
error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует
error.namespace.id.notFound=Пространство имён не найдено: {0}
error.namespace.slug.notFound=Пространство имён не найдено: {0}
error.namespace.invite.invalid=Приглашение не найдено или недействительно
error.namespace.invite.replaceConfirmationRequired=Подтвердите замену текущего приглашения
error.namespace.membership.required=Требуется членство в пространстве имён
error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён
error.namespace.admin.required=Требуется роль владельца или администратора пространства имён

View file

@ -88,6 +88,8 @@ label.translation.locale.conflict=标签翻译 locale 重复
error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在
error.namespace.id.notFound=未找到命名空间:{0}
error.namespace.slug.notFound=未找到命名空间:{0}
error.namespace.invite.invalid=邀请不存在或已失效
error.namespace.invite.replaceConfirmationRequired=请先确认替换当前邀请
error.namespace.membership.required=需要先加入该命名空间
error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员
error.namespace.admin.required=需要命名空间管理员或所有者权限

View file

@ -0,0 +1,52 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.portal.NamespaceInvitationController;
import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest;
import com.iflytek.skillhub.exception.UnauthorizedException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class NamespaceInvitationControllerTest {
private final NamespaceInvitationService invitations = mock(NamespaceInvitationService.class);
private final ApiResponseFactory responses = mock(ApiResponseFactory.class);
private final NamespaceInvitationController controller = new NamespaceInvitationController(invitations, responses);
private final HttpServletRequest request = mock(HttpServletRequest.class);
private final HttpServletResponse response = mock(HttpServletResponse.class);
@Test
void refusesApiTokenEvenWhenItCarriesAnAdminPlatformRole() {
when(request.getSession(false)).thenReturn(mock(HttpSession.class));
PlatformPrincipal token = new PlatformPrincipal("admin", "Admin", null, null, "api_token", Set.of("SUPER_ADMIN"));
assertThatThrownBy(() -> controller.create("team", new NamespaceInvitationCreateRequest(true), token, request, response))
.isInstanceOf(UnauthorizedException.class);
verifyNoInteractions(invitations);
}
@Test
void refusesAuthenticationWithoutAnExistingBrowserSession() {
PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of());
assertThatThrownBy(() -> controller.preview(new com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest("secret"),
principal, request, response)).isInstanceOf(UnauthorizedException.class);
verifyNoInteractions(invitations);
}
@Test
void sendsNoStoreHeadersBeforeReturningAnInvitation() {
when(request.getSession(false)).thenReturn(mock(HttpSession.class));
PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of());
controller.create("team", new NamespaceInvitationCreateRequest(false), principal, request, response);
verify(response).setHeader("Cache-Control", "no-store, private");
verify(response).setHeader("Pragma", "no-cache");
verify(invitations).create("team", "owner", false);
}
}

View file

@ -0,0 +1,108 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.infra.jpa.NamespaceInvitationJpaRepository;
import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository;
import com.iflytek.skillhub.infra.jpa.NamespaceMemberJpaRepository;
import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository;
import java.time.Clock;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
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.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.PlatformTransactionManager;
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({NamespaceInvitationService.class, AuditLogService.class, NamespaceInvitationPostgresTest.ClockConfig.class})
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class NamespaceInvitationPostgresTest {
@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");
}
@TestConfiguration
static class ClockConfig {
@Bean Clock clock() { return Clock.systemUTC(); }
}
@Autowired private NamespaceInvitationService invitations;
@Autowired private NamespaceInvitationJpaRepository invitationRows;
@Autowired private NamespaceJpaRepository namespaces;
@Autowired private NamespaceMemberJpaRepository members;
@Autowired private UserAccountJpaRepository users;
@Autowired private PlatformTransactionManager transactionManager;
@Test
void twoConcurrentAcceptsCreateOnlyOneMemberAndOneConsumption() throws Exception {
String suffix = UUID.randomUUID().toString().substring(0, 8);
String owner = "inv-owner-" + suffix;
String first = "inv-a-" + suffix;
String second = "inv-b-" + suffix;
Long namespaceId = new TransactionTemplate(transactionManager).execute(status -> {
users.saveAndFlush(new UserAccount(owner, owner, null, null));
users.saveAndFlush(new UserAccount(first, first, null, null));
users.saveAndFlush(new UserAccount(second, second, null, null));
Namespace namespace = namespaces.saveAndFlush(new Namespace("inv-" + suffix, "Invitation test", owner));
members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER));
return namespace.getId();
});
String token = invitations.create("inv-" + suffix, owner).token();
CountDownLatch start = new CountDownLatch(1);
try (var pool = Executors.newFixedThreadPool(2)) {
var one = pool.submit(() -> attempt(start, token, first));
var two = pool.submit(() -> attempt(start, token, second));
start.countDown();
assertThat(one.get(20, TimeUnit.SECONDS) + two.get(20, TimeUnit.SECONDS)).isEqualTo(1);
}
assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged())
.getContent().stream().filter(member -> member.getRole() == NamespaceRole.MEMBER)).hasSize(1);
assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedBy()).isIn(first, second);
}
private int attempt(CountDownLatch start, String token, String userId) throws InterruptedException {
start.await();
try {
invitations.accept(token, userId);
return 1;
} catch (DomainNotFoundException expected) {
return 0;
}
}
}

View file

@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
@ -40,6 +41,7 @@ class AdminNamespaceAppServiceTest {
private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class);
private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class);
private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class);
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
private final NamespaceMemberCandidateService namespaceMemberCandidateService = mock(NamespaceMemberCandidateService.class);
private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class);
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
@ -49,6 +51,7 @@ class AdminNamespaceAppServiceTest {
namespaceGovernanceService,
namespaceMemberService,
namespaceMemberRepository,
namespaceRepository,
namespaceMemberCandidateService,
namespaceAccessPolicy,
userAccountRepository
@ -84,6 +87,8 @@ class AdminNamespaceAppServiceTest {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
NamespaceMember saved = member(10L, 1L, "user-2", NamespaceRole.ADMIN);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace);
when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")).thenReturn(Optional.empty());
@ -100,6 +105,8 @@ class AdminNamespaceAppServiceTest {
void updateMemberRoleRejectsDirectOwnerAssignment() {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace);
when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true);
@ -118,6 +125,8 @@ class AdminNamespaceAppServiceTest {
NamespaceMember currentOwner = member(10L, 1L, "owner-1", NamespaceRole.OWNER);
NamespaceMember newOwner = member(11L, 1L, "admin-1", NamespaceRole.ADMIN);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace);
when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true);
when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(1L, List.of(NamespaceRole.OWNER)))
@ -138,6 +147,8 @@ class AdminNamespaceAppServiceTest {
void globalNamespaceCannotBeMutatedFromAdminMemberApi() {
Namespace global = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL);
when(namespaceService.getNamespaceBySlug("global")).thenReturn(global);
when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(global));
when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L));
when(namespaceAccessPolicy.isImmutable(global)).thenReturn(true);
assertThatThrownBy(() -> service.addMember(

View file

@ -80,7 +80,7 @@ class NamespacePortalCommandAppServiceTest {
@Test
void deleteNamespace_delegatesToDomainService() {
Namespace namespace = namespace(7L, "team-alpha");
when(namespaceService.getNamespaceBySlug("team-alpha")).thenReturn(namespace);
when(namespaceRepository.findIdBySlug("team-alpha")).thenReturn(Optional.of(7L));
MessageResponse response = service.deleteNamespace("team-alpha", "owner-1");
@ -102,7 +102,7 @@ class NamespacePortalCommandAppServiceTest {
ReflectionTestUtils.setField(member, "id", 10L);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("user-2"))
@ -122,7 +122,7 @@ class NamespacePortalCommandAppServiceTest {
NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER);
ReflectionTestUtils.setField(member, "id", 20L);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberService.addMember(1L, "ghost", NamespaceRole.MEMBER, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("ghost"))
@ -142,7 +142,7 @@ class NamespacePortalCommandAppServiceTest {
ReflectionTestUtils.setField(member, "id", 10L);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("user-2"))
@ -166,7 +166,7 @@ class NamespacePortalCommandAppServiceTest {
NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER);
ReflectionTestUtils.setField(member, "id", 20L);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberService.updateMemberRole(1L, "ghost", NamespaceRole.ADMIN, "owner-1"))
.thenReturn(member);
when(userAccountRepository.findById("ghost"))
@ -186,7 +186,7 @@ class NamespacePortalCommandAppServiceTest {
@Test
void transferOwnership_success() {
Namespace ns = namespace(1L, "team-a");
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
MessageResponse result = service.transferOwnership("team-a", "new-owner", "current-owner");
@ -197,7 +197,7 @@ class NamespacePortalCommandAppServiceTest {
@Test
void transferOwnership_nonOwnerFails() {
Namespace ns = namespace(1L, "team-a");
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
doThrow(new DomainBadRequestException("error.namespace.owner.current.invalid"))
.when(namespaceMemberService).transferOwnership(1L, "non-owner", "new-owner");
@ -208,7 +208,7 @@ class NamespacePortalCommandAppServiceTest {
@Test
void transferOwnership_targetNotFoundFails() {
Namespace ns = namespace(1L, "team-a");
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
doThrow(new DomainBadRequestException("error.namespace.owner.new.notFound"))
.when(namespaceMemberService).transferOwnership(1L, "owner-1", "non-existent");
@ -220,7 +220,7 @@ class NamespacePortalCommandAppServiceTest {
void transferOwnership_frozenNamespaceFails() {
Namespace ns = namespace(1L, "team-a");
ns.setStatus(NamespaceStatus.FROZEN);
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
doThrow(new DomainBadRequestException("error.namespace.readonly", "team-a"))
.when(namespaceMemberService).transferOwnership(1L, "owner-1", "new-owner");

View file

@ -12,6 +12,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
@ -49,6 +50,7 @@ public class AccountMergeService {
private final UserRoleBindingRepository userRoleBindingRepository;
private final ApiTokenRepository apiTokenRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceRepository namespaceRepository;
private final Clock clock;
public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository,
@ -58,6 +60,7 @@ public class AccountMergeService {
UserRoleBindingRepository userRoleBindingRepository,
ApiTokenRepository apiTokenRepository,
NamespaceMemberRepository namespaceMemberRepository,
NamespaceRepository namespaceRepository,
Clock clock) {
this.mergeRequestRepository = mergeRequestRepository;
this.userAccountRepository = userAccountRepository;
@ -66,6 +69,7 @@ public class AccountMergeService {
this.userRoleBindingRepository = userRoleBindingRepository;
this.apiTokenRepository = apiTokenRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceRepository = namespaceRepository;
this.clock = clock;
}
@ -287,6 +291,8 @@ public class AccountMergeService {
private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) {
List<NamespaceMember> secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId);
secondaryMemberships.stream().map(NamespaceMember::getNamespaceId).distinct().sorted()
.forEach(namespaceRepository::findLockedById);
for (NamespaceMember secondaryMembership : secondaryMemberships) {
Optional<NamespaceMember> existingPrimaryMembership = namespaceMemberRepository
.findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId);

View file

@ -20,6 +20,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
@ -52,6 +53,8 @@ class AccountMergeServiceTest {
private ApiTokenRepository apiTokenRepository;
@Mock
private NamespaceMemberRepository namespaceMemberRepository;
@Mock
private NamespaceRepository namespaceRepository;
private AccountMergeService service;
private Clock clock;
@ -66,6 +69,7 @@ class AccountMergeServiceTest {
userRoleBindingRepository,
apiTokenRepository,
namespaceMemberRepository,
namespaceRepository,
clock
);
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -13,21 +14,26 @@ public class GlobalNamespaceMembershipService {
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final UserAccountRepository userAccountRepository;
public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository) {
NamespaceMemberRepository namespaceMemberRepository,
UserAccountRepository userAccountRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.userAccountRepository = userAccountRepository;
}
@Transactional
public void ensureMember(String userId) {
Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG)
userAccountRepository.findLockedById(userId);
Long namespaceId = namespaceRepository.findIdBySlug(GLOBAL_NAMESPACE_SLUG)
.orElseThrow(() -> new IllegalStateException("Missing built-in global namespace"));
namespaceRepository.findLockedById(namespaceId);
namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId)
namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.orElseGet(() -> namespaceMemberRepository.save(
new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER)
new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER)
));
}
}

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -18,15 +19,18 @@ public class NamespaceGovernanceService {
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceAccessPolicy namespaceAccessPolicy;
private final AuditLogService auditLogService;
private final UserAccountRepository userAccountRepository;
public NamespaceGovernanceService(NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
NamespaceAccessPolicy namespaceAccessPolicy,
AuditLogService auditLogService) {
AuditLogService auditLogService,
UserAccountRepository userAccountRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.auditLogService = auditLogService;
this.userAccountRepository = userAccountRepository;
}
@Transactional
@ -36,6 +40,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
NamespaceRole role = requireRole(namespace.getId(), actorUserId);
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
@ -57,6 +62,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug());
@ -73,6 +79,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
NamespaceRole role = requireRole(namespace.getId(), actorUserId);
if (namespace.getStatus() != NamespaceStatus.FROZEN) {
@ -93,6 +100,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
if (namespace.getStatus() != NamespaceStatus.FROZEN) {
throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug());
@ -110,6 +118,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
NamespaceRole role = requireRole(namespace.getId(), actorUserId);
if (namespace.getStatus() == NamespaceStatus.ARCHIVED) {
@ -131,6 +140,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
if (namespace.getStatus() == NamespaceStatus.ARCHIVED) {
throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug());
@ -147,6 +157,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
NamespaceRole role = requireRole(namespace.getId(), actorUserId);
if (namespace.getStatus() != NamespaceStatus.ARCHIVED) {
@ -167,6 +178,7 @@ public class NamespaceGovernanceService {
String requestId,
String clientIp,
String userAgent) {
userAccountRepository.findLockedById(actorUserId);
Namespace namespace = loadNamespaceBySlug(slug);
if (namespace.getStatus() != NamespaceStatus.ARCHIVED) {
throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug());
@ -178,7 +190,9 @@ public class NamespaceGovernanceService {
}
private Namespace loadNamespaceBySlug(String slug) {
Namespace namespace = namespaceRepository.findBySlug(slug)
Long namespaceId = namespaceRepository.findIdBySlug(slug)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
Namespace namespace = namespaceRepository.findLockedById(namespaceId)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
if (namespaceAccessPolicy.isImmutable(namespace)) {
throw new DomainBadRequestException("error.namespace.system.immutable", slug);

View file

@ -5,6 +5,7 @@ import java.util.Optional;
public interface NamespaceInvitationRepository {
Optional<NamespaceInvitation> findByNamespaceId(Long namespaceId);
Optional<NamespaceInvitation> findByTokenHash(String tokenHash);
Optional<Long> findNamespaceIdByTokenHash(String tokenHash);
NamespaceInvitation save(NamespaceInvitation invitation);
void deleteByNamespaceId(Long namespaceId);
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.springframework.stereotype.Service;
@ -42,6 +43,12 @@ public class NamespaceInvitationService {
@Transactional
public Created create(String slug, String actorId) {
return create(slug, actorId, false);
}
@Transactional
public Created create(String slug, String actorId, boolean replace) {
requireActiveLockedUser(actorId);
Namespace namespace = lock(slug);
requireManager(namespace, actorId);
requireActiveTeam(namespace);
@ -51,6 +58,10 @@ public class NamespaceInvitationService {
Instant now = clock.instant();
NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null);
boolean replaced = invitation != null;
if (invitation != null && invitation.getConsumedAt() == null
&& invitation.getExpiresAt().isAfter(now) && !replace) {
throw new DomainConflictException("error.namespace.invite.replaceConfirmationRequired");
}
if (invitation == null) {
invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400));
} else {
@ -63,6 +74,7 @@ public class NamespaceInvitationService {
@Transactional
public void revoke(String slug, String actorId) {
requireActiveLockedUser(actorId);
Namespace namespace = lock(slug);
requireManager(namespace, actorId);
requireActiveTeam(namespace);
@ -97,8 +109,11 @@ public class NamespaceInvitationService {
@Transactional
public Preview accept(String token, String userId) {
NamespaceInvitation located = current(token);
Namespace namespace = namespaces.findLockedById(located.getNamespaceId()).orElseThrow(this::invalid);
// Membership writers and account merge lock users before the Namespace root.
// The FK check on namespace_member would otherwise invert that order.
requireActiveLockedUser(userId);
Long namespaceId = locateNamespaceId(token);
Namespace namespace = namespaces.findLockedById(namespaceId).orElseThrow(this::invalid);
NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid);
if (!MessageDigest.isEqual(invitation.getTokenHash().getBytes(StandardCharsets.US_ASCII),
hash(token).getBytes(StandardCharsets.US_ASCII))) {
@ -117,8 +132,8 @@ public class NamespaceInvitationService {
}
private Namespace lock(String slug) {
Namespace found = namespaces.findBySlug(slug).orElseThrow(this::invalid);
return namespaces.findLockedById(found.getId()).orElseThrow(this::invalid);
Long namespaceId = namespaces.findIdBySlug(slug).orElseThrow(this::invalid);
return namespaces.findLockedById(namespaceId).orElseThrow(this::invalid);
}
private NamespaceInvitation current(String token) {
@ -126,6 +141,11 @@ public class NamespaceInvitationService {
return invitations.findByTokenHash(hash(token)).orElseThrow(this::invalid);
}
private Long locateNamespaceId(String token) {
if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid();
return invitations.findNamespaceIdByTokenHash(hash(token)).orElseThrow(this::invalid);
}
private void requireManager(Namespace namespace, String userId) {
NamespaceRole role = members.findByNamespaceIdAndUserId(namespace.getId(), userId)
.map(NamespaceMember::getRole).orElse(null);
@ -137,6 +157,10 @@ public class NamespaceInvitationService {
if (users.findById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid();
}
private void requireActiveLockedUser(String userId) {
if (users.findLockedById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid();
}
private void requireActiveTeam(Namespace namespace) {
if (namespace.getStatus() != NamespaceStatus.ACTIVE || namespace.getType() != NamespaceType.TEAM) throw invalid();
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@ -18,17 +19,24 @@ public class NamespaceMemberService {
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceService namespaceService;
private final NamespaceAccessPolicy namespaceAccessPolicy;
private final NamespaceRepository namespaceRepository;
private final UserAccountRepository userAccountRepository;
public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository,
NamespaceService namespaceService,
NamespaceAccessPolicy namespaceAccessPolicy) {
NamespaceAccessPolicy namespaceAccessPolicy,
NamespaceRepository namespaceRepository,
UserAccountRepository userAccountRepository) {
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceService = namespaceService;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.namespaceRepository = namespaceRepository;
this.userAccountRepository = userAccountRepository;
}
@Transactional
public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) {
lockUserThenNamespace(userId, namespaceId);
assertMemberMutationAllowed(namespaceId);
namespaceService.assertAdminOrOwner(namespaceId, operatorUserId);
@ -46,6 +54,7 @@ public class NamespaceMemberService {
@Transactional
public void removeMember(Long namespaceId, String userId, String operatorUserId) {
lockUserThenNamespace(userId, namespaceId);
assertMemberMutationAllowed(namespaceId);
namespaceService.assertAdminOrOwner(namespaceId, operatorUserId);
@ -61,6 +70,7 @@ public class NamespaceMemberService {
@Transactional
public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) {
lockUserThenNamespace(userId, namespaceId);
assertMemberMutationAllowed(namespaceId);
namespaceService.assertAdminOrOwner(namespaceId, operatorUserId);
@ -77,6 +87,14 @@ public class NamespaceMemberService {
@Transactional
public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) {
if (currentOwnerId.compareTo(newOwnerId) <= 0) {
userAccountRepository.findLockedById(currentOwnerId);
userAccountRepository.findLockedById(newOwnerId);
} else {
userAccountRepository.findLockedById(newOwnerId);
userAccountRepository.findLockedById(currentOwnerId);
}
namespaceRepository.findLockedById(namespaceId);
Namespace namespace = namespaceService.getNamespace(namespaceId);
if (!namespaceAccessPolicy.canTransferOwnership(namespace)) {
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
@ -117,4 +135,9 @@ public class NamespaceMemberService {
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
}
}
private void lockUserThenNamespace(String userId, Long namespaceId) {
userAccountRepository.findLockedById(userId);
namespaceRepository.findLockedById(namespaceId);
}
}

View file

@ -17,6 +17,7 @@ public interface NamespaceRepository {
List<Namespace> findBySlugIn(List<String> slugs);
Page<Namespace> findByIdIn(List<Long> ids, Pageable pageable);
Optional<Namespace> findBySlug(String slug);
Optional<Long> findIdBySlug(String slug);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
Namespace save(Namespace namespace);
void delete(Namespace namespace);

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -22,19 +23,22 @@ public class NamespaceService {
private final SkillRepository skillRepository;
private final ReviewTaskRepository reviewTaskRepository;
private final PromotionRequestRepository promotionRequestRepository;
private final UserAccountRepository userAccountRepository;
public NamespaceService(NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
NamespaceAccessPolicy namespaceAccessPolicy,
SkillRepository skillRepository,
ReviewTaskRepository reviewTaskRepository,
PromotionRequestRepository promotionRequestRepository) {
PromotionRequestRepository promotionRequestRepository,
UserAccountRepository userAccountRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.skillRepository = skillRepository;
this.reviewTaskRepository = reviewTaskRepository;
this.promotionRequestRepository = promotionRequestRepository;
this.userAccountRepository = userAccountRepository;
}
/**
@ -43,6 +47,7 @@ public class NamespaceService {
*/
@Transactional
public Namespace createNamespace(String slug, String displayName, String description, String creatorUserId) {
userAccountRepository.findLockedById(creatorUserId);
SlugValidator.validate(slug);
if (namespaceRepository.findBySlug(slug).isPresent()) {
@ -119,7 +124,8 @@ public class NamespaceService {
*/
@Transactional
public void deleteNamespace(Long namespaceId, String operatorUserId) {
Namespace namespace = getNamespace(namespaceId);
Namespace namespace = namespaceRepository.findLockedById(namespaceId)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId));
assertNotImmutable(namespace);
NamespaceRole role = requireRole(namespaceId, operatorUserId);

View file

@ -24,12 +24,14 @@ class GlobalNamespaceMembershipServiceTest {
@Mock
private NamespaceMemberRepository namespaceMemberRepository;
@Mock
private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository;
private GlobalNamespaceMembershipService service;
@BeforeEach
void setUp() {
service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository);
service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository, userAccountRepository);
}
@Test
@ -37,7 +39,7 @@ class GlobalNamespaceMembershipServiceTest {
Namespace global = new Namespace("global", "Global", "system");
setNamespaceId(global, 1L);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global));
when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty());
service.ensureMember("usr_1");
@ -55,7 +57,7 @@ class GlobalNamespaceMembershipServiceTest {
setNamespaceId(global, 1L);
NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global));
when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing));
service.ensureMember("usr_1");

View file

@ -3,7 +3,9 @@ package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@ -30,14 +32,23 @@ class NamespaceGovernanceServiceTest {
@Mock
private AuditLogService auditLogService;
@Mock
private UserAccountRepository userAccountRepository;
@InjectMocks
private NamespaceGovernanceService governanceService;
@BeforeEach
void lockResolvedNamespace() {
org.mockito.Mockito.lenient().when(namespaceRepository.findLockedById(org.mockito.ArgumentMatchers.anyLong()))
.thenAnswer(call -> namespaceRepository.findBySlug("team-a"));
}
@Test
void freezeNamespace_allowsAdminOnActiveTeamNamespace() {
Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1"))
.thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN)));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
@ -54,6 +65,7 @@ class NamespaceGovernanceServiceTest {
void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() {
Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1"))
.thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN)));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
@ -67,6 +79,7 @@ class NamespaceGovernanceServiceTest {
void restoreNamespace_movesArchivedNamespaceBackToActive() {
Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ARCHIVED);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1"))
.thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER)));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
@ -81,7 +94,8 @@ class NamespaceGovernanceServiceTest {
@Test
void freezeNamespace_rejectsGlobalNamespace() {
Namespace namespace = namespace(1L, "global", NamespaceType.GLOBAL, NamespaceStatus.ACTIVE);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L));
when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true);
assertThrows(DomainBadRequestException.class,
@ -92,6 +106,7 @@ class NamespaceGovernanceServiceTest {
void unfreezeNamespace_rejectsIllegalTransition() {
Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1"))
.thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER)));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);

View file

@ -39,6 +39,7 @@ class NamespaceInvitationServiceTest {
throw new AssertionError(e);
}
when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace));
when(namespaces.findIdBySlug("team")).thenReturn(Optional.of(7L));
when(namespaces.findLockedById(7L)).thenReturn(Optional.of(namespace));
when(namespaces.findById(7L)).thenReturn(Optional.of(namespace));
when(members.findByNamespaceIdAndUserId(7L, "owner"))
@ -47,6 +48,8 @@ class NamespaceInvitationServiceTest {
when(owner.getStatus()).thenReturn(com.iflytek.skillhub.domain.user.UserStatus.ACTIVE);
when(users.findById("owner")).thenReturn(Optional.of(owner));
when(users.findById("guest")).thenReturn(Optional.of(owner));
lenient().when(users.findLockedById("owner")).thenReturn(Optional.of(owner));
lenient().when(users.findLockedById("guest")).thenReturn(Optional.of(owner));
}
@Test
@ -65,4 +68,72 @@ class NamespaceInvitationServiceTest {
.isInstanceOf(DomainNotFoundException.class);
verify(invitations).findByTokenHash(any());
}
@Test
void acceptCreatesMemberAndConsumesExactlyTheCurrentInvitation() {
NamespaceInvitationService.Created created = service.create("team", "owner");
ArgumentCaptor<NamespaceInvitation> saved = ArgumentCaptor.forClass(NamespaceInvitation.class);
verify(invitations).save(saved.capture());
NamespaceInvitation invitation = saved.getValue();
when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation));
when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L));
when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation));
assertThat(service.preview(created.token(), "guest").alreadyMember()).isFalse();
assertThat(service.accept(created.token(), "guest").alreadyMember()).isFalse();
verify(members).save(argThat(member -> member.getRole() == NamespaceRole.MEMBER
&& member.getNamespaceId().equals(7L) && member.getUserId().equals("guest")));
assertThat(invitation.getConsumedBy()).isEqualTo("guest");
assertThat(invitation.getConsumedAt()).isEqualTo(clock.instant());
Optional<UserAccount> guest = users.findById("guest");
when(users.findLockedById("another")).thenReturn(guest);
when(users.findById("another")).thenReturn(guest);
assertThatThrownBy(() -> service.accept(created.token(), "another"))
.isInstanceOf(DomainNotFoundException.class);
}
@Test
void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() {
NamespaceInvitationService.Created created = service.create("team", "owner");
ArgumentCaptor<NamespaceInvitation> saved = ArgumentCaptor.forClass(NamespaceInvitation.class);
verify(invitations).save(saved.capture());
NamespaceInvitation invitation = saved.getValue();
invitation.consume("someone", clock.instant());
when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation));
when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L));
when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation));
when(members.findByNamespaceIdAndUserId(7L, "guest"))
.thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER)));
assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue();
assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue();
verify(members, never()).save(any());
}
@Test
void replacingCurrentInvitationMakesOldTokenInvalidAfterPreview() {
NamespaceInvitationService.Created first = service.create("team", "owner");
ArgumentCaptor<NamespaceInvitation> saved = ArgumentCaptor.forClass(NamespaceInvitation.class);
verify(invitations).save(saved.capture());
NamespaceInvitation invitation = saved.getValue();
when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation));
when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L));
when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation));
service.preview(first.token(), "guest");
service.create("team", "owner", true);
assertThatThrownBy(() -> service.accept(first.token(), "guest"))
.isInstanceOf(DomainNotFoundException.class);
verify(members, never()).save(any());
}
@Test
void revokeDeletesCurrentInvitationWithoutRemovingMembers() {
when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(new NamespaceInvitation(
7L, "a".repeat(64), "owner", clock.instant(), clock.instant().plusSeconds(86400))));
service.revoke("team", "owner");
verify(invitations).deleteByNamespaceId(7L);
verify(members, never()).deleteByNamespaceId(any());
}
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -22,6 +23,10 @@ class NamespaceMemberServiceTest {
private NamespaceService namespaceService;
@Mock
private NamespaceAccessPolicy namespaceAccessPolicy;
@Mock
private NamespaceRepository namespaceRepository;
@Mock
private UserAccountRepository userAccountRepository;
@InjectMocks
private NamespaceMemberService namespaceMemberService;

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -38,6 +39,8 @@ class NamespaceServiceTest {
@Mock
private PromotionRequestRepository promotionRequestRepository;
@Mock
private UserAccountRepository userAccountRepository;
@InjectMocks
private NamespaceService namespaceService;
@ -214,7 +217,7 @@ class NamespaceServiceTest {
Long namespaceId = 1L;
String operatorUserId = "owner-1";
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
@ -234,7 +237,7 @@ class NamespaceServiceTest {
Long namespaceId = 1L;
String operatorUserId = "admin-1";
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.ADMIN)).thenReturn(false);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
@ -252,7 +255,7 @@ class NamespaceServiceTest {
Long namespaceId = 1L;
String operatorUserId = "owner-1";
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))

View file

@ -3,6 +3,8 @@ package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.namespace.NamespaceInvitation;
import com.iflytek.skillhub.domain.namespace.NamespaceInvitationRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@ -10,5 +12,7 @@ import java.util.Optional;
@Repository
public interface NamespaceInvitationJpaRepository extends JpaRepository<NamespaceInvitation, Long>, NamespaceInvitationRepository {
Optional<NamespaceInvitation> findByTokenHash(String tokenHash);
@Query("select invitation.namespaceId from NamespaceInvitation invitation where invitation.tokenHash = :tokenHash")
Optional<Long> findNamespaceIdByTokenHash(@Param("tokenHash") String tokenHash);
void deleteByNamespaceId(Long namespaceId);
}

View file

@ -29,5 +29,8 @@ public interface NamespaceJpaRepository
List<Namespace> findBySlugIn(List<String> slugs);
Page<Namespace> findByIdIn(List<Long> ids, Pageable pageable);
Optional<Namespace> findBySlug(String slug);
@Override
@Query("select n.id from Namespace n where n.slug = :slug")
Optional<Long> findIdBySlug(@Param("slug") String slug);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
}

View file

@ -747,6 +747,37 @@ export const labelApi = {
}
export const namespaceApi = {
async invitationStatus(slug: string): Promise<{ active: boolean }> {
return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`)
},
async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string }> {
return fetchJson<{ token: string; expiresAt: string }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, {
method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ replace }),
})
},
async revokeInvitation(slug: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, {
method: 'DELETE', headers: await ensureCsrfHeaders(),
})
},
async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> {
return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/preview`, {
method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ token }),
})
},
async acceptInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> {
return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/accept`, {
method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ token }),
})
},
async create(request: CreateNamespaceRequest): Promise<Namespace> {
const namespace = await fetchJson<Namespace>('/api/v1/namespaces', {
method: 'POST',

View file

@ -7,6 +7,7 @@ import { RouteError } from '@/shared/components/route-error'
import { createRedirectAuthenticated, createRequireAuth, isSafeAuthReturnTo } from '@/shared/lib/auth-route'
import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
import { captureInvitationFragment } from '@/shared/lib/namespace-invitation'
/**
* Central route registry for the SkillHub web app.
@ -16,6 +17,7 @@ import { normalizeSearchQuery } from '@/shared/lib/search-query'
*/
// Capture original URL before TanStack Router rewrites it
const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : ''
captureInvitationFragment()
// Export for use in cli-auth page
export { ORIGINAL_URL_SEARCH }
@ -95,6 +97,7 @@ const SuitesPage = createLazyRouteComponent(() => import('@/pages/suites'), 'Sui
const SuiteDetailPage = createLazyRouteComponent(() => import('@/pages/suite-detail'), 'SuiteDetailPage')
const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
const JoinPage = createLazyRouteComponent(() => import('@/pages/join'), 'JoinPage')
const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage')
const dashboardRouteOptions = { silentFallback: true } satisfies LazyRouteOptions
@ -323,6 +326,13 @@ const namespaceRoute = createRoute({
component: NamespacePage,
})
const joinRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'join',
beforeLoad: requireAuth,
component: JoinPage,
})
const skillDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/space/$namespace/$slug',
@ -678,6 +688,7 @@ const routeTree = rootRoute.addChildren([
suiteDetailRoute,
termsRoute,
namespaceRoute,
joinRoute,
skillDetailRoute,
skillVersionCompareRoute,
dashboardRoute,

View file

@ -0,0 +1,66 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { getAppBaseUrl, namespaceApi } from '@/api/client'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { BASE_PATH } from '@/shared/lib/base-path'
import { invitationUrl } from '@/shared/lib/namespace-invitation'
import { resolvePublicRegistryUrl } from '@/shared/lib/registry-url'
import { toast } from '@/shared/lib/toast'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
export function InvitationControls({ slug }: { slug: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null)
const [link, setLink] = useState<string | null>(null)
const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug) })
const create = useMutation({
mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace),
onSuccess: async ({ token }) => {
await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token)
setLink(url)
try {
await navigator.clipboard.writeText(url)
toast.success(t('invitation.copied'))
} catch {
toast.info(t('invitation.manualCopy'))
}
},
})
const revoke = useMutation({
mutationFn: () => namespaceApi.revokeInvitation(slug),
onSuccess: async () => {
setLink(null)
await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
toast.success(t('invitation.revoked'))
},
})
const handleCreate = () => {
if (status.data?.active) setConfirm('replace')
else create.mutate(false)
}
return (
<div className="space-y-2">
<div className="flex gap-2">
<Button variant="outline" disabled={status.isPending || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>
{status.data?.active ? <Button variant="outline" disabled={revoke.isPending} onClick={() => setConfirm('revoke')}>{t('invitation.revoke')}</Button> : null}
</div>
{link ? (
<div className="space-y-1">
<label className="text-sm" htmlFor="namespace-invite-link">{t('invitation.link')}</label>
<Input id="namespace-invite-link" readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
<p className="text-xs text-muted-foreground">{t('invitation.linkHint')}</p>
</div>
) : null}
{(create.error || revoke.error || status.error) ? <p role="alert" className="text-sm text-destructive">{t('invitation.error')}</p> : null}
<ConfirmDialog open={confirm !== null} onOpenChange={(open) => { if (!open) setConfirm(null) }}
title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')}
description={t(confirm === 'revoke' ? 'invitation.revokeDescription' : 'invitation.replaceDescription')}
confirmText={t('dialog.confirm')}
onConfirm={async () => { if (confirm === 'revoke') await revoke.mutateAsync(); else await create.mutateAsync(true) }} />
</div>
)
}

View file

@ -1388,6 +1388,31 @@
"searchRebuildSuccessDescription": "The system is rebuilding the full search index using the current rules.",
"searchRebuildErrorTitle": "Search index rebuild failed"
},
"invitation": {
"title": "Join a team space",
"create": "Invite member",
"revoke": "Revoke invitation",
"replaceTitle": "Replace the current invitation?",
"replaceDescription": "The old link will stop working immediately. Existing members are unaffected.",
"revokeTitle": "Revoke the current invitation?",
"revokeDescription": "The link will stop working immediately. Existing members are unaffected.",
"link": "Invitation link",
"linkHint": "Valid for 24 hours and one new member. Share it securely.",
"copied": "Invitation link copied",
"manualCopy": "Automatic copy failed. Copy the link below manually.",
"revoked": "Invitation revoked",
"error": "Invitation action failed. Try again.",
"invalid": "This invitation is invalid or expired. Ask a space admin for a new link.",
"loading": "Checking invitation…",
"networkError": "Connection failed. Try again.",
"retry": "Retry",
"namespace": "Space: {{name}}",
"account": "Current account: {{name}}",
"permissions": "Confirm to join as MEMBER. You can access space resources and publish skills.",
"alreadyMember": "You are already a member of this space.",
"enter": "Enter space",
"confirm": "Confirm and join"
},
"members": {
"title": "Member Management",
"addMember": "Add Member",

View file

@ -1453,6 +1453,31 @@
"searchRebuildSuccessDescription": "Система перестраивает полный индекс поиска по текущим правилам.",
"searchRebuildErrorTitle": "Не удалось перестроить индекс поиска"
},
"invitation": {
"title": "Вступить в командное пространство",
"create": "Пригласить участника",
"revoke": "Отозвать приглашение",
"replaceTitle": "Заменить текущее приглашение?",
"replaceDescription": "Старая ссылка сразу перестанет работать. Участники не изменятся.",
"revokeTitle": "Отозвать текущее приглашение?",
"revokeDescription": "Ссылка сразу перестанет работать. Участники не изменятся.",
"link": "Ссылка приглашения",
"linkHint": "Действует 24 часа и только для одного нового участника. Передавайте безопасно.",
"copied": "Ссылка скопирована",
"manualCopy": "Не удалось скопировать автоматически. Скопируйте ссылку ниже.",
"revoked": "Приглашение отозвано",
"error": "Не удалось выполнить действие. Попробуйте ещё раз.",
"invalid": "Приглашение недействительно или истекло. Запросите новую ссылку у администратора.",
"loading": "Проверка приглашения…",
"networkError": "Ошибка соединения. Попробуйте ещё раз.",
"retry": "Повторить",
"namespace": "Пространство: {{name}}",
"account": "Текущая учётная запись: {{name}}",
"permissions": "Подтвердите вступление с ролью MEMBER. Вы сможете просматривать ресурсы и публиковать навыки.",
"alreadyMember": "Вы уже участник этого пространства.",
"enter": "Открыть пространство",
"confirm": "Подтвердить вступление"
},
"members": {
"title": "Управление участниками",
"addMember": "Добавить участника",

View file

@ -1388,6 +1388,31 @@
"searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。",
"searchRebuildErrorTitle": "搜索索引重建失败"
},
"invitation": {
"title": "加入团队空间",
"create": "邀请成员",
"revoke": "作废邀请",
"replaceTitle": "替换现有邀请?",
"replaceDescription": "旧链接将立即失效,已加入的成员不受影响。",
"revokeTitle": "作废当前邀请?",
"revokeDescription": "链接将立即失效,已加入的成员不受影响。",
"link": "邀请链接",
"linkHint": "24 小时内有效,仅能由一名新成员使用一次。请安全分享。",
"copied": "邀请链接已复制",
"manualCopy": "无法自动复制,请手动复制下方链接",
"revoked": "邀请已作废",
"error": "邀请操作失败,请重试",
"invalid": "邀请无效或已失效,请联系空间管理员获取新链接。",
"loading": "正在核对邀请…",
"networkError": "连接失败,请重试。",
"retry": "重试",
"namespace": "目标空间:{{name}}",
"account": "当前账号:{{name}}",
"permissions": "确认后,你将以 MEMBER 身份加入,可访问空间资源并发布技能。",
"alreadyMember": "你已是该空间成员。",
"enter": "进入空间",
"confirm": "确认加入"
},
"members": {
"title": "成员管理",
"addMember": "添加成员",

View file

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog'
import { BatchImportMembersDialog } from '@/features/namespace/batch-import-members-dialog'
import { InvitationControls } from '@/features/namespace/invitation-controls'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { TransferOwnershipDialog } from '@/features/namespace/transfer-ownership-dialog'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
@ -165,6 +166,7 @@ export function NamespaceMembersPage() {
) : null}
<div className="flex items-center justify-end gap-2">
{canManageMembers ? <InvitationControls slug={slug} /> : null}
{canTransferOwnership ? (
<TransferOwnershipDialog namespace={namespace} members={members}>
<Button variant="outline">{t('members.transferOwnership')}</Button>

74
web/src/pages/join.tsx Normal file
View file

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { namespaceApi } from '@/api/client'
import { useAuth } from '@/features/auth/use-auth'
import { ApiError } from '@/shared/lib/api-error'
import { captureInvitationFragment, clearInvitationToken, storedInvitationToken } from '@/shared/lib/namespace-invitation'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
export function JoinPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { user } = useAuth()
const [token] = useState(() => {
captureInvitationFragment()
return storedInvitationToken()
})
const preview = useQuery({
queryKey: ['namespace-invitation', token],
queryFn: () => namespaceApi.previewInvitation(token!),
enabled: !!token,
retry: false,
})
const accept = useMutation({
mutationFn: () => namespaceApi.acceptInvitation(token!),
onSuccess: async (result) => {
clearInvitationToken()
await queryClient.invalidateQueries({ queryKey: ['namespaces'] })
await navigate({ to: '/space/$namespace', params: { namespace: result.namespaceSlug } })
},
})
const invalid = !token || (preview.error instanceof ApiError && preview.error.status === 404)
|| (accept.error instanceof ApiError && accept.error.status === 404)
useEffect(() => { if (invalid) clearInvitationToken() }, [invalid])
return (
<div className="mx-auto max-w-xl px-4 py-16">
<Card className="space-y-5 p-8">
<h1 className="text-2xl font-semibold">{t('invitation.title')}</h1>
{invalid ? <p>{t('invitation.invalid')}</p> : null}
{!invalid && preview.isPending ? <p>{t('invitation.loading')}</p> : null}
{!invalid && preview.error ? (
<div className="space-y-3">
<p role="alert">{t('invitation.networkError')}</p>
<Button onClick={() => preview.refetch()}>{t('invitation.retry')}</Button>
</div>
) : null}
{!invalid && preview.data ? (
<div className="space-y-4">
<p>{t('invitation.namespace', { name: preview.data.namespaceName })}</p>
<p>{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}</p>
{preview.data.alreadyMember ? (
<>
<p>{t('invitation.alreadyMember')}</p>
<Button onClick={() => { clearInvitationToken(); navigate({ to: '/space/$namespace', params: { namespace: preview.data.namespaceSlug } }) }}>{t('invitation.enter')}</Button>
</>
) : (
<>
<p>{t('invitation.permissions')}</p>
<Button disabled={accept.isPending} onClick={() => accept.mutate()}>{t('invitation.confirm')}</Button>
{accept.error && !(accept.error instanceof ApiError && accept.error.status === 404) ? (
<p role="alert">{t('invitation.networkError')}</p>
) : null}
</>
)}
</div>
) : null}
</Card>
</div>
)
}

View file

@ -0,0 +1,31 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { captureInvitationFragment, clearInvitationToken, invitationUrl, storedInvitationToken } from './namespace-invitation'
const token = 'A'.repeat(43)
describe('namespace invitation URL handoff', () => {
beforeEach(() => {
clearInvitationToken()
window.history.replaceState(null, '', '/')
})
it('moves a token from the fragment to tab-scoped storage before login routing', () => {
window.history.replaceState(null, '', `/join?from=mail#${token}`)
captureInvitationFragment()
expect(storedInvitationToken()).toBe(token)
expect(window.location.href).toContain('/join?from=mail')
expect(window.location.hash).toBe('')
})
it('rejects malformed fragments and still removes them from the URL', () => {
window.history.replaceState(null, '', '/join#bad-token')
captureInvitationFragment()
expect(storedInvitationToken()).toBeNull()
expect(window.location.hash).toBe('')
})
it('uses a resolved public base path in the shared link', () => {
expect(invitationUrl('https://example.com/skillhub', token)).toBe(`https://example.com/skillhub/join#${token}`)
})
})

View file

@ -0,0 +1,28 @@
import { toRouterPath } from './base-path'
const STORAGE_KEY = 'skillhub.namespace.invitation'
// Run before route guards inspect the location: a login redirect must never copy the fragment.
export function captureInvitationFragment() {
if (typeof window === 'undefined' || !window.location.hash) return
if (toRouterPath(window.location.pathname) !== '/join') return
const token = window.location.hash.slice(1)
try {
if (/^[A-Za-z0-9_-]{43}$/.test(token)) window.sessionStorage.setItem(STORAGE_KEY, token)
} finally {
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`)
}
}
export function storedInvitationToken(): string | null {
if (typeof window === 'undefined') return null
return window.sessionStorage.getItem(STORAGE_KEY)
}
export function clearInvitationToken() {
if (typeof window !== 'undefined') window.sessionStorage.removeItem(STORAGE_KEY)
}
export function invitationUrl(publicBase: string, token: string): string {
return `${publicBase}/join#${token}`
}