From 9b3e93e84c0437d27f136d62b091e6d8d2a69f8a Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 27 Jul 2026 21:50:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/api/controller/UserController.java | 11 +++ .../dto/request/UserWithdrawalRequest.java | 6 ++ .../com/gachi/be/domain/user/entity/User.java | 8 +++ .../user/service/UserProfileService.java | 18 +++++ .../com/gachi/be/global/code/SuccessCode.java | 1 + .../UserControllerIntegrationTest.java | 70 +++++++++++++++++++ 6 files changed, 114 insertions(+) create mode 100644 src/main/java/com/gachi/be/domain/user/dto/request/UserWithdrawalRequest.java diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 4235680..415075b 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -13,6 +13,7 @@ import com.gachi.be.domain.user.dto.request.EmailChangeVerifyRequest; import com.gachi.be.domain.user.dto.request.PasswordChangeRequest; import com.gachi.be.domain.user.dto.request.ProfileUpdateRequest; +import com.gachi.be.domain.user.dto.request.UserWithdrawalRequest; import com.gachi.be.domain.user.dto.response.EmailChangeResponse; import com.gachi.be.domain.user.dto.response.ProfileUpdateResponse; import com.gachi.be.domain.user.dto.response.UserMeResponse; @@ -178,4 +179,14 @@ public ApiResponse changePassword( userProfileService.changePassword(user, request); return ApiResponse.success(SuccessCode.USER_PASSWORD_UPDATED, null); } + + @Operation(summary = "회원 탈퇴", description = "현재 비밀번호를 확인한 후 계정을 탈퇴 처리합니다.") + @DeleteMapping("/me") + public ApiResponse withdraw( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @RequestBody @Valid UserWithdrawalRequest request) { + User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); + userProfileService.withdraw(user, request); + return ApiResponse.success(SuccessCode.USER_WITHDRAWAL_SUCCESS, null); + } } diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/UserWithdrawalRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/UserWithdrawalRequest.java new file mode 100644 index 0000000..2c278d4 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/user/dto/request/UserWithdrawalRequest.java @@ -0,0 +1,6 @@ +package com.gachi.be.domain.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UserWithdrawalRequest(@NotBlank @Size(max = 100) String currentPassword) {} diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 4f9c26d..91e77e0 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -123,6 +123,14 @@ public boolean isActive() { return status == UserStatus.ACTIVE; } + public void withdraw(OffsetDateTime withdrawnAt) { + if (withdrawnAt == null) { + throw new IllegalArgumentException("withdrawnAt은 비어 있을 수 없습니다."); + } + this.status = UserStatus.WITHDRAWN; + this.deletedAt = withdrawnAt; + } + public void resetPassword(String passwordHash, OffsetDateTime passwordUpdatedAt) { if (passwordHash == null || passwordHash.isBlank()) { throw new IllegalArgumentException("passwordHash는 비어 있을 수 없습니다."); diff --git a/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java b/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java index c6f97a4..2ccb1f3 100644 --- a/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java +++ b/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java @@ -9,11 +9,14 @@ import com.gachi.be.domain.auth.service.EmailVerificationStore; import com.gachi.be.domain.auth.service.impl.NoopAuthMailService; import com.gachi.be.domain.auth.service.password.PasswordPolicyValidator; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; import com.gachi.be.domain.user.dto.request.EmailChangeCodeSendRequest; import com.gachi.be.domain.user.dto.request.EmailChangeRequest; import com.gachi.be.domain.user.dto.request.EmailChangeVerifyRequest; import com.gachi.be.domain.user.dto.request.PasswordChangeRequest; import com.gachi.be.domain.user.dto.request.ProfileUpdateRequest; +import com.gachi.be.domain.user.dto.request.UserWithdrawalRequest; import com.gachi.be.domain.user.dto.response.EmailChangeResponse; import com.gachi.be.domain.user.dto.response.ProfileUpdateResponse; import com.gachi.be.domain.user.entity.User; @@ -40,6 +43,7 @@ public class UserProfileService { private final UserRepository userRepository; private final AuthRefreshTokenRepository authRefreshTokenRepository; + private final PushDeviceTokenRepository pushDeviceTokenRepository; private final PasswordEncoder passwordEncoder; private final AuthMailService authMailService; private final EmailVerificationStore emailVerificationStore; @@ -87,6 +91,20 @@ public void changePassword(User user, PasswordChangeRequest request) { revokeActiveRefreshTokens(currentUser.getId()); } + @Transactional + public void withdraw(User user, UserWithdrawalRequest request) { + User currentUser = findActiveUserWithLock(user.getId()); + if (!passwordEncoder.matches(request.currentPassword(), currentUser.getPasswordHash())) { + throw new BusinessException(ErrorCode.AUTH_INVALID_CREDENTIALS); + } + + currentUser.withdraw(OffsetDateTime.now()); + revokeActiveRefreshTokens(currentUser.getId()); + pushDeviceTokenRepository + .findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(currentUser.getId()) + .forEach(PushDeviceToken::softDelete); + } + @Transactional public EmailSendResponse sendEmailChangeCode(User user, EmailChangeCodeSendRequest request) { User currentUser = findActiveUser(user.getId()); diff --git a/src/main/java/com/gachi/be/global/code/SuccessCode.java b/src/main/java/com/gachi/be/global/code/SuccessCode.java index ad3a09d..713b629 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -67,6 +67,7 @@ public enum SuccessCode { USER_EMAIL_CHANGE_VERIFIED(HttpStatus.OK, "USER2005", "이메일 변경 인증이 완료되었습니다."), USER_EMAIL_UPDATED(HttpStatus.OK, "USER2006", "이메일이 변경되었습니다."), USER_PASSWORD_UPDATED(HttpStatus.OK, "USER2007", "비밀번호가 변경되었습니다."), + USER_WITHDRAWAL_SUCCESS(HttpStatus.OK, "USER2008", "회원 탈퇴가 완료되었습니다."), NOTIFICATION_LIST_SUCCESS(HttpStatus.OK, "NOTI2001", "알림 목록 조회에 성공하였습니다."), NOTIFICATION_UNREAD_COUNT_SUCCESS(HttpStatus.OK, "NOTI2002", "미읽음 알림 수 조회에 성공하였습니다."), NOTIFICATION_READ_SUCCESS(HttpStatus.OK, "NOTI2003", "알림 읽음 처리에 성공하였습니다."), diff --git a/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java index be18a78..73155de 100644 --- a/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java @@ -1,6 +1,7 @@ package com.gachi.be.domain.user.api.controller; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -10,6 +11,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.auth.service.AuthMailService; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; import com.gachi.be.domain.user.entity.User; import com.gachi.be.domain.user.entity.enums.NotificationPreference; import com.gachi.be.domain.user.entity.enums.UserStatus; @@ -53,6 +57,7 @@ class UserControllerIntegrationTest { @Autowired private ObjectMapper objectMapper; @Autowired private WebApplicationContext webApplicationContext; @Autowired private UserRepository userRepository; + @Autowired private PushDeviceTokenRepository pushDeviceTokenRepository; @Autowired private PasswordEncoder passwordEncoder; @Autowired private CapturingAuthMailService capturingAuthMailService; @@ -233,6 +238,62 @@ void passwordChangeRejectsDangerousPasswordStrength() throws Exception { .andExpect(jsonPath("$.code").value("AUTH4009")); } + @Test + void withdrawalChangesAccountStatusAndInvalidatesTokens() throws Exception { + String loginId = "withdrawal_user"; + createUser(loginId, "withdrawal@gachi.com", "01010001012", UserStatus.ACTIVE); + User user = userRepository.findByLoginId(loginId).orElseThrow(); + JsonNode loginBody = login(loginId, "Policy12!"); + String accessToken = loginBody.path("result").path("accessToken").asText(); + String refreshToken = loginBody.path("result").path("refreshToken").asText(); + PushDeviceToken pushToken = + pushDeviceTokenRepository.saveAndFlush( + PushDeviceToken.builder() + .userId(user.getId()) + .platform(PushPlatform.ANDROID) + .token("withdrawal-push-token") + .tokenHash("a".repeat(64)) + .deviceId("withdrawal-device") + .appVersion("1.0.0") + .build()); + + withdraw(accessToken, "Policy12!") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("USER2008")); + + User withdrawnUser = userRepository.findById(user.getId()).orElseThrow(); + assertThat(withdrawnUser.getStatus()).isEqualTo(UserStatus.WITHDRAWN); + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + + PushDeviceToken deletedPushToken = + pushDeviceTokenRepository.findById(pushToken.getId()).orElseThrow(); + assertThat(deletedPushToken.isEnabled()).isFalse(); + assertThat(deletedPushToken.getDeletedAt()).isNotNull(); + + reissue(refreshToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH4014")); + mockMvc + .perform(get("/api/v1/users/me").header("Authorization", bearer(accessToken))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AUTH4031")); + } + + @Test + void withdrawalRejectsWrongCurrentPassword() throws Exception { + String loginId = "withdrawal_wrong_password"; + createUser(loginId, "withdrawal-wrong-password@gachi.com", "01010001013", UserStatus.ACTIVE); + String accessToken = loginAccessToken(loginId, "Policy12!"); + + withdraw(accessToken, "Wrong12!") + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH4011")); + + User user = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(user.getDeletedAt()).isNull(); + } + private org.springframework.test.web.servlet.ResultActions sendEmailChangeCode( String accessToken, String email, String currentPassword) throws Exception { return mockMvc.perform( @@ -280,6 +341,15 @@ private org.springframework.test.web.servlet.ResultActions changePassword( newPasswordConfirm)))); } + private org.springframework.test.web.servlet.ResultActions withdraw( + String accessToken, String currentPassword) throws Exception { + return mockMvc.perform( + delete("/api/v1/users/me") + .header("Authorization", bearer(accessToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("currentPassword", currentPassword)))); + } + private org.springframework.test.web.servlet.ResultActions reissue(String refreshToken) throws Exception { return mockMvc.perform( From 00076663e881b04ddfc003c4d81028055a59673c Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 27 Jul 2026 22:17:40 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/api/controller/UserController.java | 43 +------------------ .../user/service/UserProfileService.java | 37 ++++++++++++++++ .../UserControllerIntegrationTest.java | 41 ++++++++++++++++++ 3 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 415075b..54d5410 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -4,8 +4,6 @@ import com.gachi.be.domain.auth.service.AuthRateLimitService; import com.gachi.be.domain.auth.service.AuthenticatedUserResolver; import com.gachi.be.domain.auth.service.ClientIpExtractor; -import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; -import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import com.gachi.be.domain.user.dto.request.ChangeLanguageRequest; import com.gachi.be.domain.user.dto.request.ChangeNotificationRequest; import com.gachi.be.domain.user.dto.request.EmailChangeCodeSendRequest; @@ -18,22 +16,16 @@ import com.gachi.be.domain.user.dto.response.ProfileUpdateResponse; import com.gachi.be.domain.user.dto.response.UserMeResponse; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.domain.user.service.UserProfileService; import com.gachi.be.global.api.ApiResponse; import com.gachi.be.global.code.SuccessCode; import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import java.util.List; -import java.util.Objects; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; /** 로그인 사용자 기준 내 정보 조회 API를 제공한다. */ -@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/users") @@ -42,8 +34,6 @@ public class UserController { private final AuthRateLimitService authRateLimitService; private final ClientIpExtractor clientIpExtractor; private final UserProfileService userProfileService; - private final UserRepository userRepository; - private final NewsletterRepository newsletterRepository; @Operation( summary = "사용자 내 정보 조회", @@ -79,50 +69,21 @@ public ApiResponse getMyInfo( 마이페이지에서 내가 회원가입 시에 설정했떤 언어를 변경할 수 있습니다. 해당 언어를 변경한 뒤에 스캔된 문서들은 전부 해당 언어로 번역됩니다. """) @PatchMapping("/me/language") - @Transactional public ApiResponse changeLanguage( @RequestHeader(value = "Authorization", required = false) String authorizationHeader, @RequestBody @Valid ChangeLanguageRequest request) { - User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); - - String previousLanguage = user.getLanguageCode(); - String newLanguage = request.languageCode(); - - if (Objects.equals(previousLanguage, newLanguage)) { - return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); - } - - user.updateLanguage(newLanguage); - userRepository.save(user); - - // 진행 중인 파이프라인 FAILED 처리 - int cancelledCount = - newsletterRepository.cancelInProgressByUserId( - user.getId(), - List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), - NewsletterStatus.FAILED, - request.languageCode()); - - log.info( - "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", - user.getId(), - previousLanguage, - newLanguage, - cancelledCount); - + userProfileService.changeLanguage(user, request); return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); } @Operation(summary = "사용자 알림 설정 변경", description = "마이페이지에서 알림 수신 단계를 변경합니다.") @PatchMapping("/me/notification") - @Transactional public ApiResponse changeNotificationPreference( @RequestHeader(value = "Authorization", required = false) String authorizationHeader, @RequestBody @Valid ChangeNotificationRequest request) { User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); - user.updateNotificationPreference(request.notificationPreference()); - userRepository.save(user); + userProfileService.changeNotificationPreference(user, request); return ApiResponse.success(SuccessCode.USER_NOTIFICATION_UPDATED, null); } diff --git a/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java b/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java index 2ccb1f3..7f6589c 100644 --- a/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java +++ b/src/main/java/com/gachi/be/domain/user/service/UserProfileService.java @@ -9,8 +9,12 @@ import com.gachi.be.domain.auth.service.EmailVerificationStore; import com.gachi.be.domain.auth.service.impl.NoopAuthMailService; import com.gachi.be.domain.auth.service.password.PasswordPolicyValidator; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; +import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import com.gachi.be.domain.notification.entity.PushDeviceToken; import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.domain.user.dto.request.ChangeLanguageRequest; +import com.gachi.be.domain.user.dto.request.ChangeNotificationRequest; import com.gachi.be.domain.user.dto.request.EmailChangeCodeSendRequest; import com.gachi.be.domain.user.dto.request.EmailChangeRequest; import com.gachi.be.domain.user.dto.request.EmailChangeVerifyRequest; @@ -27,7 +31,9 @@ import com.gachi.be.global.exception.BusinessException; import com.gachi.be.global.exception.ExternalApiException; import java.time.OffsetDateTime; +import java.util.List; import java.util.Locale; +import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -43,6 +49,7 @@ public class UserProfileService { private final UserRepository userRepository; private final AuthRefreshTokenRepository authRefreshTokenRepository; + private final NewsletterRepository newsletterRepository; private final PushDeviceTokenRepository pushDeviceTokenRepository; private final PasswordEncoder passwordEncoder; private final AuthMailService authMailService; @@ -50,6 +57,36 @@ public class UserProfileService { private final AuthProperties authProperties; private final PasswordPolicyValidator passwordPolicyValidator; + @Transactional + public void changeLanguage(User user, ChangeLanguageRequest request) { + User currentUser = findActiveUserWithLock(user.getId()); + String previousLanguage = currentUser.getLanguageCode(); + String newLanguage = request.languageCode(); + if (Objects.equals(previousLanguage, newLanguage)) { + return; + } + + currentUser.updateLanguage(newLanguage); + int cancelledCount = + newsletterRepository.cancelInProgressByUserId( + currentUser.getId(), + List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), + NewsletterStatus.FAILED, + newLanguage); + log.info( + "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", + currentUser.getId(), + previousLanguage, + newLanguage, + cancelledCount); + } + + @Transactional + public void changeNotificationPreference(User user, ChangeNotificationRequest request) { + User currentUser = findActiveUserWithLock(user.getId()); + currentUser.updateNotificationPreference(request.notificationPreference()); + } + @Transactional public ProfileUpdateResponse updateProfile(User user, ProfileUpdateRequest request) { User currentUser = findActiveUserWithLock(user.getId()); diff --git a/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java index 73155de..97e71c6 100644 --- a/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/user/api/controller/UserControllerIntegrationTest.java @@ -90,6 +90,47 @@ void updateProfileChangesNameAndPhoneNumber() throws Exception { assertThat(updatedUser.getPhoneNumber()).isEqualTo("01022223333"); } + @Test + void changeLanguageUpdatesLockedActiveUser() throws Exception { + String loginId = "language_change_user"; + createUser(loginId, "language-change@gachi.com", "01010001014", UserStatus.ACTIVE); + String accessToken = loginAccessToken(loginId, "Policy12!"); + + mockMvc + .perform( + patch("/api/v1/users/me/language") + .header("Authorization", bearer(accessToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("languageCode", "US")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("USER2001")); + + User user = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(user.getLanguageCode()).isEqualTo("US"); + } + + @Test + void changeNotificationPreferenceUpdatesLockedActiveUser() throws Exception { + String loginId = "notification_change_user"; + createUser(loginId, "notification-change@gachi.com", "01010001015", UserStatus.ACTIVE); + String accessToken = loginAccessToken(loginId, "Policy12!"); + + mockMvc + .perform( + patch("/api/v1/users/me/notification") + .header("Authorization", bearer(accessToken)) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of("notificationPreference", NotificationPreference.OFF)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("USER2002")); + + User user = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(user.getNotificationPreference()).isEqualTo(NotificationPreference.OFF); + assertThat(user.isNotificationEnabled()).isFalse(); + } + @Test void emailChangeCompletesAndRevokesRefreshToken() throws Exception { String loginId = "email_change_user";