From dc635a24d68d16783d0eaefde85b44df3065262e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:03:55 +0800 Subject: [PATCH] fix(auth): require secondary approval before account merge Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/03-authentication-design.md | 8 +- .../controller/AccountMergeController.java | 32 +++- .../dto/MergeApprovalDetailsResponse.java | 8 + .../skillhub/dto/MergeInitiateResponse.java | 1 - .../skillhub/dto/MergeVerifyRequest.java | 5 +- .../AccountMergeControllerTest.java | 44 ++++- .../AccountMergeFlowIntegrationTest.java | 156 ++++++++++++++++++ .../merge/AccountMergeRequestRepository.java | 12 +- .../auth/merge/AccountMergeService.java | 101 ++++++++---- .../auth/merge/AccountMergeServiceTest.java | 124 +++++++++++--- web/src/api/client.ts | 15 ++ web/src/api/generated/schema.d.ts | 100 ++++++++++- web/src/api/types.ts | 9 +- web/src/features/auth/use-account-merge.ts | 12 ++ web/src/i18n/locales/en.json | 21 ++- web/src/i18n/locales/ru.json | 21 ++- web/src/i18n/locales/zh.json | 23 ++- web/src/pages/settings/accounts.test.ts | 2 + web/src/pages/settings/accounts.tsx | 79 ++++++--- 19 files changed, 655 insertions(+), 118 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeApprovalDetailsResponse.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 9fa2a179..d847dd64 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -434,10 +434,14 @@ public class OAuthClaimsExtractor { 同一个员工通过不同 OAuth Provider 登录时,可能产生多个 `user_account`。 -一期策略:默认关闭自动合并,仅支持管理员手动合并。 +默认不按邮箱自动合并。当前自助合并要求两个账号分别完成身份确认: +发起账号创建 30 分钟内有效的请求;待合并账号在自己的已认证会话中查看合并目标并批准; +最后发起账号在批准后 30 分钟内确认迁移;任一账号可在完成前撤销请求。 +请求 ID 只用于定位请求,不是第二个账号的所有权凭据。 +账号合并页面当前仍未开放,`/settings/accounts` 保持重定向;上述流程由 API 强制执行。 - 一期 GitHub-only:不需要自动合并,每个 Provider 登录独立创建用户 -- 多 Provider 上线时,再引入显式绑定/合并流程(用户主动发起 + 邮箱验证确认) +- 多 Provider 场景使用显式绑定/合并流程;单独验证邮箱不能证明同时控制两个账号 - 管理员可在后台手动合并两个 user_account(合并 identity_binding、迁移 skill ownership、合并角色取并集) 合并操作规则: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java index b7e1386e..cb1785ed 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -7,11 +7,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.MergeInitiateRequest; import com.iflytek.skillhub.dto.MergeInitiateResponse; import com.iflytek.skillhub.dto.MergeVerifyRequest; +import com.iflytek.skillhub.dto.MergeApprovalDetailsResponse; import com.iflytek.skillhub.dto.MessageResponse; import com.iflytek.skillhub.exception.UnauthorizedException; import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -42,11 +45,24 @@ public class AccountMergeController extends BaseApiController { return ok("response.success.created", new MergeInitiateResponse( result.mergeRequestId(), result.secondaryUserId(), - result.verificationToken(), result.expiresAt().toString() )); } + @GetMapping("/requests/{mergeRequestId}") + public ApiResponse approvalDetails( + @AuthenticationPrincipal PlatformPrincipal principal, + @PathVariable Long mergeRequestId + ) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + var details = accountMergeService.getApprovalDetails(principal.userId(), mergeRequestId); + return ok("response.success.read", new MergeApprovalDetailsResponse( + details.mergeRequestId(), details.primaryUserId(), details.primaryDisplayName(), details.expiresAt().toString() + )); + } + @PostMapping("/verify") public ApiResponse verify(@AuthenticationPrincipal PlatformPrincipal principal, @Valid @RequestBody MergeVerifyRequest request) { @@ -55,8 +71,7 @@ public class AccountMergeController extends BaseApiController { } accountMergeService.verify( principal.userId(), - request.mergeRequestId(), - request.verificationToken() + request.mergeRequestId() ); return ok("response.success.updated", new MessageResponse("Account merge verified")); } @@ -71,5 +86,16 @@ public class AccountMergeController extends BaseApiController { return ok("response.success.updated", new MessageResponse("Account merge completed")); } + @PostMapping("/cancel") + public ApiResponse cancel(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody CancelMergeRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.cancel(principal.userId(), request.mergeRequestId()); + return ok("response.success.updated", new MessageResponse("Account merge cancelled")); + } + public record ConfirmMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {} + public record CancelMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {} } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeApprovalDetailsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeApprovalDetailsResponse.java new file mode 100644 index 00000000..e9c49244 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeApprovalDetailsResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record MergeApprovalDetailsResponse( + Long mergeRequestId, + String primaryUserId, + String primaryDisplayName, + String expiresAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java index 86e7fab8..4b770571 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java @@ -3,6 +3,5 @@ package com.iflytek.skillhub.dto; public record MergeInitiateResponse( Long mergeRequestId, String secondaryUserId, - String verificationToken, String expiresAt ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java index 3d0e63d4..9669d1e9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java @@ -1,11 +1,8 @@ package com.iflytek.skillhub.dto; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record MergeVerifyRequest( @NotNull(message = "合并请求 ID 不能为空") - Long mergeRequestId, - @NotBlank(message = "验证 token 不能为空") - String verificationToken + Long mergeRequestId ) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java index e0d5677b..df95ffd2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -40,11 +41,11 @@ class AccountMergeControllerTest { private NamespaceMemberRepository namespaceMemberRepository; @Test - void initiate_returnsVerificationToken() throws Exception { + void initiate_doesNotReturnVerificationToken() throws Exception { PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); given(accountMergeService.initiate("usr_primary", "secondary")) - .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", Instant.parse("2026-03-12T22:30:00Z"))); + .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", Instant.parse("2026-03-12T22:30:00Z"))); mockMvc.perform(post("/api/v1/account/merge/initiate") .with(authentication(auth)) @@ -57,13 +58,26 @@ class AccountMergeControllerTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.mergeRequestId").value(1)) .andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary")) - .andExpect(jsonPath("$.data.verificationToken").value("merge-token")) + .andExpect(jsonPath("$.data.verificationToken").doesNotExist()) .andExpect(jsonPath("$.data.expiresAt").value("2026-03-12T22:30:00Z")); } + @Test + void approvalDetails_returnsDestinationForSecondaryAccount() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_secondary", "secondary", "s@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + given(accountMergeService.getApprovalDetails("usr_secondary", 1L)) + .willReturn(new AccountMergeService.ApprovalDetails(1L, "usr_primary", "Primary Name", Instant.parse("2026-03-12T22:30:00Z"))); + + mockMvc.perform(get("/api/v1/account/merge/requests/1").with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.primaryUserId").value("usr_primary")) + .andExpect(jsonPath("$.data.primaryDisplayName").value("Primary Name")); + } + @Test void verify_returnsSuccessMessage() throws Exception { - PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + PlatformPrincipal principal = new PlatformPrincipal("usr_secondary", "secondary", "s@example.com", "", "local", Set.of()); var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); mockMvc.perform(post("/api/v1/account/merge/verify") @@ -71,13 +85,13 @@ class AccountMergeControllerTest { .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" - {"mergeRequestId":1,"verificationToken":"merge-token"} + {"mergeRequestId":1} """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.message").value("Account merge verified")); - verify(accountMergeService).verify("usr_primary", 1L, "merge-token"); + verify(accountMergeService).verify("usr_secondary", 1L); } @Test @@ -98,4 +112,22 @@ class AccountMergeControllerTest { verify(accountMergeService).confirm("usr_primary", 1L); } + + @Test + void cancel_usesAuthenticatedAccount() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_secondary", "secondary", "s@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + + mockMvc.perform(post("/api/v1/account/merge/cancel") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("Account merge cancelled")); + + verify(accountMergeService).cancel("usr_secondary", 1L); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java new file mode 100644 index 00000000..5278e320 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeFlowIntegrationTest.java @@ -0,0 +1,156 @@ +package com.iflytek.skillhub.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.merge.AccountMergeRequest; +import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Testcontainers +class AccountMergeFlowIntegrationTest { + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(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"); + registry.add("skillhub.builtin-skills.enabled", () -> false); + } + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private UserAccountRepository userAccountRepository; + @Autowired private LocalCredentialRepository localCredentialRepository; + @Autowired private AccountMergeRequestRepository mergeRequestRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void secondaryAccountMustApproveBeforeInitiatorCanMerge() throws Exception { + String suffix = UUID.randomUUID().toString(); + String primaryId = "merge-primary-" + suffix; + String secondaryId = "merge-secondary-" + suffix; + String secondaryUsername = "merge-" + suffix; + userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null)); + userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null)); + localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash")); + + String initiateResponse = mockMvc.perform(post("/api/v1/account/merge/initiate") + .with(authentication(auth(primaryId))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(java.util.Map.of("secondaryIdentifier", secondaryUsername)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.verificationToken").doesNotExist()) + .andReturn().getResponse().getContentAsString(); + long requestId = objectMapper.readTree(initiateResponse).path("data").path("mergeRequestId").asLong(); + String requestBody = objectMapper.writeValueAsString(java.util.Map.of("mergeRequestId", requestId)); + + mockMvc.perform(get("/api/v1/account/merge/requests/{id}", requestId) + .with(authentication(auth(primaryId)))) + .andExpect(status().isNotFound()); + mockMvc.perform(post("/api/v1/account/merge/verify") + .with(authentication(auth(primaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(requestBody)) + .andExpect(status().isNotFound()); + mockMvc.perform(get("/api/v1/account/merge/requests/{id}", requestId) + .with(authentication(auth(secondaryId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.primaryUserId").value(primaryId)); + mockMvc.perform(post("/api/v1/account/merge/verify") + .with(authentication(auth(secondaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(requestBody)) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(auth(secondaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(requestBody)) + .andExpect(status().isNotFound()); + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(auth(primaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(requestBody)) + .andExpect(status().isOk()); + + assertThat(userAccountRepository.findById(secondaryId).orElseThrow().getStatus()).isEqualTo(UserStatus.MERGED); + assertThat(localCredentialRepository.findByUsernameIgnoreCase(secondaryUsername).orElseThrow().getUserId()) + .isEqualTo(primaryId); + } + + @Test + void expiredRequestCanBeReplacedUnderThePartialUniqueIndex() throws Exception { + String suffix = UUID.randomUUID().toString(); + String primaryId = "merge-primary-" + suffix; + String secondaryId = "merge-secondary-" + suffix; + String secondaryUsername = "merge-" + suffix; + userAccountRepository.save(new UserAccount(primaryId, "Primary", null, null)); + userAccountRepository.save(new UserAccount(secondaryId, "Secondary", null, null)); + localCredentialRepository.save(new LocalCredential(secondaryId, secondaryUsername, "hash")); + AccountMergeRequest expired = mergeRequestRepository.save(new AccountMergeRequest( + primaryId, secondaryId, null, Instant.now().minusSeconds(1))); + + Integer indexCount = jdbcTemplate.queryForObject( + "select count(*) from pg_indexes where indexname = 'idx_merge_secondary_pending'", Integer.class); + assertThat(indexCount).isEqualTo(1); + + String response = mockMvc.perform(post("/api/v1/account/merge/initiate") + .with(authentication(auth(primaryId))).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(java.util.Map.of("secondaryIdentifier", secondaryUsername)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.verificationToken").doesNotExist()) + .andReturn().getResponse().getContentAsString(); + long newRequestId = objectMapper.readTree(response).path("data").path("mergeRequestId").asLong(); + + assertThat(newRequestId).isNotEqualTo(expired.getId()); + assertThat(mergeRequestRepository.findById(expired.getId()).orElseThrow().getStatus()) + .isEqualTo(AccountMergeRequest.STATUS_CANCELLED); + assertThat(mergeRequestRepository.findById(newRequestId).orElseThrow().getStatus()) + .isEqualTo(AccountMergeRequest.STATUS_PENDING); + } + + private static UsernamePasswordAuthenticationToken auth(String userId) { + PlatformPrincipal principal = new PlatformPrincipal(userId, userId, null, "", "local", Set.of()); + return new UsernamePasswordAuthenticationToken(principal, null, List.of()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java index 8638cad5..e75b26ac 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java @@ -1,6 +1,10 @@ package com.iflytek.skillhub.auth.merge; +import jakarta.persistence.LockModeType; import java.util.Optional; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -10,7 +14,13 @@ import org.springframework.stereotype.Repository; @Repository public interface AccountMergeRequestRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findByIdAndPrimaryUserId(Long id, String primaryUserId); - boolean existsBySecondaryUserIdAndStatus(String secondaryUserId, String status); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select request from AccountMergeRequest request where request.id = :id") + Optional findLockedById(@Param("id") Long id); + + Optional findBySecondaryUserIdAndStatus(String secondaryUserId, String status); + } 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 20fc4080..c80b9de3 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 @@ -16,11 +16,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; -import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; import java.time.Instant; -import java.util.Base64; import java.util.Comparator; import java.util.HashSet; import java.util.List; @@ -28,7 +26,6 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; import org.springframework.http.HttpStatus; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,9 +49,7 @@ public class AccountMergeService { private final UserRoleBindingRepository userRoleBindingRepository; private final ApiTokenRepository apiTokenRepository; private final NamespaceMemberRepository namespaceMemberRepository; - private final PasswordEncoder passwordEncoder; private final Clock clock; - private final SecureRandom secureRandom = new SecureRandom(); public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, UserAccountRepository userAccountRepository, @@ -63,7 +58,6 @@ public class AccountMergeService { UserRoleBindingRepository userRoleBindingRepository, ApiTokenRepository apiTokenRepository, NamespaceMemberRepository namespaceMemberRepository, - PasswordEncoder passwordEncoder, Clock clock) { this.mergeRequestRepository = mergeRequestRepository; this.userAccountRepository = userAccountRepository; @@ -72,11 +66,12 @@ public class AccountMergeService { this.userRoleBindingRepository = userRoleBindingRepository; this.apiTokenRepository = apiTokenRepository; this.namespaceMemberRepository = namespaceMemberRepository; - this.passwordEncoder = passwordEncoder; this.clock = clock; } - public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, Instant expiresAt) {} + public record InitiationResult(Long mergeRequestId, String secondaryUserId, Instant expiresAt) {} + + public record ApprovalDetails(Long mergeRequestId, String primaryUserId, String primaryDisplayName, Instant expiresAt) {} @Transactional public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) { @@ -84,12 +79,15 @@ public class AccountMergeService { UserAccount secondaryUser = resolveSecondaryUser(secondaryIdentifier); validateMergePair(primaryUser, secondaryUser); - if (mergeRequestRepository.existsBySecondaryUserIdAndStatus( - secondaryUser.getId(), - AccountMergeRequest.STATUS_PENDING - )) { - throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists"); - } + mergeRequestRepository.findBySecondaryUserIdAndStatus(secondaryUser.getId(), AccountMergeRequest.STATUS_PENDING) + .ifPresent(existing -> { + if (existing.getTokenExpiresAt() != null && !existing.getTokenExpiresAt().isBefore(currentTime())) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists"); + } + existing.setStatus(AccountMergeRequest.STATUS_CANCELLED); + existing.setVerificationToken(null); + mergeRequestRepository.saveAndFlush(existing); + }); Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUser.getId()); @@ -97,38 +95,55 @@ public class AccountMergeService { throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); } - String rawToken = generateVerificationToken(); AccountMergeRequest request = new AccountMergeRequest( primaryUserId, secondaryUser.getId(), - passwordEncoder.encode(rawToken), + null, currentTime().plus(Duration.ofMinutes(30)) ); request = mergeRequestRepository.save(request); - return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt()); + return new InitiationResult(request.getId(), secondaryUser.getId(), request.getTokenExpiresAt()); + } + + @Transactional(readOnly = true) + public ApprovalDetails getApprovalDetails(String secondaryUserId, Long mergeRequestId) { + AccountMergeRequest request = loadPendingApproval(secondaryUserId, mergeRequestId); + UserAccount primaryUser = loadActiveUser(request.getPrimaryUserId()); + return new ApprovalDetails(request.getId(), primaryUser.getId(), primaryUser.getDisplayName(), request.getTokenExpiresAt()); } @Transactional - public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) { - AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + public void verify(String secondaryUserId, Long mergeRequestId) { + AccountMergeRequest request = loadPendingApproval(secondaryUserId, mergeRequestId, true); + loadActiveUser(request.getPrimaryUserId()); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + request.setTokenExpiresAt(currentTime().plus(Duration.ofMinutes(30))); + request.setVerificationToken(null); + mergeRequestRepository.save(request); + } + + private AccountMergeRequest loadPendingApproval(String secondaryUserId, Long mergeRequestId) { + return loadPendingApproval(secondaryUserId, mergeRequestId, false); + } + + private AccountMergeRequest loadPendingApproval(String secondaryUserId, Long mergeRequestId, boolean lock) { + AccountMergeRequest request = (lock + ? mergeRequestRepository.findLockedById(mergeRequestId) + : mergeRequestRepository.findById(mergeRequestId)) .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!request.getSecondaryUserId().equals(secondaryUserId)) { + throw new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"); + } if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending"); } if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(currentTime())) { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired"); } - if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) { - throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken"); - } - - loadActiveUser(primaryUserId); - UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); - validateMergePair(loadActiveUser(primaryUserId), secondaryUser); - - request.setStatus(AccountMergeRequest.STATUS_VERIFIED); - mergeRequestRepository.save(request); + UserAccount secondaryUser = loadActiveUser(secondaryUserId); + UserAccount primaryUser = loadActiveUser(request.getPrimaryUserId()); + validateMergePair(primaryUser, secondaryUser); + return request; } @Transactional @@ -138,6 +153,9 @@ public class AccountMergeService { if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified"); } + if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(currentTime())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired"); + } UserAccount primaryUser = loadActiveUser(primaryUserId); UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) @@ -166,6 +184,23 @@ public class AccountMergeService { mergeRequestRepository.save(request); } + @Transactional + public void cancel(String actorUserId, Long mergeRequestId) { + AccountMergeRequest request = mergeRequestRepository.findLockedById(mergeRequestId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!actorUserId.equals(request.getPrimaryUserId()) && !actorUserId.equals(request.getSecondaryUserId())) { + throw new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound"); + } + if (AccountMergeRequest.STATUS_COMPLETED.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending"); + } + if (!AccountMergeRequest.STATUS_CANCELLED.equals(request.getStatus())) { + request.setStatus(AccountMergeRequest.STATUS_CANCELLED); + request.setVerificationToken(null); + mergeRequestRepository.save(request); + } + } + private UserAccount resolveSecondaryUser(String identifier) { String normalized = identifier == null ? "" : identifier.trim(); if (normalized.isBlank()) { @@ -276,12 +311,6 @@ public class AccountMergeService { }); } - private String generateVerificationToken() { - byte[] tokenBytes = new byte[24]; - secureRandom.nextBytes(tokenBytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); - } - private Instant currentTime() { return Instant.now(clock); } 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 d882386d..070c9073 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 @@ -34,7 +34,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; @ExtendWith(MockitoExtension.class) class AccountMergeServiceTest { @@ -53,9 +52,6 @@ class AccountMergeServiceTest { private ApiTokenRepository apiTokenRepository; @Mock private NamespaceMemberRepository namespaceMemberRepository; - @Mock - private PasswordEncoder passwordEncoder; - private AccountMergeService service; private Clock clock; @@ -70,7 +66,6 @@ class AccountMergeServiceTest { userRoleBindingRepository, apiTokenRepository, namespaceMemberRepository, - passwordEncoder, clock ); } @@ -83,39 +78,90 @@ class AccountMergeServiceTest { given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(secondaryCredential)); given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); - given(mergeRequestRepository.existsBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING)) - .willReturn(false); + given(mergeRequestRepository.findBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING)) + .willReturn(Optional.empty()); given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(secondaryCredential)); - given(passwordEncoder.encode(any())).willReturn("encoded-token"); given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); var result = service.initiate("usr_primary", "secondary"); assertThat(result.secondaryUserId()).isEqualTo("usr_secondary"); - assertThat(result.verificationToken()).isNotBlank(); assertThat(result.expiresAt()).isEqualTo(Instant.parse("2026-03-18T00:30:00Z")); - verify(mergeRequestRepository).save(any(AccountMergeRequest.class)); + org.mockito.ArgumentCaptor saved = org.mockito.ArgumentCaptor.forClass(AccountMergeRequest.class); + verify(mergeRequestRepository).save(saved.capture()); + assertThat(saved.getValue().getVerificationToken()).isNull(); } @Test - void verify_marksRequestVerifiedWhenTokenMatches() throws Exception { + void initiate_cancelsExpiredPendingRequestBeforeRetry() throws Exception { + UserAccount primary = new UserAccount("usr_primary", "primary", null, null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", null, null); + LocalCredential credential = new LocalCredential("usr_secondary", "secondary", "hash"); + AccountMergeRequest expired = request("usr_primary", "usr_secondary", null); + expired.setTokenExpiresAt(Instant.parse("2026-03-17T23:59:00Z")); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(mergeRequestRepository.findBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING)) + .willReturn(Optional.of(expired)); + given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(credential)); + given(mergeRequestRepository.saveAndFlush(expired)).willReturn(expired); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + service.initiate("usr_primary", "secondary"); + + assertThat(expired.getStatus()).isEqualTo(AccountMergeRequest.STATUS_CANCELLED); + verify(mergeRequestRepository).saveAndFlush(expired); + } + + @Test + void verify_marksRequestVerifiedForSecondaryAccount() throws Exception { UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); - given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request)); given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); - given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true); given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); - service.verify("usr_primary", 7L, "raw-token"); + service.verify("usr_secondary", 7L); assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_VERIFIED); + assertThat(request.getTokenExpiresAt()).isEqualTo(Instant.parse("2026-03-18T00:30:00Z")); verify(mergeRequestRepository).save(request); } + @Test + void verify_rejectsInitiatorEvenWhenTheyKnowTheRequestId() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); + given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request)); + + assertThatThrownBy(() -> service.verify("usr_primary", 7L)) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.merge.requestNotFound"); + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_PENDING); + } + + @Test + void approvalDetails_showsDestinationOnlyToSecondaryAccount() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", null); + given(mergeRequestRepository.findById(7L)).willReturn(Optional.of(request)); + given(userAccountRepository.findById("usr_primary")) + .willReturn(Optional.of(new UserAccount("usr_primary", "Primary Name", null, null))); + given(userAccountRepository.findById("usr_secondary")) + .willReturn(Optional.of(new UserAccount("usr_secondary", "Secondary Name", null, null))); + + var details = service.getApprovalDetails("usr_secondary", 7L); + assertThat(details.primaryUserId()).isEqualTo("usr_primary"); + assertThat(details.primaryDisplayName()).isEqualTo("Primary Name"); + assertThatThrownBy(() -> service.getApprovalDetails("usr_other", 7L)) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.merge.requestNotFound"); + } + @Test void confirm_migratesBindingsRolesTokensAndMemberships() throws Exception { UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); @@ -158,18 +204,56 @@ class AccountMergeServiceTest { } @Test - void verify_rejectsInvalidToken() throws Exception { - AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); - given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); - given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false); + void verify_rejectsExpiredApproval() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", null); + request.setTokenExpiresAt(Instant.parse("2026-03-17T23:59:00Z")); + given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request)); - assertThatThrownBy(() -> service.verify("usr_primary", 7L, "bad-token")) + assertThatThrownBy(() -> service.verify("usr_secondary", 7L)) .isInstanceOf(AuthFlowException.class) - .hasMessageContaining("error.auth.merge.invalidToken"); + .hasMessageContaining("error.auth.merge.tokenExpired"); verify(identityBindingRepository, never()).saveAll(any()); } + @Test + void confirm_rejectsExpiredApprovalWithoutMigratingData() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", null); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + request.setTokenExpiresAt(Instant.parse("2026-03-17T23:59:00Z")); + given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); + + assertThatThrownBy(() -> service.confirm("usr_primary", 7L)) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.merge.tokenExpired"); + verify(identityBindingRepository, never()).saveAll(any()); + } + + @Test + void cancel_allowsSecondaryAccountToRevokeApproval() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", null); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request)); + given(mergeRequestRepository.save(request)).willReturn(request); + + service.cancel("usr_secondary", 7L); + + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_CANCELLED); + verify(mergeRequestRepository).save(request); + } + + @Test + void cancel_rejectsUnrelatedAccountWithoutChangingState() throws Exception { + AccountMergeRequest request = request("usr_primary", "usr_secondary", null); + given(mergeRequestRepository.findLockedById(7L)).willReturn(Optional.of(request)); + + assertThatThrownBy(() -> service.cancel("usr_other", 7L)) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.merge.requestNotFound"); + assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_PENDING); + verify(mergeRequestRepository, never()).save(any()); + } + private AccountMergeRequest request(String primaryUserId, String secondaryUserId, String token) throws Exception { AccountMergeRequest request = new AccountMergeRequest( primaryUserId, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a724997e..aeef6d67 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { LocalRegisterRequest, MergeInitiateRequest, MergeInitiateResponse, + MergeApprovalDetails, MergeVerifyRequest, ReviewSkillDetail, ReviewProgressPage, @@ -458,6 +459,10 @@ export const accountApi = { }) }, + async getMergeApprovalDetails(mergeRequestId: number): Promise { + return fetchJson(`/api/v1/account/merge/requests/${mergeRequestId}`) + }, + async verifyMerge(request: MergeVerifyRequest): Promise { await fetchJson('/api/v1/account/merge/verify', { method: 'POST', @@ -468,6 +473,16 @@ export const accountApi = { }) }, + async cancelMerge(request: MergeConfirmRequest): Promise { + await fetchJson('/api/v1/account/merge/cancel', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async confirmMerge(request: MergeConfirmRequest): Promise { await fetchJson('/api/v1/account/merge/confirm', { method: 'POST', diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 4fcc42fb..0103827d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2573,6 +2573,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/account/merge/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["cancel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cli/v1/skills/{namespace}/publish": { parameters: { query?: never; @@ -4609,6 +4625,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/account/merge/requests/{mergeRequestId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["approvalDetails"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download": { parameters: { query?: never; @@ -5866,7 +5898,6 @@ export interface components { MergeVerifyRequest: { /** Format: int64 */ mergeRequestId: number; - verificationToken: string; }; MergeInitiateRequest: { secondaryIdentifier: string; @@ -5884,13 +5915,16 @@ export interface components { /** Format: int64 */ mergeRequestId?: number; secondaryUserId?: string; - verificationToken?: string; expiresAt?: string; }; ConfirmMergeRequest: { /** Format: int64 */ mergeRequestId: number; }; + CancelMergeRequest: { + /** Format: int64 */ + mergeRequestId: number; + }; ApiResponseCliPublishResponse: { /** Format: int32 */ code?: number; @@ -7468,6 +7502,22 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseMergeApprovalDetailsResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MergeApprovalDetailsResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + MergeApprovalDetailsResponse: { + /** Format: int64 */ + mergeRequestId?: number; + primaryUserId?: string; + primaryDisplayName?: string; + expiresAt?: string; + }; ApiResponseCliResolveResponse: { /** Format: int32 */ code?: number; @@ -12401,6 +12451,30 @@ export interface operations { }; }; }; + cancel: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CancelMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; publish_3: { parameters: { query?: never; @@ -15528,6 +15602,28 @@ export interface operations { }; }; }; + approvalDetails: { + parameters: { + query?: never; + header?: never; + path: { + mergeRequestId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMergeApprovalDetailsResponse"]; + }; + }; + }; + }; downloadVersion_2: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bbc37549..b9978ef2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -84,13 +84,18 @@ export interface MergeInitiateRequest { export interface MergeInitiateResponse { mergeRequestId: number secondaryUserId: string - verificationToken: string + expiresAt: string +} + +export interface MergeApprovalDetails { + mergeRequestId: number + primaryUserId: string + primaryDisplayName: string expiresAt: string } export interface MergeVerifyRequest { mergeRequestId: number - verificationToken: string } export interface MergeConfirmRequest { diff --git a/web/src/features/auth/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts index c4f75fe8..b5311c63 100644 --- a/web/src/features/auth/use-account-merge.ts +++ b/web/src/features/auth/use-account-merge.ts @@ -8,12 +8,24 @@ export function useInitiateAccountMerge() { }) } +export function useInspectAccountMerge() { + return useMutation({ + mutationFn: (mergeRequestId: number) => accountApi.getMergeApprovalDetails(mergeRequestId), + }) +} + export function useVerifyAccountMerge() { return useMutation({ mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request), }) } +export function useCancelAccountMerge() { + return useMutation({ + mutationFn: (request: MergeConfirmRequest) => accountApi.cancelMerge(request), + }) +} + export function useConfirmAccountMerge() { const queryClient = useQueryClient() return useMutation({ diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c506f0be..0d493073 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -486,6 +486,7 @@ "account": "Account", "profile": "Profile", "security": "Security", + "accounts": "Account Merge", "notifications": "Notifications", "skillsAndData": "Skills & Data", "mySkills": "My Skills", @@ -992,20 +993,26 @@ "secondaryPlaceholder": "e.g.: other_user or github:123456", "initiating": "Initiating...", "initiate": "Initiate Merge", - "initiateSuccess": "Merge request created, secondary={{secondaryUserId}}", + "initiateSuccess": "Merge request created. Save the request ID, then sign in to {{secondaryUserId}} in another browser to approve it.", "initiateError": "Failed to initiate merge", - "verifyTitle": "Verify & Complete Merge", - "verifyDesc": "Complete token verification first, then confirm to execute data migration.", + "verifyTitle": "Approve from the second account", + "verifyDesc": "Sign in to the second account in another browser, enter the request ID, review the destination, then approve.", "mergeRequestId": "Merge Request ID", - "verificationToken": "Verification Token", + "inspect": "View destination", + "inspectError": "Unable to view merge request", + "destinationWarning": "Approval will transfer this account's sign-in methods, API tokens and permissions to {{name}} ({{id}}), then deactivate this account. Confirm that you own both accounts.", "verifying": "Verifying...", - "verify": "Complete Merge", - "verifySuccess": "Verification successful, confirm to execute the merge", + "verify": "Approve merge", + "verifySuccess": "Approved. Return to the initiating account and confirm within 30 minutes.", "verifyError": "Merge verification failed", "confirming": "Confirming...", + "confirmDesc": "Only the initiating account can confirm the merge. Return to it after approval.", "confirm": "Confirm & Complete Merge", "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "confirmError": "Merge confirmation failed", + "cancel": "Cancel merge request", + "cancelSuccess": "Merge request cancelled", + "cancelError": "Failed to cancel merge request" }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index b63ec012..1b844de3 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -486,6 +486,7 @@ "account": "Аккаунт", "profile": "Профиль", "security": "Безопасность", + "accounts": "Объединение аккаунтов", "notifications": "Уведомления", "skillsAndData": "Скиллы и данные", "mySkills": "Мои скиллы", @@ -1057,20 +1058,26 @@ "secondaryPlaceholder": "например: other_user или github:123456", "initiating": "Инициализация...", "initiate": "Начать объединение", - "initiateSuccess": "Запрос на объединение создан, secondary={{secondaryUserId}}", + "initiateSuccess": "Запрос создан. Сохраните его ID и войдите в аккаунт {{secondaryUserId}} в другом браузере для подтверждения.", "initiateError": "Не удалось начать объединение", - "verifyTitle": "Проверить и завершить объединение", - "verifyDesc": "Сначала завершите проверку токена, затем подтвердите выполнение миграции данных.", + "verifyTitle": "Подтверждение со второго аккаунта", + "verifyDesc": "Войдите во второй аккаунт в другом браузере, введите ID запроса и проверьте целевой аккаунт.", "mergeRequestId": "ID запроса на объединение", - "verificationToken": "Токен проверки", + "inspect": "Показать целевой аккаунт", + "inspectError": "Не удалось открыть запрос", + "destinationWarning": "Подтверждение перенесёт способы входа, API-токены и права этого аккаунта в {{name}} ({{id}}), затем отключит текущий аккаунт. Убедитесь, что оба аккаунта принадлежат вам.", "verifying": "Проверка...", - "verify": "Завершить объединение", - "verifySuccess": "Проверка успешна, подтвердите выполнение объединения", + "verify": "Подтвердить объединение", + "verifySuccess": "Подтверждено. Вернитесь в аккаунт, создавший запрос, и завершите объединение в течение 30 минут.", "verifyError": "Проверка объединения не удалась", "confirming": "Подтверждение...", + "confirmDesc": "Завершить объединение может только аккаунт, создавший запрос. Вернитесь в него после подтверждения.", "confirm": "Подтвердить и завершить объединение", "confirmSuccess": "Объединение учётных записей завершено", - "confirmError": "Подтверждение объединения не удалось" + "confirmError": "Подтверждение объединения не удалось", + "cancel": "Отменить запрос", + "cancelSuccess": "Запрос отменён", + "cancelError": "Не удалось отменить запрос" }, "namespace": { "notFound": "Пространство имён не найдено", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 92b6fb79..15b25cac 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -486,6 +486,7 @@ "account": "账号管理", "profile": "个人设置", "security": "安全设置", + "accounts": "账号合并", "notifications": "通知设置", "skillsAndData": "技能与数据", "mySkills": "我的技能", @@ -992,20 +993,26 @@ "secondaryPlaceholder": "例如:other_user 或 github:123456", "initiating": "发起中...", "initiate": "发起合并", - "initiateSuccess": "已创建合并请求,secondary={{secondaryUserId}}", + "initiateSuccess": "已创建合并请求。请记录请求 ID,并在另一个浏览器登录账号 {{secondaryUserId}} 后批准。", "initiateError": "发起合并失败", - "verifyTitle": "验证并完成合并", - "verifyDesc": "先完成 token 验证,再单独确认执行数据迁移。", - "mergeRequestId": "Merge Request ID", - "verificationToken": "Verification Token", + "verifyTitle": "由待合并账号批准", + "verifyDesc": "请在另一个浏览器登录待合并账号,输入请求 ID,核对合并目标后批准。", + "mergeRequestId": "合并请求 ID", + "inspect": "查看合并目标", + "inspectError": "无法查看合并请求", + "destinationWarning": "批准后,当前账号的登录方式、API Token 和权限将转移给 {{name}}({{id}});当前账号将停用。请确认这是你自己的账号。", "verifying": "验证中...", - "verify": "完成合并", - "verifySuccess": "验证成功,确认后将执行正式合并", + "verify": "批准合并", + "verifySuccess": "已批准。请在 30 分钟内回到发起合并的账号完成确认。", "verifyError": "验证合并失败", "confirming": "确认中...", + "confirmDesc": "只有发起合并的账号能完成最后确认。批准后请回到该账号操作。", "confirm": "确认并完成合并", "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "confirmError": "确认合并失败", + "cancel": "撤销合并请求", + "cancelSuccess": "合并请求已撤销", + "cancelError": "撤销合并失败" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.test.ts b/web/src/pages/settings/accounts.test.ts index 3b62220a..b2eef67a 100644 --- a/web/src/pages/settings/accounts.test.ts +++ b/web/src/pages/settings/accounts.test.ts @@ -12,7 +12,9 @@ vi.mock('react-i18next', async () => { vi.mock('@/features/auth/use-account-merge', () => ({ useInitiateAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }), + useInspectAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false, data: null, reset: vi.fn() }), useVerifyAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCancelAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }), useConfirmAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }), })) diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a933..8d14beb4 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { useCancelAccountMerge, useConfirmAccountMerge, useInitiateAccountMerge, useInspectAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' import { truncateErrorMessage } from '@/shared/lib/error-display' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' @@ -9,22 +9,24 @@ import { Input } from '@/shared/ui/input' /** * Account linking settings page for the multi-step account merge workflow. * The route intentionally keeps all three steps visible because operators often - * need to paste ids or tokens across systems while completing the merge. + * need to carry a request id between their two authenticated accounts. */ export function AccountSettingsPage() { const { t } = useTranslation() const [secondaryIdentifier, setSecondaryIdentifier] = useState('') const [mergeRequestId, setMergeRequestId] = useState('') - const [verificationToken, setVerificationToken] = useState('') const [statusMessage, setStatusMessage] = useState('') const initiateMutation = useInitiateAccountMerge() + const inspectMutation = useInspectAccountMerge() const verifyMutation = useVerifyAccountMerge() + const cancelMutation = useCancelAccountMerge() const confirmMutation = useConfirmAccountMerge() + const validMergeRequestId = Number.isSafeInteger(Number(mergeRequestId)) && Number(mergeRequestId) > 0 + const approvalDetails = inspectMutation.data?.mergeRequestId === Number(mergeRequestId) ? inspectMutation.data : null /** - * Starts the merge flow and surfaces the request id plus verification token - * returned by the backend for the following steps. + * Starts the merge flow and surfaces the request id for the second account. */ async function handleInitiate(event: React.FormEvent) { event.preventDefault() @@ -32,7 +34,7 @@ export function AccountSettingsPage() { try { const result = await initiateMutation.mutateAsync({ secondaryIdentifier }) setMergeRequestId(String(result.mergeRequestId)) - setVerificationToken(result.verificationToken) + inspectMutation.reset() setStatusMessage(t('accounts.initiateSuccess', { secondaryUserId: result.secondaryUserId })) } catch (error) { setStatusMessage( @@ -42,7 +44,22 @@ export function AccountSettingsPage() { } /** - * Verifies ownership of the secondary account before the final merge step. + * Loads the destination while signed in as the second account. + */ + async function handleInspect() { + setStatusMessage('') + inspectMutation.reset() + try { + await inspectMutation.mutateAsync(Number(mergeRequestId)) + } catch (error) { + setStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.inspectError')) ?? t('accounts.inspectError'), + ) + } + } + + /** + * Approves the merge only from the second account's authenticated session. */ async function handleVerify(event: React.FormEvent) { event.preventDefault() @@ -50,8 +67,8 @@ export function AccountSettingsPage() { try { await verifyMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId), - verificationToken, }) + inspectMutation.reset() setStatusMessage(t('accounts.verifySuccess')) } catch (error) { setStatusMessage( @@ -75,6 +92,19 @@ export function AccountSettingsPage() { } } + async function handleCancel() { + setStatusMessage('') + try { + await cancelMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId) }) + inspectMutation.reset() + setStatusMessage(t('accounts.cancelSuccess')) + } catch (error) { + setStatusMessage( + truncateErrorMessage(error instanceof Error ? error.message : t('accounts.cancelError')) ?? t('accounts.cancelError'), + ) + } + } + return (
@@ -112,25 +142,36 @@ export function AccountSettingsPage() { setMergeRequestId(event.target.value)} + onChange={(event) => { + setMergeRequestId(event.target.value) + inspectMutation.reset() + }} + inputMode="numeric" />
-
- - setVerificationToken(event.target.value)} - /> -
- + {approvalDetails ? ( +

+ {t('accounts.destinationWarning', { + name: approvalDetails.primaryDisplayName, + id: approvalDetails.primaryUserId, + })} +

+ ) : null} +
- +
{statusMessage ?

{statusMessage}

: null}