fix(invitation): align API with issue contract

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 12:48:18 +08:00
parent 6299733dd8
commit 44a1623620
18 changed files with 233 additions and 55 deletions

View file

@ -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

View file

@ -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`

View file

@ -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 和验收报告。

View file

@ -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);

View file

@ -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

View file

@ -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=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён

View file

@ -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 命名空间成员

View file

@ -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();
}
}
}

View file

@ -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();

View file

@ -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 {

View file

@ -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();

View file

@ -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()

View file

@ -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 }),
})

View file

@ -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"];
};
};
};

View file

@ -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",

View file

@ -1468,7 +1468,7 @@
"manualCopy": "Не удалось скопировать автоматически. Скопируйте ссылку ниже.",
"revoked": "Приглашение отозвано",
"error": "Не удалось выполнить действие. Попробуйте ещё раз.",
"invalid": "Приглашение недействительно или истекло. Запросите новую ссылку у администратора.",
"invalid": "Ссылка-приглашение недействительна или истекла. Запросите новую ссылку у администратора.",
"loading": "Проверка приглашения…",
"networkError": "Ошибка соединения. Попробуйте ещё раз.",
"retry": "Повторить",

View file

@ -1403,7 +1403,7 @@
"manualCopy": "无法自动复制,请手动复制下方链接",
"revoked": "邀请已作废",
"error": "邀请操作失败,请重试",
"invalid": "邀请无效或已失效,请联系空间管理员获取新链接。",
"invalid": "邀请链接无效或已失效,请联系空间管理员获取新链接。",
"loading": "正在核对邀请…",
"networkError": "连接失败,请重试。",
"retry": "重试",

View file

@ -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>