mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
feat(namespace): add one-time invitation links
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
b1d5391102
commit
9c997f7eca
46 changed files with 1028 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -73,6 +73,15 @@
|
|||
5. 选择角色(Owner / Admin / Member)
|
||||
6. 确认添加
|
||||
|
||||
**通过链接邀请成员**:
|
||||
|
||||
1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。
|
||||
2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。
|
||||
3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。
|
||||
4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。
|
||||
|
||||
邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。
|
||||
|
||||
**管理权限**:
|
||||
|
||||
1. 在成员列表中找到目标用户
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-10-12
|
||||
19
openspec/changes/add-namespace-invite-links/design.md
Normal file
19
openspec/changes/add-namespace-invite-links/design.md
Normal 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、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。
|
||||
30
openspec/changes/add-namespace-invite-links/proposal.md
Normal file
30
openspec/changes/add-namespace-invite-links/proposal.md
Normal 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 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。
|
||||
|
|
@ -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,用户可重试而无需新邀请
|
||||
17
openspec/changes/add-namespace-invite-links/tasks.md
Normal file
17
openspec/changes/add-namespace-invite-links/tasks.md
Normal 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 和验收报告。
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record NamespaceInvitationCreateRequest(boolean replace) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record NamespaceInvitationTokenRequest(@NotBlank String token) {}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=Требуется роль владельца или администратора пространства имён
|
||||
|
|
|
|||
|
|
@ -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=需要命名空间管理员或所有者权限
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
66
web/src/features/namespace/invitation-controls.tsx
Normal file
66
web/src/features/namespace/invitation-controls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Добавить участника",
|
||||
|
|
|
|||
|
|
@ -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": "添加成员",
|
||||
|
|
|
|||
|
|
@ -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
74
web/src/pages/join.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
web/src/shared/lib/namespace-invitation.test.ts
Normal file
31
web/src/shared/lib/namespace-invitation.test.ts
Normal 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}`)
|
||||
})
|
||||
})
|
||||
28
web/src/shared/lib/namespace-invitation.ts
Normal file
28
web/src/shared/lib/namespace-invitation.ts
Normal 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}`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue