diff --git a/docs/skillhub/en/guide/namespace.md b/docs/skillhub/en/guide/namespace.md index b693cc6e6..8c2f391ad 100644 --- a/docs/skillhub/en/guide/namespace.md +++ b/docs/skillhub/en/guide/namespace.md @@ -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 diff --git a/docs/skillhub/guide/namespace.md b/docs/skillhub/guide/namespace.md index 8af0d2ec2..e26d78903 100644 --- a/docs/skillhub/guide/namespace.md +++ b/docs/skillhub/guide/namespace.md @@ -73,6 +73,15 @@ 5. 选择角色(Owner / Admin / Member) 6. 确认添加 +**通过链接邀请成员**: + +1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。 +2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。 +3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。 +4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。 + +邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。 + **管理权限**: 1. 在成员列表中找到目标用户 diff --git a/openspec/changes/add-namespace-invite-links/.openspec.yaml b/openspec/changes/add-namespace-invite-links/.openspec.yaml new file mode 100644 index 000000000..b9ebfc72d --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md new file mode 100644 index 000000000..624597ce5 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -0,0 +1,20 @@ +## 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. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。 +7. 正式路径沿用 Issue #911 的复数合同:`POST /api/v1/namespaces/{slug}/invitations` 创建或替换,`POST /api/v1/namespace-invitations/preview|accept` 预览及确认。状态查询和作废使用同一 `/namespaces/{slug}/invitations` 路径的 GET/DELETE;保留现有 `/api/web` 别名。Preview 返回 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`、`expiresAt`;Accept 仅返回 `namespaceSlug`、`outcome`。无效邀请的本地化键为 `error.namespace.invitation.invalid`。 + +## Risks and verification + +- 账号合并已先锁 UserAccount;邀请创建/替换/作废/接受、直接成员变更、平台成员管理及账号合并要按相同的用户后 Namespace 顺序加锁,避免与成员或审计外键检查形成环。 +- 并发兑换、直接添加与兑换、替换/作废与兑换、冻结/归档/删除与兑换均需事务级验证。 +- 根路径及子路径部署都要验证分享 URL、登录回跳和 fragment 清理。 +- API Token、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。 diff --git a/openspec/changes/add-namespace-invite-links/proposal.md b/openspec/changes/add-namespace-invite-links/proposal.md new file mode 100644 index 000000000..5d8db8be5 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/proposal.md @@ -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 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md new file mode 100644 index 000000000..9f7277f54 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -0,0 +1,112 @@ +## Purpose + +让 Namespace 管理员使用可控的短期单次链接邀请新成员。 + +## ADDED Requirements + +### Requirement: REQ-INV-01 管理员 SHALL 创建、替换及作废邀请 + +有权管理员 SHALL 可创建、替换及作废邀请。 + +只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认,创建响应仅在旧链接当时仍可兑换时标记 `replaced=true`;已消费或过期后的新建不标记替换。管理员可以单独作废当前邀请,且已加入成员不受影响。 + +#### 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 并自动使用 replace 导航进入空间,避免重复导航。加载状态使用可访问的 status 提示,终态失效使用 alert 提示。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 + +#### Scenario: 剪贴板写入失败 +- **WHEN** 创建成功但浏览器拒绝写剪贴板 +- **THEN** 页面显示同一链接供手动复制,不再次生成邀请 + +#### Scenario: 网络错误 +- **WHEN** 预览或接受遇到可重试网络错误 +- **THEN** 页面保留 Token,用户可重试而无需新邀请 + +### Requirement: REQ-INV-07 API SHALL 使用 Issue #911 的路径与响应合同 + +API SHALL 使用 Issue #911 的路径与响应合同。 + +创建或替换使用 `POST /api/v1/namespaces/{slug}/invitations`,首次创建允许无请求体;有仍可兑换的当前邀请时须按已批准的确认流程传入 `replace=true`。状态查询和单独作废分别使用同一路径的 GET 与 DELETE。预览和接受分别使用 `POST /api/v1/namespace-invitations/preview` 与 `POST /api/v1/namespace-invitations/accept`,请求体均为 `{ "token": "..." }`;可保留 `/api/web` 别名。 + +创建数据仅包含 `token`、`expiresAt`、`replaced`。预览数据包含 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`(`CONFIRM_REQUIRED` 或 `ALREADY_MEMBER`)和 `expiresAt`。接受数据仅包含 `namespaceSlug` 和 `outcome`(`JOINED` 或 `ALREADY_MEMBER`)。无效邀请使用 `error.namespace.invitation.invalid` 本地化键,中文为“邀请链接无效或已失效”,响应为 HTTP 404、envelope `code=404`、`data=null`。 + +#### Scenario: 首次创建和预览 +- **WHEN** OWNER/ADMIN 首次创建邀请且不传请求体,非成员预览链接 +- **THEN** 创建返回三字段,预览返回五字段且 `outcome=CONFIRM_REQUIRED` + +#### Scenario: 接受与已有成员 +- **WHEN** 非成员确认加入,或已有成员访问当前邀请 +- **THEN** 接受分别返回 `JOINED` 或 `ALREADY_MEMBER`,且仅包含 `namespaceSlug` 和 `outcome` diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md new file mode 100644 index 000000000..0b8b0f173 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -0,0 +1,17 @@ +## 1. 持久化与事务 + +- [x] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。 +- [x] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。 +- [x] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。 + +## 2. API 与页面 + +- [x] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。 +- [x] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。 +- [x] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。 + +## 3. 验证与交付 + +- [x] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 +- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 +- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java new file mode 100644 index 000000000..02879c68b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java @@ -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}/invitations") + public ApiResponse 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}/invitations") + public ApiResponse 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}/invitations") + public ApiResponse 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("/namespace-invitations/preview") + public ApiResponse 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("/namespace-invitations/accept") + public ApiResponse 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) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java new file mode 100644 index 000000000..ed2470020 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record NamespaceInvitationCreateRequest(boolean replace) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java new file mode 100644 index 000000000..3f87ff6fa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record NamespaceInvitationTokenRequest(@NotBlank String token) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java index e67cd87f8..1286096de 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java @@ -7,6 +7,7 @@ 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 AdminNamespaceAppService(AdminNamespaceQueryRepository adminNamespaceQuer NamespaceGovernanceService namespaceGovernanceService, NamespaceMemberService namespaceMemberService, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, NamespaceMemberCandidateService namespaceMemberCandidateService, NamespaceAccessPolicy namespaceAccessPolicy, UserAccountRepository userAccountRepository) { @@ -65,6 +68,7 @@ public AdminNamespaceAppService(AdminNamespaceQueryRepository adminNamespaceQuer 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 List searchMemberCandidates(String slug, @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 MemberResponse addMember(String slug, MemberRequest request, String actor @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 results = new ArrayList<>(); int successCount = 0; int failureCount = 0; @@ -178,7 +182,7 @@ public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest reque @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 MemberResponse updateMemberRole(String slug, String userId, UpdateMemberR @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 MessageResponse removeMember(String slug, String userId, String actorUser @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 @@ private Map loadRoles(List namespaceIds, String userI .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 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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index 4bf8af917..b5ef64684 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -84,8 +84,7 @@ public NamespaceResponse updateNamespace(String slug, NamespaceRequest request, @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 NamespaceResponse restoreNamespace(String slug, String userId, AuditReque @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 MemberResponse addMember(String slug, String memberUserId, com.iflytek.sk // 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 members, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - Long namespaceId = namespace.getId(); + Long namespaceId = namespaceId(slug); List results = new ArrayList<>(); int successCount = 0; @@ -195,8 +192,7 @@ private String mapBatchError(Exception e) { @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 MemberResponse updateMemberRole(String slug, 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 MemberResponse updateMemberRole(String slug, @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 @@ private boolean canCreateNamespace(PlatformPrincipal principal) { 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)); + } } diff --git a/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql new file mode 100644 index 000000000..ed42c0be4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql @@ -0,0 +1,9 @@ +CREATE TABLE namespace_invitation ( + namespace_id BIGINT PRIMARY KEY REFERENCES namespace(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + issued_by VARCHAR(128) NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_by VARCHAR(128), + consumed_at TIMESTAMPTZ +); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 24bf9324d..1635516d2 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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.invitation.invalid=Invitation link is invalid 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 diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 566d443e8..dbaefe0ce 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -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.invitation.invalid=Ссылка-приглашение недействительна или срок её действия истёк +error.namespace.invite.replaceConfirmationRequired=Подтвердите замену текущего приглашения error.namespace.membership.required=Требуется членство в пространстве имён error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён error.namespace.admin.required=Требуется роль владельца или администратора пространства имён diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 90bad54b1..1a38634bf 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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.invitation.invalid=邀请链接无效或已失效 +error.namespace.invite.replaceConfirmationRequired=请先确认替换当前邀请 error.namespace.membership.required=需要先加入该命名空间 error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java index 8ac331680..8a8145af5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -31,6 +32,7 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -80,6 +82,11 @@ class NamespaceBatchMemberControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @BeforeEach + void setUpNamespaceLookup() { + given(namespaceRepository.findIdBySlug("team-a")).willReturn(Optional.of(1L)); + } + @Test void batchAddMembers_partialSuccess_returnsResultsPerRow() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java new file mode 100644 index 000000000..8da407541 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java @@ -0,0 +1,160 @@ +package com.iflytek.skillhub.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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.ApiResponse; +import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.exception.GlobalExceptionHandler; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.security.SensitiveLogSanitizer; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +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.RequestMapping; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +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 refusesApiTokenForInvitationStatusAndRevoke() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("admin", "Admin", null, null, "api_token", Set.of("SUPER_ADMIN")); + + assertThatThrownBy(() -> controller.status("team", token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + assertThatThrownBy(() -> controller.revoke("team", 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); + } + + @Test + void issueRoutesUsePluralPathsForBothApiPrefixes() throws NoSuchMethodException { + assertThat(NamespaceInvitationController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/v1", "/api/web"); + assertThat(NamespaceInvitationController.class.getMethod("create", String.class, + NamespaceInvitationCreateRequest.class, PlatformPrincipal.class, HttpServletRequest.class, + HttpServletResponse.class).getAnnotation(PostMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("status", String.class, PlatformPrincipal.class, + HttpServletRequest.class, HttpServletResponse.class).getAnnotation(GetMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("revoke", String.class, PlatformPrincipal.class, + HttpServletRequest.class, HttpServletResponse.class).getAnnotation(DeleteMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("preview", NamespaceInvitationTokenRequest.class, + PlatformPrincipal.class, HttpServletRequest.class, HttpServletResponse.class) + .getAnnotation(PostMapping.class).value()).containsExactly("/namespace-invitations/preview"); + assertThat(NamespaceInvitationController.class.getMethod("accept", NamespaceInvitationTokenRequest.class, + PlatformPrincipal.class, HttpServletRequest.class, HttpServletResponse.class) + .getAnnotation(PostMapping.class).value()).containsExactly("/namespace-invitations/accept"); + } + + @Test + void firstCreateNeedsNoBodyAndSuccessDtosMatchIssueContract() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal owner = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + Instant expiry = Instant.parse("2026-10-13T00:00:00Z"); + NamespaceInvitationService.Created created = new NamespaceInvitationService.Created("raw-token", expiry, false); + when(invitations.create("team", "owner", false)).thenReturn(created); + when(responses.ok(anyString(), any())).thenAnswer(call -> new ApiResponse<>(0, "ok", call.getArgument(1), expiry, null)); + + assertThat(controller.create("team", null, owner, request, response).data()).isEqualTo(created); + verify(invitations).create("team", "owner", false); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + assertThat(mapper.valueToTree(created).fieldNames()).toIterable() + .containsExactlyInAnyOrder("token", "expiresAt", "replaced"); + for (NamespaceInvitationService.PreviewOutcome outcome : NamespaceInvitationService.PreviewOutcome.values()) { + NamespaceInvitationService.Preview preview = new NamespaceInvitationService.Preview( + "team", "Team", null, outcome, expiry); + assertThat(mapper.valueToTree(preview).fieldNames()).toIterable() + .containsExactlyInAnyOrder("namespaceSlug", "namespaceDisplayName", "namespaceAvatarUrl", "outcome", "expiresAt"); + assertThat(mapper.valueToTree(preview).get("outcome").asText()).isEqualTo(outcome.name()); + } + for (NamespaceInvitationService.AcceptOutcome outcome : NamespaceInvitationService.AcceptOutcome.values()) { + NamespaceInvitationService.Accepted accepted = new NamespaceInvitationService.Accepted("team", outcome); + assertThat(mapper.valueToTree(accepted).fieldNames()).toIterable() + .containsExactlyInAnyOrder("namespaceSlug", "outcome"); + assertThat(mapper.valueToTree(accepted).get("outcome").asText()).isEqualTo(outcome.name()); + } + } + + @Test + void invalidInvitationUsesIssueErrorKeyAndLocalized404Envelope() { + ResourceBundleMessageSource messages = new ResourceBundleMessageSource(); + messages.setBasename("messages"); + messages.setDefaultEncoding("UTF-8"); + ApiResponseFactory factory = new ApiResponseFactory(messages, Clock.fixed( + Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC), new RequestIdAccessor()); + GlobalExceptionHandler handler = new GlobalExceptionHandler(factory, + mock(SensitiveLogSanitizer.class), mock(SkillHubMetrics.class), new RequestIdAccessor()); + LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE); + try { + var result = handler.handleLocalizedDomainException( + new DomainNotFoundException("error.namespace.invitation.invalid"), request); + assertThat(result.getStatusCode().value()).isEqualTo(404); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().code()).isEqualTo(404); + assertThat(result.getBody().msg()).isEqualTo("邀请链接无效或已失效"); + assertThat(result.getBody().data()).isNull(); + } finally { + LocaleContextHolder.resetLocaleContext(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index d7dc9142c..ac60aad1b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -31,6 +32,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -78,6 +80,11 @@ class NamespacePortalControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @BeforeEach + void setUpNamespaceLookup() { + given(namespaceRepository.findIdBySlug("team-a")).willReturn(Optional.of(1L)); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java new file mode 100644 index 000000000..74d5a5548 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -0,0 +1,323 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.auth.merge.AccountMergeRequest; +import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository; +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +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.shared.exception.DomainBadRequestException; +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.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; +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.time.Instant; +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.boot.test.mock.mockito.MockBean; +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, NamespaceMemberService.class, NamespaceService.class, + AccountMergeService.class, + NamespaceAccessPolicy.class, NamespaceGovernanceService.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 NamespaceMemberService memberService; + @Autowired private NamespaceGovernanceService governance; + @Autowired private NamespaceService namespaceService; + @Autowired private AccountMergeService accountMerge; + @Autowired private AccountMergeRequestRepository mergeRequests; + @Autowired private NamespaceInvitationJpaRepository invitationRows; + @Autowired private NamespaceJpaRepository namespaces; + @Autowired private NamespaceMemberJpaRepository members; + @Autowired private UserAccountJpaRepository users; + @Autowired private PlatformTransactionManager transactionManager; + @MockBean private SkillRepository skills; + @MockBean private ReviewTaskRepository reviewTasks; + @MockBean private PromotionRequestRepository promotions; + + @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); + } + + @Test + void revokeAndReplaceSerializeWithAcceptance() throws Exception { + for (boolean replace : new boolean[] {false, true}) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(guest, guest, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace(slug, "Invitation race", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + String oldToken = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, oldToken, guest)); + var change = pool.submit(() -> { + start.await(); + if (replace) invitations.create(slug, owner, true); + else invitations.revoke(slug, owner); + return true; + }); + start.countDown(); + int accepted = accept.get(20, TimeUnit.SECONDS); + assertThat(change.get(20, TimeUnit.SECONDS)).isTrue(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest).isPresent()) + .isEqualTo(accepted == 1); + if (replace) { + assertThat(invitationRows.findByNamespaceId(namespaceId)).isPresent(); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt()).isNull(); + } else { + assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + } + assertThat(attempt(new CountDownLatch(0), oldToken, guest)).isZero(); + } + } + } + + @Test + void directAddAndAcceptProduceOneMembershipWithoutConsumingUnusedLink() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean acceptedAsExistingMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, guest).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER; + }); + var direct = pool.submit(() -> { + start.await(); + try { + memberService.addMember(namespaceId, guest, NamespaceRole.MEMBER, owner); + return true; + } catch (DomainBadRequestException alreadyMember) { + assertThat(alreadyMember).hasMessageContaining("error.namespace.member.alreadyExists"); + return false; + } + }); + start.countDown(); + acceptedAsExistingMember = accept.get(20, TimeUnit.SECONDS); + boolean directAdded = direct.get(20, TimeUnit.SECONDS); + assertThat(acceptedAsExistingMember).isEqualTo(directAdded); + } + assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(guest))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(!acceptedAsExistingMember); + } + + @Test + void freezeAndArchiveSerializeWithAcceptance() throws Exception { + for (boolean archive : new boolean[] {false, true}) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var lifecycle = pool.submit(() -> { + start.await(); + if (archive) governance.archiveNamespace(slug, owner, "test", null, null, null); + else governance.freezeNamespace(slug, owner, "test", null, null, null); + return true; + }); + start.countDown(); + int accepted = accept.get(20, TimeUnit.SECONDS); + assertThat(lifecycle.get(20, TimeUnit.SECONDS)).isTrue(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest).isPresent()) + .isEqualTo(accepted == 1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(accepted == 1); + } + } + } + + @Test + void deletingNamespaceAndAcceptingInvitationLeaveNoOrphan() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var deletion = pool.submit(() -> { + start.await(); + namespaceService.deleteNamespace(namespaceId, owner); + return true; + }); + start.countDown(); + accept.get(20, TimeUnit.SECONDS); + assertThat(deletion.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(namespaces.findBySlug(slug)).isEmpty(); + assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest)).isEmpty(); + } + + @Test + void overlappingNamespaceAccountMergeAndAcceptKeepOnePrimaryMembership() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String primary = "inv-a-" + suffix; + String secondary = "inv-z-" + suffix; + String inviteSlug = "inv-b-" + suffix; + Long[] ids = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(primary, primary, null, null)); + users.saveAndFlush(new UserAccount(secondary, secondary, null, null)); + Namespace shared = namespaces.saveAndFlush(new Namespace("inv-a-" + suffix, "Shared", owner)); + Namespace invited = namespaces.saveAndFlush(new Namespace(inviteSlug, "Invited", owner)); + for (Namespace ns : new Namespace[] {shared, invited}) { + members.saveAndFlush(new NamespaceMember(ns.getId(), owner, NamespaceRole.OWNER)); + members.saveAndFlush(new NamespaceMember(ns.getId(), secondary, NamespaceRole.MEMBER)); + } + members.saveAndFlush(new NamespaceMember(shared.getId(), primary, NamespaceRole.MEMBER)); + AccountMergeRequest request = new AccountMergeRequest(primary, secondary, null, + Instant.now().plusSeconds(3600)); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + return new Long[] {shared.getId(), invited.getId(), mergeRequests.saveAndFlush(request).getId()}; + }); + String token = invitations.create(inviteSlug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean alreadyMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, primary).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER; + }); + var merge = pool.submit(() -> { + start.await(); + accountMerge.confirm(primary, ids[2]); + return true; + }); + start.countDown(); + alreadyMember = accept.get(20, TimeUnit.SECONDS); + assertThat(merge.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(users.findAllById(java.util.List.of(secondary)).iterator().next().getStatus()).isEqualTo(UserStatus.MERGED); + assertThat(members.findByNamespaceIdAndUserId(ids[0], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], primary)).isPresent(); + assertThat(members.findByNamespaceId(ids[1], org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(primary))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(ids[1]).orElseThrow().getConsumedAt() != null) + .isEqualTo(!alreadyMember); + } + + private Long createTeam(String slug, String owner, String guest) { + return new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(guest, guest, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace(slug, "Invitation race", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + } + + 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; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java index 149f885d9..16012dbaf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java @@ -16,6 +16,7 @@ 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 @@ void addMemberAllowsSuperAdminToManageNonMemberTeamNamespace() { 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 @@ void addMemberAllowsSuperAdminToManageNonMemberTeamNamespace() { 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 @@ void transferOwnershipDemotesCurrentOwnerAndPromotesExistingMember() { 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 @@ void transferOwnershipDemotesCurrentOwnerAndPromotesExistingMember() { 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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java index 1f4ad1ac4..b069b1b0f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -80,7 +80,7 @@ void freezeNamespace_mapsAuditContextAndReturnsResponse() { @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 @@ void addMember_populatesDisplayNameAndEmail() { 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 @@ void addMember_withoutUserAccount_degradesGracefully() { 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 @@ void updateMemberRole_populatesDisplayNameAndEmail() { 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 @@ void updateMemberRole_withoutUserAccount_degradesGracefully() { 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 @@ void updateMemberRole_withoutUserAccount_degradesGracefully() { @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 @@ void transferOwnership_success() { @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 @@ void transferOwnership_nonOwnerFails() { @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 @@ void transferOwnership_targetNotFoundFails() { 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"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index b5de4dbe4..ecfd37943 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -12,6 +12,7 @@ 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 AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, UserRoleBindingRepository userRoleBindingRepository, ApiTokenRepository apiTokenRepository, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, Clock clock) { this.mergeRequestRepository = mergeRequestRepository; this.userAccountRepository = userAccountRepository; @@ -66,6 +69,7 @@ public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, this.userRoleBindingRepository = userRoleBindingRepository; this.apiTokenRepository = apiTokenRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.clock = clock; } @@ -287,6 +291,8 @@ private void migrateUserRoles(String primaryUserId, String secondaryUserId) { private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); + secondaryMemberships.stream().map(NamespaceMember::getNamespaceId).distinct().sorted() + .forEach(namespaceRepository::findLockedById); for (NamespaceMember secondaryMembership : secondaryMemberships) { Optional existingPrimaryMembership = namespaceMemberRepository .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java index 08a6d4807..49d678b5d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -20,6 +20,7 @@ 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 @@ void setUp() { userRoleBindingRepository, apiTokenRepository, namespaceMemberRepository, + namespaceRepository, clock ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java index 1ef7d2470..5b251a917 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -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) )); } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java index 984fe4fc7..4e0072fdf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -4,6 +4,7 @@ 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 Namespace freezeNamespace(String slug, 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 Namespace freezeNamespaceByPlatformAdmin(String slug, 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 Namespace unfreezeNamespace(String slug, 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 Namespace unfreezeNamespaceByPlatformAdmin(String slug, 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 Namespace archiveNamespace(String slug, 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 Namespace archiveNamespaceByPlatformAdmin(String slug, 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 Namespace restoreNamespace(String slug, 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 Namespace restoreNamespaceByPlatformAdmin(String slug, 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 Namespace restoreNamespaceByPlatformAdmin(String slug, } 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); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java new file mode 100644 index 000000000..eb97b7c16 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "namespace_invitation") +public class NamespaceInvitation { + @Id + @Column(name = "namespace_id") + private Long namespaceId; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "issued_by", nullable = false, length = 128) + private String issuedBy; + + @Column(name = "issued_at", nullable = false) + private Instant issuedAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_by", length = 128) + private String consumedBy; + + @Column(name = "consumed_at") + private Instant consumedAt; + + protected NamespaceInvitation() {} + + public NamespaceInvitation(Long namespaceId, String tokenHash, String issuedBy, Instant issuedAt, Instant expiresAt) { + this.namespaceId = namespaceId; + this.tokenHash = tokenHash; + this.issuedBy = issuedBy; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public Long getNamespaceId() { return namespaceId; } + public String getTokenHash() { return tokenHash; } + public String getIssuedBy() { return issuedBy; } + public Instant getIssuedAt() { return issuedAt; } + public Instant getExpiresAt() { return expiresAt; } + public String getConsumedBy() { return consumedBy; } + public Instant getConsumedAt() { return consumedAt; } + public void replace(String hash, String issuer, Instant now) { + this.tokenHash = hash; + this.issuedBy = issuer; + this.issuedAt = now; + this.expiresAt = now.plusSeconds(86400); + this.consumedBy = null; + this.consumedAt = null; + } + public void consume(String userId, Instant now) { + this.consumedBy = userId; + this.consumedAt = now; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java new file mode 100644 index 000000000..36e0f99bf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Optional; + +public interface NamespaceInvitationRepository { + Optional findByNamespaceId(Long namespaceId); + Optional findByTokenHash(String tokenHash); + Optional findNamespaceIdByTokenHash(String tokenHash); + NamespaceInvitation save(NamespaceInvitation invitation); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java new file mode 100644 index 000000000..fa50f5cb2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -0,0 +1,192 @@ +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; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; + +@Service +public class NamespaceInvitationService { + private static final SecureRandom RANDOM = new SecureRandom(); + private final NamespaceRepository namespaces; + private final NamespaceInvitationRepository invitations; + private final NamespaceMemberRepository members; + private final UserAccountRepository users; + private final AuditLogService audit; + private final Clock clock; + + public NamespaceInvitationService(NamespaceRepository namespaces, NamespaceInvitationRepository invitations, + NamespaceMemberRepository members, UserAccountRepository users, + AuditLogService audit, Clock clock) { + this.namespaces = namespaces; + this.invitations = invitations; + this.members = members; + this.users = users; + this.audit = audit; + this.clock = clock; + } + + public record Created(String token, Instant expiresAt, boolean replaced) {} + public enum PreviewOutcome { CONFIRM_REQUIRED, ALREADY_MEMBER } + public enum AcceptOutcome { JOINED, ALREADY_MEMBER } + public record Preview(String namespaceSlug, String namespaceDisplayName, String namespaceAvatarUrl, + PreviewOutcome outcome, Instant expiresAt) {} + public record Accepted(String namespaceSlug, AcceptOutcome outcome) {} + + @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); + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + Instant now = clock.instant(); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null); + boolean replaced = invitation != null && invitation.getConsumedAt() == null + && invitation.getExpiresAt().isAfter(now); + if (replaced && !replace) { + throw new DomainConflictException("error.namespace.invite.replaceConfirmationRequired"); + } + if (invitation == null) { + invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400)); + } else { + invitation.replace(hash(token), actorId, now); + } + invitations.save(invitation); + record(actorId, replaced ? "NAMESPACE_INVITE_REPLACED" : "NAMESPACE_INVITE_CREATED", namespace.getId()); + return new Created(token, invitation.getExpiresAt(), replaced); + } + + @Transactional + public void revoke(String slug, String actorId) { + requireActiveLockedUser(actorId); + Namespace namespace = lock(slug); + requireManager(namespace, actorId); + requireActiveTeam(namespace); + if (invitations.findByNamespaceId(namespace.getId()).isEmpty()) { + throw invalid(); + } + invitations.deleteByNamespaceId(namespace.getId()); + record(actorId, "NAMESPACE_INVITE_REVOKED", namespace.getId()); + } + + @Transactional(readOnly = true) + public boolean hasActiveInvitation(String slug, String actorId) { + Namespace namespace = namespaces.findBySlug(slug) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.invitation.invalid")); + requireManager(namespace, actorId); + return invitations.findByNamespaceId(namespace.getId()) + .filter(invite -> invite.getConsumedAt() == null && invite.getExpiresAt().isAfter(clock.instant())) + .isPresent(); + } + + @Transactional(readOnly = true) + public Preview preview(String token, String userId) { + NamespaceInvitation invitation = current(token); + Namespace namespace = namespaces.findById(invitation.getNamespaceId()).orElseThrow(this::invalid); + boolean member = members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent(); + if (!member) { + requireActiveUser(userId); + requireValid(namespace, invitation); + } + return new Preview(namespace.getSlug(), namespace.getDisplayName(), namespace.getAvatarUrl(), + member ? PreviewOutcome.ALREADY_MEMBER : PreviewOutcome.CONFIRM_REQUIRED, invitation.getExpiresAt()); + } + + @Transactional + public Accepted accept(String token, String userId) { + // 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))) { + throw invalid(); + } + if (members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent()) { + return new Accepted(namespace.getSlug(), AcceptOutcome.ALREADY_MEMBER); + } + requireActiveUser(userId); + requireValid(namespace, invitation); + members.save(new NamespaceMember(namespace.getId(), userId, NamespaceRole.MEMBER)); + invitation.consume(userId, clock.instant()); + invitations.save(invitation); + record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId()); + return new Accepted(namespace.getSlug(), AcceptOutcome.JOINED); + } + + private Namespace lock(String slug) { + Long namespaceId = namespaces.findIdBySlug(slug).orElseThrow(this::invalid); + return namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); + } + + private NamespaceInvitation current(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid(); + 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); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) throw invalid(); + requireActiveUser(userId); + } + + private void requireActiveUser(String userId) { + 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(); + } + + private void requireValid(Namespace namespace, NamespaceInvitation invitation) { + requireActiveTeam(namespace); + if (invitation.getConsumedAt() != null || !invitation.getExpiresAt().isAfter(clock.instant())) throw invalid(); + } + + private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invitation.invalid"); } + + private String hash(String token) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(token.getBytes(StandardCharsets.US_ASCII))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private void record(String actorId, String action, Long namespaceId) { + audit.record(actorId, action, "NAMESPACE", namespaceId, null, null, null, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index d8e8bc741..040f048b0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -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 NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -61,6 +70,7 @@ public void removeMember(Long namespaceId, String userId, String operatorUserId) @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 NamespaceMember updateMemberRole(Long namespaceId, String userId, Namespa @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 @@ private void assertMemberMutationAllowed(Long namespaceId) { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); } } + + private void lockUserThenNamespace(String userId, Long namespaceId) { + userAccountRepository.findLockedById(userId); + namespaceRepository.findLockedById(namespaceId); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index cdedbfec8..b235d2f50 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -11,11 +11,13 @@ */ public interface NamespaceRepository { Optional findById(Long id); + Optional findLockedById(Long id); List findAll(); List findByIdIn(List ids); List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + Optional findIdBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); void delete(Namespace namespace); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index e54379e01..764f30f34 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -5,6 +5,7 @@ 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 NamespaceService(NamespaceRepository namespaceRepository, */ @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 Namespace getNamespace(Long namespaceId) { */ @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); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java index c4e211bb1..95ae6ad11 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java @@ -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 @@ void ensureMember_createsGlobalMembershipWhenMissing() throws Exception { 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 @@ void ensureMember_keepsExistingGlobalMembership() throws Exception { 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"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java index 652902702..f164cc2e6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java @@ -3,7 +3,9 @@ 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 @@ void freezeNamespace_allowsAdminOnActiveTeamNamespace() { 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 @@ void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { 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 @@ void restoreNamespace_movesArchivedNamespaceBackToActive() { @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 @@ void freezeNamespace_rejectsGlobalNamespace() { 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); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java new file mode 100644 index 000000000..940c67d6d --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -0,0 +1,318 @@ +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.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class NamespaceInvitationServiceTest { + private final NamespaceRepository namespaces = mock(NamespaceRepository.class); + private final NamespaceInvitationRepository invitations = mock(NamespaceInvitationRepository.class); + private final NamespaceMemberRepository members = mock(NamespaceMemberRepository.class); + private final UserAccountRepository users = mock(UserAccountRepository.class); + private final AuditLogService audit = mock(AuditLogService.class); + private final Clock clock = Clock.fixed(Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC); + private final NamespaceInvitationService service = new NamespaceInvitationService(namespaces, invitations, members, users, audit, clock); + + @BeforeEach + void setup() { + Namespace namespace = new Namespace("team", "Team", "owner"); + // Entity ID is database-generated; mock the locked lookup with the same instance. + try { + var id = Namespace.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(namespace, 7L); + } catch (ReflectiveOperationException e) { + 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")) + .thenReturn(Optional.of(new NamespaceMember(7L, "owner", NamespaceRole.OWNER))); + UserAccount owner = mock(UserAccount.class); + 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 + void createsSingleUseTokenWithoutPersistingRawValue() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + assertThat(created.token()).matches("[A-Za-z0-9_-]{43}"); + assertThat(saved.getValue().getTokenHash()).matches("[a-f0-9]{64}").doesNotContain(created.token()); + assertThat(created.expiresAt()).isEqualTo(Instant.parse("2026-10-13T00:00:00Z")); + assertThat(created.replaced()).isFalse(); + } + + @Test + void namespaceAdminCanCreateInvitation() { + Optional active = users.findById("owner"); + when(members.findByNamespaceIdAndUserId(7L, "admin")) + .thenReturn(Optional.of(new NamespaceMember(7L, "admin", NamespaceRole.ADMIN))); + when(users.findLockedById("admin")).thenReturn(active); + when(users.findById("admin")).thenReturn(active); + + assertThat(service.create("team", "admin").token()).matches("[A-Za-z0-9_-]{43}"); + verify(invitations).save(any()); + } + + @Test + void namespaceMemberCannotCreateInvitation() { + Optional active = users.findById("owner"); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + when(users.findLockedById("guest")).thenReturn(active); + + assertThatThrownBy(() -> service.create("team", "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations, never()).save(any()); + } + + @Test + void ownerCannotCreateInvitationForGlobalFrozenOrArchivedNamespace() { + Namespace namespace = namespaces.findById(7L).orElseThrow(); + namespace.setType(NamespaceType.GLOBAL); + assertThatThrownBy(() -> service.create("team", "owner")) + .isInstanceOf(DomainNotFoundException.class); + + namespace.setType(NamespaceType.TEAM); + for (NamespaceStatus status : new NamespaceStatus[] {NamespaceStatus.FROZEN, NamespaceStatus.ARCHIVED}) { + namespace.setStatus(status); + assertThatThrownBy(() -> service.create("team", "owner")) + .as("Namespace status %s", status) + .isInstanceOf(DomainNotFoundException.class); + } + verify(invitations, never()).save(any()); + } + + @Test + void platformAdminWithoutNamespaceMembershipCannotCreateInvitation() { + Optional active = users.findById("owner"); + when(users.findLockedById("platform-admin")).thenReturn(active); + + assertThatThrownBy(() -> service.create("team", "platform-admin")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations, never()).save(any()); + } + + @Test + void ownerCanCreateInvitationForPersonalNamespaceRepresentedAsTeam() { + Namespace personal = new Namespace("personal", "Personal", "owner"); + try { + var id = Namespace.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(personal, 8L); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + assertThat(personal.getType()).isEqualTo(NamespaceType.TEAM); + when(namespaces.findIdBySlug("personal")).thenReturn(Optional.of(8L)); + when(namespaces.findLockedById(8L)).thenReturn(Optional.of(personal)); + when(members.findByNamespaceIdAndUserId(8L, "owner")) + .thenReturn(Optional.of(new NamespaceMember(8L, "owner", NamespaceRole.OWNER))); + + assertThat(service.create("personal", "owner").token()).matches("[A-Za-z0-9_-]{43}"); + verify(invitations).save(any()); + } + + @Test + void revokedTokenCannotBePreviewed() { + assertThatThrownBy(() -> service.preview("A".repeat(43), "guest")) + .isInstanceOf(DomainNotFoundException.class) + .hasMessage("error.namespace.invitation.invalid"); + verify(invitations).findByTokenHash(any()); + } + + @Test + void acceptCreatesMemberAndConsumesExactlyTheCurrentInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor 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)); + namespaces.findById(7L).orElseThrow().setAvatarUrl("https://example.com/team.png"); + + NamespaceInvitationService.Preview preview = service.preview(created.token(), "guest"); + assertThat(preview.namespaceSlug()).isEqualTo("team"); + assertThat(preview.namespaceDisplayName()).isEqualTo("Team"); + assertThat(preview.namespaceAvatarUrl()).isEqualTo("https://example.com/team.png"); + assertThat(preview.outcome()).isEqualTo(NamespaceInvitationService.PreviewOutcome.CONFIRM_REQUIRED); + assertThat(preview.expiresAt()).isEqualTo(created.expiresAt()); + assertThat(service.accept(created.token(), "guest")) + .isEqualTo(new NamespaceInvitationService.Accepted("team", NamespaceInvitationService.AcceptOutcome.JOINED)); + + 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 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 nonActiveUserCannotPreviewOrAcceptAndDoesNotConsumeInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor 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)); + UserAccount pending = mock(UserAccount.class); + when(pending.getStatus()).thenReturn(UserStatus.PENDING); + when(users.findById("guest")).thenReturn(Optional.of(pending)); + when(users.findLockedById("guest")).thenReturn(Optional.of(pending)); + clearInvocations(invitations, members, audit); + + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(invitation.getConsumedAt()).isNull(); + } + + @Test + void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor 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").outcome()) + .isEqualTo(NamespaceInvitationService.PreviewOutcome.ALREADY_MEMBER); + assertThat(service.accept(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER); + verify(members, never()).save(any()); + } + + @Test + void replacingCurrentInvitationMakesOldTokenInvalidAfterPreview() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + ArgumentCaptor 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"); + + assertThat(service.create("team", "owner", true).replaced()).isTrue(); + 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()); + } + + @Test + void renewingConsumedInvitationDoesNotClaimToReplaceUsableLink() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation old = saved.getValue(); + old.consume("guest", clock.instant()); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(old)); + + NamespaceInvitationService.Created renewed = service.create("team", "owner", false); + + assertThat(renewed.replaced()).isFalse(); + assertThat(renewed.token()).isNotEqualTo(first.token()); + } + + @Test + void nonMemberCannotPreviewOrAcceptAtExactExpiryInstant() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(expired)); + when(invitations.findNamespaceIdByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + + assertThat(service.hasActiveInvitation("team", "owner")).isFalse(); + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + assertThat(expired.getConsumedAt()).isNull(); + } + + @Test + void existingMemberCanPreviewAndAcceptCurrentExpiredInvitationWhileNamespaceFrozen() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(expired)); + when(invitations.findNamespaceIdByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + namespaces.findById(7L).orElseThrow().setStatus(NamespaceStatus.FROZEN); + clearInvocations(invitations, members, audit); + + assertThat(service.preview(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.PreviewOutcome.ALREADY_MEMBER); + assertThat(service.accept(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(expired.getConsumedAt()).isNull(); + } + + @Test + void renewingAtExactExpiryDoesNotClaimToReplaceUsableLink() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + + NamespaceInvitationService.Created renewed = service.create("team", "owner", false); + + assertThat(renewed.replaced()).isFalse(); + assertThat(renewed.token()).isNotEqualTo(first.token()); + } + + private NamespaceInvitation invitationExpiringNow() { + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + return new NamespaceInvitation(7L, saved.getValue().getTokenHash(), "owner", + clock.instant().minusSeconds(86400), clock.instant()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 84d823c1f..5d897a3d5 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -2,6 +2,7 @@ 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; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index 12c555f38..1631fa6c6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -5,6 +5,7 @@ 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 @@ void deleteNamespace_shouldDeleteMembersAndNamespaceForOwner() { 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 @@ void deleteNamespace_shouldRejectAdminUser() { 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 @@ void deleteNamespace_shouldRejectNamespaceWithDependentData() { 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)) diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java new file mode 100644 index 000000000..b305ef6e8 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java @@ -0,0 +1,18 @@ +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; + +@Repository +public interface NamespaceInvitationJpaRepository extends JpaRepository, NamespaceInvitationRepository { + Optional findByTokenHash(String tokenHash); + @Query("select invitation.namespaceId from NamespaceInvitation invitation where invitation.tokenHash = :tokenHash") + Optional findNamespaceIdByTokenHash(@Param("tokenHash") String tokenHash); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index ed9a67246..fd112bf38 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -6,6 +6,10 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import jakarta.persistence.LockModeType; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -17,9 +21,16 @@ @Repository public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select n from Namespace n where n.id = :id") + Optional findLockedById(@Param("id") Long id); List findByIdIn(List ids); List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + @Override + @Query("select n.id from Namespace n where n.slug = :slug") + Optional findIdBySlug(@Param("slug") String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index da659024c..d1d25158f 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -172,6 +172,37 @@ describe('namespaceApi.delete', () => { }) }) +describe('namespace invitation API', () => { + it('uses the Issue #911 plural paths for status, create, revoke, preview, and accept', async () => { + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { cookie: 'XSRF-TOKEN=test-token' }, + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ code: 0, msg: 'ok', data: {}, timestamp: '', requestId: '' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await namespaceApi.invitationStatus('@team') + await namespaceApi.createInvitation('@team', false) + await namespaceApi.revokeInvitation('@team') + await namespaceApi.previewInvitation('raw-token') + await namespaceApi.acceptInvitation('raw-token') + + expect(fetchMock.mock.calls.map(([url, init]) => [url, init.method ?? 'GET'])).toEqual([ + ['/api/web/namespaces/team/invitations', 'GET'], + ['/api/web/namespaces/team/invitations', 'POST'], + ['/api/web/namespaces/team/invitations', 'DELETE'], + ['/api/web/namespace-invitations/preview', 'POST'], + ['/api/web/namespace-invitations/accept', 'POST'], + ]) + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ replace: false }) + expect(JSON.parse(fetchMock.mock.calls[3][1].body)).toEqual({ token: 'raw-token' }) + }) +}) + describe('getDirectAuthRuntimeConfig', () => { it('returns disabled when no runtime config is present', () => { const config = getDirectAuthRuntimeConfig() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 09f2fd9d2..c1968dda1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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)}/invitations`) + }, + + async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string; replaced: boolean }> { + return fetchJson<{ token: string; expiresAt: string; replaced: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ replace }), + }) + }, + + async revokeInvitation(slug: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`, { + method: 'DELETE', headers: await ensureCsrfHeaders(), + }) + }, + + async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceDisplayName: string; namespaceAvatarUrl: string | null; outcome: 'CONFIRM_REQUIRED' | 'ALREADY_MEMBER'; expiresAt: string }> { + return fetchJson<{ namespaceSlug: string; namespaceDisplayName: string; namespaceAvatarUrl: string | null; outcome: 'CONFIRM_REQUIRED' | 'ALREADY_MEMBER'; expiresAt: string }>(`${WEB_API_PREFIX}/namespace-invitations/preview`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + + async acceptInvitation(token: string): Promise<{ namespaceSlug: string; outcome: 'JOINED' | 'ALREADY_MEMBER' }> { + return fetchJson<{ namespaceSlug: string; outcome: 'JOINED' | 'ALREADY_MEMBER' }>(`${WEB_API_PREFIX}/namespace-invitations/accept`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + async create(request: CreateNamespaceRequest): Promise { const namespace = await fetchJson('/api/v1/namespaces', { method: 'POST', diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 8884f9caf..8423d97c9 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1932,6 +1932,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespaces/{slug}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status"]; + put?: never; + post: operations["create"]; + delete: operations["revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status_1"]; + put?: never; + post: operations["create_1"]; + delete: operations["revoke_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/namespaces/{slug}/freeze": { parameters: { query?: never; @@ -2028,6 +2060,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespace-invitations/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["preview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespace-invitations/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["preview_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/namespace-invitations/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["accept"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespace-invitations/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["accept_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/governance/notifications/{id}/read": { parameters: { query?: never; @@ -2069,7 +2165,7 @@ export interface paths { }; get: operations["list_4"]; put?: never; - post: operations["create"]; + post: operations["create_2"]; delete?: never; options?: never; head?: never; @@ -5245,7 +5341,7 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations["revoke"]; + delete: operations["revoke_2"]; options?: never; head?: never; patch?: never; @@ -6070,9 +6166,62 @@ export interface components { success?: boolean; error?: string; }; + NamespaceInvitationCreateRequest: { + replace?: boolean; + }; + ApiResponseCreated: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Created"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Created: { + token?: string; + /** Format: date-time */ + expiresAt?: string; + replaced?: boolean; + }; NamespaceLifecycleRequest: { reason?: string; }; + NamespaceInvitationTokenRequest: { + token: string; + }; + ApiResponsePreview: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Preview"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Preview: { + namespaceSlug?: string; + namespaceDisplayName?: string; + namespaceAvatarUrl?: string; + /** @enum {string} */ + outcome?: "CONFIRM_REQUIRED" | "ALREADY_MEMBER"; + /** Format: date-time */ + expiresAt?: string; + }; + Accepted: { + namespaceSlug?: string; + /** @enum {string} */ + outcome?: "JOINED" | "ALREADY_MEMBER"; + }; + ApiResponseAccepted: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Accepted"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseGovernanceNotificationResponse: { /** Format: int32 */ code?: number; @@ -7265,6 +7414,18 @@ export interface components { email?: string; status?: string; }; + ApiResponseInvitationStatus: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["InvitationStatus"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + InvitationStatus: { + active?: boolean; + }; ApiResponsePageResponseNamespaceResponse: { /** Format: int32 */ code?: number; @@ -11939,6 +12100,146 @@ export interface operations { }; }; }; + status: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationStatus"]; + }; + }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceInvitationCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCreated"]; + }; + }; + }; + }; + revoke: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + status_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationStatus"]; + }; + }; + }; + }; + create_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceInvitationCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCreated"]; + }; + }; + }; + }; + revoke_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; freezeNamespace: { parameters: { query?: never; @@ -12135,6 +12436,102 @@ export interface operations { }; }; }; + preview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; + preview_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; + accept: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccepted"]; + }; + }; + }; + }; + accept_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccepted"]; + }; + }; + }; + }; markNotificationRead: { parameters: { query?: never; @@ -12202,7 +12599,7 @@ export interface operations { }; }; }; - create: { + create_2: { parameters: { query?: never; header?: never; @@ -17093,7 +17490,7 @@ export interface operations { }; }; }; - revoke: { + revoke_2: { parameters: { query?: never; header?: never; diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index f8a95847f..50c879cbc 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { isRedirect } from '@tanstack/react-router' // The router module captures window.location.search at module load time. // We test the exported ORIGINAL_URL_SEARCH constant and the buildReturnTo @@ -51,4 +52,23 @@ describe('router', () => { const childPaths = children.map((route) => route.fullPath ?? route.path) expect(childPaths).toContain('/device') }) + + it('returns unauthenticated invitation visitors to /join even with a query and deployment base path', async () => { + const children = (router.routeTree.children ?? []) as Array<{ + fullPath?: string + options?: { beforeLoad?: (context: { location: { pathname: string; searchStr: string; hash: string } }) => Promise } + }> + const join = children.find((route) => route.fullPath === '/join') + expect(join?.options?.beforeLoad).toBeDefined() + + await expect(join!.options!.beforeLoad!({ + location: { pathname: '/skillhub/join', searchStr: '?x=1', hash: '#invitation-token' }, + })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/login') + expect(error.options.search).toEqual({ returnTo: '/join' }) + return true + }) + }) }) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 54573332c..55e332ea0 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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 @@ -233,6 +236,7 @@ const rootRoute = createRootRoute({ }) const requireAuth = createRequireAuth(getCurrentUser) +const requireJoinAuth = createRequireAuth(getCurrentUser, '/join') const redirectAuthenticated = createRedirectAuthenticated(getCurrentUser) const landingRoute = createRoute({ @@ -323,6 +327,13 @@ const namespaceRoute = createRoute({ component: NamespacePage, }) +const joinRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'join', + beforeLoad: requireJoinAuth, + component: JoinPage, +}) + const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', @@ -678,6 +689,7 @@ const routeTree = rootRoute.addChildren([ suiteDetailRoute, termsRoute, namespaceRoute, + joinRoute, skillDetailRoute, skillVersionCompareRoute, dashboardRoute, diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx new file mode 100644 index 000000000..e56597992 --- /dev/null +++ b/web/src/features/namespace/invitation-controls.tsx @@ -0,0 +1,69 @@ +import { useId, 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, compact = false }: { slug: string; compact?: boolean }) { + const { t } = useTranslation() + const linkId = useId() + const queryClient = useQueryClient() + const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null) + const [link, setLink] = useState(null) + const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug), enabled: !compact }) + const create = useMutation({ + mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), + onSuccess: ({ token, replaced }) => { + const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token) + setLink(url) + try { + void navigator.clipboard.writeText(url) + .then(() => toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied'))) + .catch(() => toast.info(t('invitation.manualCopy'))) + } catch { + toast.info(t('invitation.manualCopy')) + } + void queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + }, + }) + 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 = async () => { + const current = compact ? (await status.refetch()).data : status.data + if (current?.active) setConfirm('replace') + else if (current) create.mutate(false) + } + return ( +
event.stopPropagation() : undefined}> +
+ + {status.data?.active ? : null} +
+ {link ? ( +
+ + event.currentTarget.select()} /> +

{t('invitation.linkHint')}

+
+ ) : null} + {(create.error || revoke.error || status.error) ?

{t('invitation.error')}

: null} + { 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) }} /> +
+ ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a863e68d2..2a8ce3d77 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1388,6 +1388,32 @@ "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", + "replacedCopied": "Old invitation replaced; new link copied", + "manualCopy": "Automatic copy failed. Copy the link below manually.", + "revoked": "Invitation revoked", + "error": "Invitation action failed. Try again.", + "invalid": "This invitation link 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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5f7d8503c..fbfe0e39a 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1453,6 +1453,32 @@ "searchRebuildSuccessDescription": "Система перестраивает полный индекс поиска по текущим правилам.", "searchRebuildErrorTitle": "Не удалось перестроить индекс поиска" }, + "invitation": { + "title": "Вступить в командное пространство", + "create": "Пригласить участника", + "revoke": "Отозвать приглашение", + "replaceTitle": "Заменить текущее приглашение?", + "replaceDescription": "Старая ссылка сразу перестанет работать. Участники не изменятся.", + "revokeTitle": "Отозвать текущее приглашение?", + "revokeDescription": "Ссылка сразу перестанет работать. Участники не изменятся.", + "link": "Ссылка приглашения", + "linkHint": "Действует 24 часа и только для одного нового участника. Передавайте безопасно.", + "copied": "Ссылка скопирована", + "replacedCopied": "Старое приглашение заменено, новая ссылка скопирована", + "manualCopy": "Не удалось скопировать автоматически. Скопируйте ссылку ниже.", + "revoked": "Приглашение отозвано", + "error": "Не удалось выполнить действие. Попробуйте ещё раз.", + "invalid": "Ссылка-приглашение недействительна или истекла. Запросите новую ссылку у администратора.", + "loading": "Проверка приглашения…", + "networkError": "Ошибка соединения. Попробуйте ещё раз.", + "retry": "Повторить", + "namespace": "Пространство: {{name}}", + "account": "Текущая учётная запись: {{name}}", + "permissions": "Подтвердите вступление с ролью MEMBER. Вы сможете просматривать ресурсы и публиковать навыки.", + "alreadyMember": "Вы уже участник этого пространства.", + "enter": "Открыть пространство", + "confirm": "Подтвердить вступление" + }, "members": { "title": "Управление участниками", "addMember": "Добавить участника", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ba4d66fa5..320a9e5ad 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1388,6 +1388,32 @@ "searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。", "searchRebuildErrorTitle": "搜索索引重建失败" }, + "invitation": { + "title": "加入团队空间", + "create": "邀请成员", + "revoke": "作废邀请", + "replaceTitle": "替换现有邀请?", + "replaceDescription": "旧链接将立即失效,已加入的成员不受影响。", + "revokeTitle": "作废当前邀请?", + "revokeDescription": "链接将立即失效,已加入的成员不受影响。", + "link": "邀请链接", + "linkHint": "24 小时内有效,仅能由一名新成员使用一次。请安全分享。", + "copied": "邀请链接已复制", + "replacedCopied": "旧邀请已替换,新链接已复制", + "manualCopy": "无法自动复制,请手动复制下方链接", + "revoked": "邀请已作废", + "error": "邀请操作失败,请重试", + "invalid": "邀请链接无效或已失效,请联系空间管理员获取新链接。", + "loading": "正在核对邀请…", + "networkError": "连接失败,请重试。", + "retry": "重试", + "namespace": "目标空间:{{name}}", + "account": "当前账号:{{name}}", + "permissions": "确认后,你将以 MEMBER 身份加入,可访问空间资源并发布技能。", + "alreadyMember": "你已是该空间成员。", + "enter": "进入空间", + "confirm": "确认加入" + }, "members": { "title": "成员管理", "addMember": "添加成员", diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index b50f6717b..48f60f7d7 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -10,6 +10,7 @@ import { Pagination } from '@/shared/components/pagination' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog' +import { InvitationControls } from '@/features/namespace/invitation-controls' import { useArchiveNamespace, useDeleteNamespace, @@ -306,6 +307,10 @@ export function MyNamespacesPage() { {t('myNamespaces.manageMembers')} )} + {namespace.type === 'TEAM' && namespace.status === 'ACTIVE' + && (namespace.currentUserRole === 'OWNER' || namespace.currentUserRole === 'ADMIN') && ( + + )} diff --git a/web/src/pages/join.test.tsx b/web/src/pages/join.test.tsx new file mode 100644 index 000000000..c24524417 --- /dev/null +++ b/web/src/pages/join.test.tsx @@ -0,0 +1,84 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + preview: vi.fn(), + accept: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/api/client', () => ({ + namespaceApi: { previewInvitation: mocks.preview, acceptInvitation: mocks.accept }, +})) +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: () => ({ user: { userId: 'guest', displayName: 'Guest' } }), +})) +vi.mock('@/shared/lib/api-error', () => ({ + ApiError: class ApiError extends Error { + constructor(message: string, public status: number) { super(message) } + }, +})) + +import { ApiError } from '@/shared/lib/api-error' +import { JoinPage } from './join' + +const token = 'A'.repeat(43) + +function renderJoin() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return { client, ...render() } +} + +describe('JoinPage', () => { + beforeEach(() => { + window.sessionStorage.clear() + window.history.replaceState(null, '', '/') + mocks.navigate.mockReset() + mocks.preview.mockReset() + mocks.accept.mockReset() + }) + + afterEach(cleanup) + + it('clears the token and navigates once with replace when preview finds an existing member', async () => { + window.history.replaceState(null, '', `/join#${token}`) + mocks.preview.mockResolvedValue({ + namespaceSlug: 'team', namespaceDisplayName: 'Team', namespaceAvatarUrl: null, + outcome: 'ALREADY_MEMBER', expiresAt: '2026-10-13T00:00:00Z', + }) + + const view = renderJoin() + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/space/$namespace', params: { namespace: 'team' }, replace: true, + })) + expect(window.sessionStorage.length).toBe(0) + expect(window.location.hash).toBe('') + view.rerender() + expect(mocks.navigate).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: 'invitation.enter' })).toBeNull() + }) + + it('announces loading while preview is pending', () => { + window.history.replaceState(null, '', `/join#${token}`) + mocks.preview.mockImplementation(() => new Promise(() => {})) + + renderJoin() + + expect(screen.getByRole('status').textContent).toBe('invitation.loading') + }) + + it('announces a terminal invalid link', async () => { + window.history.replaceState(null, '', `/join#${token}`) + mocks.preview.mockRejectedValue(new ApiError('invalid', 404)) + + renderJoin() + + expect((await screen.findByRole('alert')).textContent).toBe('invitation.invalid') + expect(window.sessionStorage.length).toBe(0) + }) +}) diff --git a/web/src/pages/join.tsx b/web/src/pages/join.tsx new file mode 100644 index 000000000..60dc8d6aa --- /dev/null +++ b/web/src/pages/join.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, 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 enteredNamespace = useRef(false) + 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 }, replace: true }) + }, + }) + const invalid = !token || (preview.error instanceof ApiError && preview.error.status === 404) + || (accept.error instanceof ApiError && accept.error.status === 404) + useEffect(() => { if (invalid) clearInvitationToken() }, [invalid]) + useEffect(() => { + if (preview.data?.outcome !== 'ALREADY_MEMBER' || enteredNamespace.current) return + enteredNamespace.current = true + clearInvitationToken() + void navigate({ to: '/space/$namespace', params: { namespace: preview.data.namespaceSlug }, replace: true }) + }, [navigate, preview.data]) + + return ( +
+ +

{t('invitation.title')}

+ {invalid ?

{t('invitation.invalid')}

: null} + {!invalid && preview.isPending ?

{t('invitation.loading')}

: null} + {!invalid && preview.error ? ( +
+

{t('invitation.networkError')}

+ +
+ ) : null} + {!invalid && preview.data ? ( +
+

{t('invitation.namespace', { name: preview.data.namespaceDisplayName })}

+

{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}

+ {preview.data.outcome === 'ALREADY_MEMBER' ? ( +

{t('invitation.alreadyMember')}

+ ) : ( + <> +

{t('invitation.permissions')}

+ + {accept.error && !(accept.error instanceof ApiError && accept.error.status === 404) ? ( +

{t('invitation.networkError')}

+ ) : null} + + )} +
+ ) : null} +
+
+ ) +} diff --git a/web/src/shared/lib/auth-route.ts b/web/src/shared/lib/auth-route.ts index ada9d746b..6988445f7 100644 --- a/web/src/shared/lib/auth-route.ts +++ b/web/src/shared/lib/auth-route.ts @@ -53,13 +53,13 @@ export function createRedirectAuthenticated(getCurrentUser: () => Promise Promise) { +export function createRequireAuth(getCurrentUser: () => Promise, returnTo?: string) { return async function requireAuth({ location }: { location: RouteLocationLike }) { const user = await getCurrentUser() if (!user) { throw redirect({ to: '/login', - search: { returnTo: buildReturnTo(location) }, + search: { returnTo: returnTo ?? buildReturnTo(location) }, }) } return { user } diff --git a/web/src/shared/lib/namespace-invitation.test.ts b/web/src/shared/lib/namespace-invitation.test.ts new file mode 100644 index 000000000..09aa5b055 --- /dev/null +++ b/web/src/shared/lib/namespace-invitation.test.ts @@ -0,0 +1,32 @@ +// @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.pathname).toBe('/join') + expect(window.location.search).toBe('?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}`) + }) +}) diff --git a/web/src/shared/lib/namespace-invitation.ts b/web/src/shared/lib/namespace-invitation.ts new file mode 100644 index 000000000..9a1b9140c --- /dev/null +++ b/web/src/shared/lib/namespace-invitation.ts @@ -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}` +}