fix(auth): require secondary approval before account merge

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-08 12:03:55 +08:00
parent 735259728f
commit dc635a24d6
19 changed files with 655 additions and 118 deletions

View file

@ -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、合并角色取并集)
合并操作规则:

View file

@ -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<MergeApprovalDetailsResponse> 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<MessageResponse> 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<MessageResponse> 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) {}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record MergeApprovalDetailsResponse(
Long mergeRequestId,
String primaryUserId,
String primaryDisplayName,
String expiresAt
) {}

View file

@ -3,6 +3,5 @@ package com.iflytek.skillhub.dto;
public record MergeInitiateResponse(
Long mergeRequestId,
String secondaryUserId,
String verificationToken,
String expiresAt
) {}

View file

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

View file

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

View file

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

View file

@ -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<AccountMergeRequest, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<AccountMergeRequest> 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<AccountMergeRequest> findLockedById(@Param("id") Long id);
Optional<AccountMergeRequest> findBySecondaryUserIdAndStatus(String secondaryUserId, String status);
}

View file

@ -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<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
Optional<LocalCredential> 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);
}

View file

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

View file

@ -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<MergeApprovalDetails> {
return fetchJson<MergeApprovalDetails>(`/api/v1/account/merge/requests/${mergeRequestId}`)
},
async verifyMerge(request: MergeVerifyRequest): Promise<void> {
await fetchJson<void>('/api/v1/account/merge/verify', {
method: 'POST',
@ -468,6 +473,16 @@ export const accountApi = {
})
},
async cancelMerge(request: MergeConfirmRequest): Promise<void> {
await fetchJson<void>('/api/v1/account/merge/cancel', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async confirmMerge(request: MergeConfirmRequest): Promise<void> {
await fetchJson<void>('/api/v1/account/merge/confirm', {
method: 'POST',

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "Пространство имён не найдено",

View file

@ -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": "命名空间不存在",

View file

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

View file

@ -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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<div className="mx-auto max-w-3xl space-y-6">
<Card className="glass-strong">
@ -112,25 +142,36 @@ export function AccountSettingsPage() {
<Input
id="merge-request-id"
value={mergeRequestId}
onChange={(event) => setMergeRequestId(event.target.value)}
onChange={(event) => {
setMergeRequestId(event.target.value)
inspectMutation.reset()
}}
inputMode="numeric"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="merge-token">{t('accounts.verificationToken')}</label>
<Input
id="merge-token"
value={verificationToken}
onChange={(event) => setVerificationToken(event.target.value)}
/>
</div>
<Button type="submit" disabled={verifyMutation.isPending}>
<Button type="button" onClick={handleInspect} disabled={!validMergeRequestId || inspectMutation.isPending}>
{t('accounts.inspect')}
</Button>
{approvalDetails ? (
<p className="text-sm" role="status">
{t('accounts.destinationWarning', {
name: approvalDetails.primaryDisplayName,
id: approvalDetails.primaryUserId,
})}
</p>
) : null}
<Button type="submit" disabled={verifyMutation.isPending || !approvalDetails || !validMergeRequestId}>
{verifyMutation.isPending ? t('accounts.verifying') : t('accounts.verify')}
</Button>
</form>
<div className="mt-4">
<Button type="button" onClick={handleConfirm} disabled={confirmMutation.isPending || !mergeRequestId}>
<p className="mb-2 text-sm text-muted-foreground">{t('accounts.confirmDesc')}</p>
<Button type="button" onClick={handleConfirm} disabled={confirmMutation.isPending || !validMergeRequestId}>
{confirmMutation.isPending ? t('accounts.confirming') : t('accounts.confirm')}
</Button>
<Button type="button" variant="outline" onClick={handleCancel} disabled={cancelMutation.isPending || !validMergeRequestId}>
{t('accounts.cancel')}
</Button>
</div>
{statusMessage ? <p className="mt-4 text-sm text-muted-foreground">{statusMessage}</p> : null}
</CardContent>