mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
fix(invitation): align API with issue contract
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
6299733dd8
commit
44a1623620
18 changed files with 233 additions and 55 deletions
|
|
@ -10,6 +10,7 @@
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -94,3 +94,19 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;
|
|||
#### 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`
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
## 1. 持久化与事务
|
||||
|
||||
- [ ] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。
|
||||
- [ ] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。
|
||||
- [ ] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。
|
||||
- [x] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。
|
||||
- [x] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。
|
||||
- [x] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。
|
||||
|
||||
## 2. API 与页面
|
||||
|
||||
- [ ] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。
|
||||
- [ ] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。
|
||||
- [ ] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。
|
||||
- [x] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。
|
||||
- [x] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。
|
||||
- [x] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。
|
||||
|
||||
## 3. 验证与交付
|
||||
|
||||
- [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。
|
||||
- [x] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。
|
||||
- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
|
||||
- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public class NamespaceInvitationController extends BaseApiController {
|
|||
this.invitations = invitations;
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/invitation")
|
||||
@GetMapping("/namespaces/{slug}/invitations")
|
||||
public ApiResponse<InvitationStatus> status(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
noStore(response);
|
||||
|
|
@ -39,7 +39,7 @@ public class NamespaceInvitationController extends BaseApiController {
|
|||
return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId())));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/invitation")
|
||||
@PostMapping("/namespaces/{slug}/invitations")
|
||||
public ApiResponse<NamespaceInvitationService.Created> create(@PathVariable String slug,
|
||||
@RequestBody(required = false) NamespaceInvitationCreateRequest body,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||
|
|
@ -48,7 +48,7 @@ public class NamespaceInvitationController extends BaseApiController {
|
|||
return ok("response.success.created", invitations.create(slug, principal.userId(), body != null && body.replace()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/namespaces/{slug}/invitation")
|
||||
@DeleteMapping("/namespaces/{slug}/invitations")
|
||||
public ApiResponse<MessageResponse> revoke(@PathVariable String slug,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||
noStore(response);
|
||||
|
|
@ -57,7 +57,7 @@ public class NamespaceInvitationController extends BaseApiController {
|
|||
return ok("response.success.deleted", new MessageResponse("Invitation revoked"));
|
||||
}
|
||||
|
||||
@PostMapping("/invitations/preview")
|
||||
@PostMapping("/namespace-invitations/preview")
|
||||
public ApiResponse<NamespaceInvitationService.Preview> preview(@Valid @RequestBody NamespaceInvitationTokenRequest body,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||
noStore(response);
|
||||
|
|
@ -65,8 +65,8 @@ public class NamespaceInvitationController extends BaseApiController {
|
|||
return ok("response.success.read", invitations.preview(body.token(), principal.userId()));
|
||||
}
|
||||
|
||||
@PostMapping("/invitations/accept")
|
||||
public ApiResponse<NamespaceInvitationService.Preview> accept(@Valid @RequestBody NamespaceInvitationTokenRequest body,
|
||||
@PostMapping("/namespace-invitations/accept")
|
||||
public ApiResponse<NamespaceInvitationService.Accepted> accept(@Valid @RequestBody NamespaceInvitationTokenRequest body,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||
noStore(response);
|
||||
requireSession(principal, request);
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ label.translation.locale.conflict=Duplicate label translation locale
|
|||
error.namespace.slug.exists=Namespace slug ''{0}'' already exists
|
||||
error.namespace.id.notFound=Namespace not found: {0}
|
||||
error.namespace.slug.notFound=Namespace not found: {0}
|
||||
error.namespace.invite.invalid=Invitation not found or expired
|
||||
error.namespace.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
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ label.translation.locale.conflict=Дублирующаяся локаль пер
|
|||
error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует
|
||||
error.namespace.id.notFound=Пространство имён не найдено: {0}
|
||||
error.namespace.slug.notFound=Пространство имён не найдено: {0}
|
||||
error.namespace.invite.invalid=Приглашение не найдено или недействительно
|
||||
error.namespace.invitation.invalid=Ссылка-приглашение недействительна или срок её действия истёк
|
||||
error.namespace.invite.replaceConfirmationRequired=Подтвердите замену текущего приглашения
|
||||
error.namespace.membership.required=Требуется членство в пространстве имён
|
||||
error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ label.translation.locale.conflict=标签翻译 locale 重复
|
|||
error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在
|
||||
error.namespace.id.notFound=未找到命名空间:{0}
|
||||
error.namespace.slug.notFound=未找到命名空间:{0}
|
||||
error.namespace.invite.invalid=邀请不存在或已失效
|
||||
error.namespace.invitation.invalid=邀请链接无效或已失效
|
||||
error.namespace.invite.replaceConfirmationRequired=请先确认替换当前邀请
|
||||
error.namespace.membership.required=需要先加入该命名空间
|
||||
error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员
|
||||
|
|
|
|||
|
|
@ -1,18 +1,38 @@
|
|||
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.*;
|
||||
|
||||
|
|
@ -62,4 +82,79 @@ class NamespaceInvitationControllerTest {
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ class NamespaceInvitationPostgresTest {
|
|||
try (var pool = Executors.newFixedThreadPool(2)) {
|
||||
var accept = pool.submit(() -> {
|
||||
start.await();
|
||||
return invitations.accept(token, guest).alreadyMember();
|
||||
return invitations.accept(token, guest).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER;
|
||||
});
|
||||
var direct = pool.submit(() -> {
|
||||
start.await();
|
||||
|
|
@ -280,7 +280,7 @@ class NamespaceInvitationPostgresTest {
|
|||
try (var pool = Executors.newFixedThreadPool(2)) {
|
||||
var accept = pool.submit(() -> {
|
||||
start.await();
|
||||
return invitations.accept(token, primary).alreadyMember();
|
||||
return invitations.accept(token, primary).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER;
|
||||
});
|
||||
var merge = pool.submit(() -> {
|
||||
start.await();
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ public class NamespaceInvitationService {
|
|||
}
|
||||
|
||||
public record Created(String token, Instant expiresAt, boolean replaced) {}
|
||||
public record Preview(String namespaceSlug, String namespaceName, boolean alreadyMember) {}
|
||||
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) {
|
||||
|
|
@ -88,7 +92,7 @@ public class NamespaceInvitationService {
|
|||
@Transactional(readOnly = true)
|
||||
public boolean hasActiveInvitation(String slug, String actorId) {
|
||||
Namespace namespace = namespaces.findBySlug(slug)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.invite.invalid"));
|
||||
.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()))
|
||||
|
|
@ -104,11 +108,12 @@ public class NamespaceInvitationService {
|
|||
requireActiveUser(userId);
|
||||
requireValid(namespace, invitation);
|
||||
}
|
||||
return new Preview(namespace.getSlug(), namespace.getDisplayName(), member);
|
||||
return new Preview(namespace.getSlug(), namespace.getDisplayName(), namespace.getAvatarUrl(),
|
||||
member ? PreviewOutcome.ALREADY_MEMBER : PreviewOutcome.CONFIRM_REQUIRED, invitation.getExpiresAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Preview accept(String token, String userId) {
|
||||
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);
|
||||
|
|
@ -120,7 +125,7 @@ public class NamespaceInvitationService {
|
|||
throw invalid();
|
||||
}
|
||||
if (members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent()) {
|
||||
return new Preview(namespace.getSlug(), namespace.getDisplayName(), true);
|
||||
return new Accepted(namespace.getSlug(), AcceptOutcome.ALREADY_MEMBER);
|
||||
}
|
||||
requireActiveUser(userId);
|
||||
requireValid(namespace, invitation);
|
||||
|
|
@ -128,7 +133,7 @@ public class NamespaceInvitationService {
|
|||
invitation.consume(userId, clock.instant());
|
||||
invitations.save(invitation);
|
||||
record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId());
|
||||
return new Preview(namespace.getSlug(), namespace.getDisplayName(), false);
|
||||
return new Accepted(namespace.getSlug(), AcceptOutcome.JOINED);
|
||||
}
|
||||
|
||||
private Namespace lock(String slug) {
|
||||
|
|
@ -170,7 +175,7 @@ public class NamespaceInvitationService {
|
|||
if (invitation.getConsumedAt() != null || !invitation.getExpiresAt().isAfter(clock.instant())) throw invalid();
|
||||
}
|
||||
|
||||
private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invite.invalid"); }
|
||||
private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invitation.invalid"); }
|
||||
|
||||
private String hash(String token) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,8 @@ class NamespaceInvitationServiceTest {
|
|||
@Test
|
||||
void revokedTokenCannotBePreviewed() {
|
||||
assertThatThrownBy(() -> service.preview("A".repeat(43), "guest"))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
.isInstanceOf(DomainNotFoundException.class)
|
||||
.hasMessage("error.namespace.invitation.invalid");
|
||||
verify(invitations).findByTokenHash(any());
|
||||
}
|
||||
|
||||
|
|
@ -151,9 +152,16 @@ class NamespaceInvitationServiceTest {
|
|||
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");
|
||||
|
||||
assertThat(service.preview(created.token(), "guest").alreadyMember()).isFalse();
|
||||
assertThat(service.accept(created.token(), "guest").alreadyMember()).isFalse();
|
||||
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")));
|
||||
|
|
@ -203,8 +211,10 @@ class NamespaceInvitationServiceTest {
|
|||
when(members.findByNamespaceIdAndUserId(7L, "guest"))
|
||||
.thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER)));
|
||||
|
||||
assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue();
|
||||
assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue();
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
@ -278,8 +288,10 @@ class NamespaceInvitationServiceTest {
|
|||
namespaces.findById(7L).orElseThrow().setStatus(NamespaceStatus.FROZEN);
|
||||
clearInvocations(invitations, members, audit);
|
||||
|
||||
assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue();
|
||||
assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -748,31 +748,31 @@ export const labelApi = {
|
|||
|
||||
export const namespaceApi = {
|
||||
async invitationStatus(slug: string): Promise<{ active: boolean }> {
|
||||
return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`)
|
||||
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)}/invitation`, {
|
||||
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<void> {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`, {
|
||||
method: 'DELETE', headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> {
|
||||
return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/preview`, {
|
||||
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; namespaceName: string; alreadyMember: boolean }> {
|
||||
return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/accept`, {
|
||||
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 }),
|
||||
})
|
||||
|
|
|
|||
38
web/src/api/generated/schema.d.ts
vendored
38
web/src/api/generated/schema.d.ts
vendored
|
|
@ -1932,7 +1932,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/namespaces/{slug}/invitation": {
|
||||
"/api/web/namespaces/{slug}/invitations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -1948,7 +1948,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/namespaces/{slug}/invitation": {
|
||||
"/api/v1/namespaces/{slug}/invitations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -2060,7 +2060,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/invitations/preview": {
|
||||
"/api/web/namespace-invitations/preview": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -2076,7 +2076,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/invitations/preview": {
|
||||
"/api/v1/namespace-invitations/preview": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -2092,7 +2092,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/invitations/accept": {
|
||||
"/api/web/namespace-invitations/accept": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -2108,7 +2108,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/invitations/accept": {
|
||||
"/api/v1/namespace-invitations/accept": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -6201,8 +6201,26 @@ export interface components {
|
|||
};
|
||||
Preview: {
|
||||
namespaceSlug?: string;
|
||||
namespaceName?: string;
|
||||
alreadyMember?: boolean;
|
||||
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 */
|
||||
|
|
@ -12485,7 +12503,7 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePreview"];
|
||||
"*/*": components["schemas"]["ApiResponseAccepted"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -12509,7 +12527,7 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePreview"];
|
||||
"*/*": components["schemas"]["ApiResponseAccepted"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1403,7 +1403,7 @@
|
|||
"manualCopy": "Automatic copy failed. Copy the link below manually.",
|
||||
"revoked": "Invitation revoked",
|
||||
"error": "Invitation action failed. Try again.",
|
||||
"invalid": "This invitation is invalid or expired. Ask a space admin for a new link.",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1468,7 +1468,7 @@
|
|||
"manualCopy": "Не удалось скопировать автоматически. Скопируйте ссылку ниже.",
|
||||
"revoked": "Приглашение отозвано",
|
||||
"error": "Не удалось выполнить действие. Попробуйте ещё раз.",
|
||||
"invalid": "Приглашение недействительно или истекло. Запросите новую ссылку у администратора.",
|
||||
"invalid": "Ссылка-приглашение недействительна или истекла. Запросите новую ссылку у администратора.",
|
||||
"loading": "Проверка приглашения…",
|
||||
"networkError": "Ошибка соединения. Попробуйте ещё раз.",
|
||||
"retry": "Повторить",
|
||||
|
|
|
|||
|
|
@ -1403,7 +1403,7 @@
|
|||
"manualCopy": "无法自动复制,请手动复制下方链接",
|
||||
"revoked": "邀请已作废",
|
||||
"error": "邀请操作失败,请重试",
|
||||
"invalid": "邀请无效或已失效,请联系空间管理员获取新链接。",
|
||||
"invalid": "邀请链接无效或已失效,请联系空间管理员获取新链接。",
|
||||
"loading": "正在核对邀请…",
|
||||
"networkError": "连接失败,请重试。",
|
||||
"retry": "重试",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function JoinPage() {
|
|||
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?.alreadyMember) clearInvitationToken() }, [preview.data?.alreadyMember])
|
||||
useEffect(() => { if (preview.data?.outcome === 'ALREADY_MEMBER') clearInvitationToken() }, [preview.data?.outcome])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-16">
|
||||
|
|
@ -51,9 +51,9 @@ export function JoinPage() {
|
|||
) : null}
|
||||
{!invalid && preview.data ? (
|
||||
<div className="space-y-4">
|
||||
<p>{t('invitation.namespace', { name: preview.data.namespaceName })}</p>
|
||||
<p>{t('invitation.namespace', { name: preview.data.namespaceDisplayName })}</p>
|
||||
<p>{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}</p>
|
||||
{preview.data.alreadyMember ? (
|
||||
{preview.data.outcome === 'ALREADY_MEMBER' ? (
|
||||
<>
|
||||
<p>{t('invitation.alreadyMember')}</p>
|
||||
<Button onClick={() => { clearInvitationToken(); navigate({ to: '/space/$namespace', params: { namespace: preview.data.namespaceSlug }, replace: true }) }}>{t('invitation.enter')}</Button>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue