From 7652c809a1f9f5bd4d0f901f661e53d63c765881 Mon Sep 17 00:00:00 2001 From: Steven Bontenbal Date: Fri, 5 Jan 2024 21:26:14 +0100 Subject: [PATCH] Add profile editing --- .../configuration/ChoristerConfiguration.kt | 39 +++---- .../chorister/event/UserEventHandler.kt | 12 +- .../model/dto/ZitadelResourceDetails.kt | 10 ++ .../model/dto/ZitadelUserEmailGetResponse.kt | 11 ++ .../model/dto/ZitadelUserEmailPutRequest.kt | 6 + .../model/dto/ZitadelUsernamePutRequest.kt | 5 + .../chorister/service/UserService.kt | 19 ++- .../chorister/service/ZitadelUserService.kt | 91 +++++++++++++-- .../service/CategorisationServiceTests.kt | 14 +-- .../service/ZitadelUserServiceTests.kt | 108 ++++++++++++++++++ frontend/src/stores/userStore.js | 3 +- frontend/src/views/Profile.vue | 9 +- 12 files changed, 280 insertions(+), 47 deletions(-) create mode 100644 backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelResourceDetails.kt create mode 100644 backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailGetResponse.kt create mode 100644 backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailPutRequest.kt create mode 100644 backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUsernamePutRequest.kt create mode 100644 backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserServiceTests.kt diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/configuration/ChoristerConfiguration.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/configuration/ChoristerConfiguration.kt index f77e78e..fc472c8 100644 --- a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/configuration/ChoristerConfiguration.kt +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/configuration/ChoristerConfiguration.kt @@ -144,15 +144,6 @@ class ChoristerConfiguration( return buildOAuthClient(oauth2Client) } - /*@Bean - fun zitadelClient( - authorizedClientManager: OAuth2AuthorizedClientManager - ): WebClient { - val oauth2Client = ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager) - oauth2Client.setDefaultClientRegistrationId("zitadel") - return buildOAuthClient(oauth2Client) - }*/ - private fun buildOAuthClient(oauth2Client: ServletOAuth2AuthorizedClientExchangeFilterFunction) = WebClient.builder() .apply(oauth2Client.oauth2Configuration()) @@ -170,13 +161,6 @@ class ChoristerConfiguration( ) .build() - - @Bean - fun zitadelClientService( - zitadelConfiguration: ZitadelProperties, - oauthClientManager: OAuth2AuthorizedClientManager - ): UserAuthorizationService = ZitadelUserService(zitadelConfiguration, authorizedWebClient(oauthClientManager)) - @Bean fun registrationService( userRepository: UserRepository, @@ -185,17 +169,28 @@ class ChoristerConfiguration( userAuthorizationService: UserAuthorizationService, categorisationService: CategorisationService, userService: UserService - ): RegistrationService = RegistrationService(userRepository, choirRepository, inviteRepository, userAuthorizationService, categorisationService, userService) + ): RegistrationService = RegistrationService( + userRepository, + choirRepository, + inviteRepository, + userAuthorizationService, + categorisationService, + userService + ) @Bean - fun categorisationService(choristerProperties: ChoristerProperties, categoryRepository: CategoryRepository): CategorisationService = CategorisationService(choristerProperties, categoryRepository) + fun categorisationService( + choristerProperties: ChoristerProperties, + categoryRepository: CategoryRepository + ): CategorisationService = CategorisationService(choristerProperties, categoryRepository) @Bean - fun databaseInitializer(choirRepository: ChoirRepository, - userRepository: UserRepository, - songbookRepository: SongbookRepository, - songRepository: SongRepository + fun databaseInitializer( + choirRepository: ChoirRepository, + userRepository: UserRepository, + songbookRepository: SongbookRepository, + songRepository: SongRepository ) = ApplicationRunner { } diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/event/UserEventHandler.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/event/UserEventHandler.kt index 12c0b50..2abdd06 100644 --- a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/event/UserEventHandler.kt +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/event/UserEventHandler.kt @@ -2,19 +2,29 @@ package nl.stevenbontenbal.chorister.event import nl.stevenbontenbal.chorister.model.entities.User import nl.stevenbontenbal.chorister.repository.ChoirRepository +import nl.stevenbontenbal.chorister.service.UserService import org.springframework.data.rest.core.annotation.HandleBeforeCreate +import org.springframework.data.rest.core.annotation.HandleBeforeSave import org.springframework.data.rest.core.annotation.RepositoryEventHandler import org.springframework.stereotype.Component @Component @RepositoryEventHandler(User::class) -class UserEventHandler(private val choirRepository: ChoirRepository) { +class UserEventHandler( + private val choirRepository: ChoirRepository, + private val userService: UserService + ) { @HandleBeforeCreate fun handleUserCreate(u: User) { saveChoir(u) } + @HandleBeforeSave + fun handleUserSave(u: User) { + userService.setUserEmail(u) + } + fun saveChoir(u: User) { choirRepository.save(u.choir ?: return) } diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelResourceDetails.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelResourceDetails.kt new file mode 100644 index 0000000..d6184c2 --- /dev/null +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelResourceDetails.kt @@ -0,0 +1,10 @@ +package nl.stevenbontenbal.chorister.model.dto + +import java.util.* + +data class ZitadelResourceDetails( + var sequence: Int?, + var creationDate: Date?, + var changeDate: Date?, + var resourceOwner: String? +) \ No newline at end of file diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailGetResponse.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailGetResponse.kt new file mode 100644 index 0000000..e263583 --- /dev/null +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailGetResponse.kt @@ -0,0 +1,11 @@ +package nl.stevenbontenbal.chorister.model.dto + +data class ZitadelUserEmailGetResponse( + var details: ZitadelResourceDetails, + var email: EmailInfo? +) + +data class EmailInfo( + var email: String, + var isEmailVerified: Boolean +) \ No newline at end of file diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailPutRequest.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailPutRequest.kt new file mode 100644 index 0000000..526926a --- /dev/null +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUserEmailPutRequest.kt @@ -0,0 +1,6 @@ +package nl.stevenbontenbal.chorister.model.dto + +data class ZitadelUserEmailPutRequest( + var email: String, + var isEmailVerified: Boolean +) diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUsernamePutRequest.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUsernamePutRequest.kt new file mode 100644 index 0000000..1589b15 --- /dev/null +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/model/dto/ZitadelUsernamePutRequest.kt @@ -0,0 +1,5 @@ +package nl.stevenbontenbal.chorister.model.dto + +data class ZitadelUsernamePutRequest( + var userName: String +) \ No newline at end of file diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/UserService.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/UserService.kt index b07c6da..42e9f60 100644 --- a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/UserService.kt +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/UserService.kt @@ -12,11 +12,22 @@ import org.springframework.stereotype.Component @Component @Lazy @DependsOn("accessPermissionEvaluator") -class UserService(private val userRepository: UserRepository) { - +class UserService(private val userRepository: UserRepository, private val zitadelUserService: ZitadelUserService) { fun getCurrentUser(): User { - val jwt = SecurityContextHolder.getContext().authentication.principal as Jwt - val userId = jwt.subject + val userId = getUserId() return userRepository.findByZitadelId(userId) ?: throw AuthException("User with Zitadel ID $userId not found.") } + + private fun getUserId(): String { + val jwt = SecurityContextHolder.getContext().authentication.principal as Jwt + return jwt.subject + } + + fun setUserEmail(user: User) { + val userId = getUserId() + val newEmail = user.email + if (newEmail != null) { + zitadelUserService.setEmailAddress(userId, newEmail) + } + } } \ No newline at end of file diff --git a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserService.kt b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserService.kt index 7cb98b4..b21ca01 100644 --- a/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserService.kt +++ b/backend/src/main/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserService.kt @@ -8,13 +8,14 @@ import nl.stevenbontenbal.chorister.model.dto.* import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId +import org.springframework.stereotype.Component import org.springframework.web.reactive.function.BodyInserters import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.util.UriComponentsBuilder import reactor.core.publisher.Mono +import java.net.URI +@Component class ZitadelUserService( private val zitadelConfiguration: ZitadelProperties, private val webClient: WebClient @@ -23,15 +24,8 @@ class ZitadelUserService( val request = createUserPostRequest(registrationRequest) val response = webClient .post() - .uri( - UriComponentsBuilder - .fromHttpUrl(zitadelConfiguration.baseUrl) - .path("/users/human/_import") - .build() - .toUri() - ) + .uri(createUri("/users/human/_import")) .headers { it.setBearerAuth(zitadelConfiguration.adminAccessToken) } -// .attributes(clientRegistrationId("zitadel")) .contentType(MediaType.APPLICATION_JSON) .body(BodyInserters.fromValue(request)) .retrieve() @@ -51,6 +45,74 @@ class ZitadelUserService( return Result.success(response?.body?.userId) } + fun setEmailAddress(userId: String, email: String): Result { + val response = getCurrentEmail(userId) + if (response?.body?.email?.email == email) + return Result.success(email) + + val emailRequest = ZitadelUserEmailPutRequest(email, false) + changeEmail(userId, emailRequest) + + val userNameRequest = ZitadelUsernamePutRequest(email) + changeUserName(userId, userNameRequest) + + return Result.success(email) + } + + private fun changeUserName( + userId: String, + request: ZitadelUsernamePutRequest + ) { + webClient + .put() + .uri(createUri("/users/$userId/username")) + .headers { it.setBearerAuth(zitadelConfiguration.adminAccessToken) } + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(request)) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError) { + it.bodyToMono(ZitadelError::class.java).flatMap { err -> + Mono.error(InvalidInputException("Error while updating user: ${err.message}")) + } + } + .onStatus(HttpStatusCode::is5xxServerError) { + Mono.error(RuntimeException("Zitadel server error: ${it.statusCode()}")) + } + .toEntity(ZitadelResourceDetails::class.java) + .block() + } + + private fun changeEmail( + userId: String, + request: ZitadelUserEmailPutRequest + ) { + webClient + .put() + .uri(createUri("/users/$userId/email")) + .headers { it.setBearerAuth(zitadelConfiguration.adminAccessToken) } + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(request)) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError) { + it.bodyToMono(ZitadelError::class.java).flatMap { err -> + Mono.error(InvalidInputException("Error while updating user: ${err.message}")) + } + } + .onStatus(HttpStatusCode::is5xxServerError) { + Mono.error(RuntimeException("Zitadel server error: ${it.statusCode()}")) + } + .toEntity(ZitadelUserEmailGetResponse::class.java) + .block() + } + + private fun getCurrentEmail(userId: String) = webClient + .get() + .uri(createUri("/users/$userId/email")) + .headers { it.setBearerAuth(zitadelConfiguration.adminAccessToken) } + .retrieve() + .toEntity(ZitadelUserEmailGetResponse::class.java) + .block() + private fun createUserPostRequest(registrationRequest: RegistrationRequest): ZitadelUserPostRequest = ZitadelUserPostRequest( userName = registrationRequest.email, @@ -59,9 +121,16 @@ class ZitadelUserService( ), password = registrationRequest.password, profile = Profile( - firstName = registrationRequest.displayName.substring(0,1), + firstName = registrationRequest.displayName.substring(0, 1), lastName = registrationRequest.displayName.substring(1), displayName = registrationRequest.displayName ) ) + + private fun createUri(path: String): URI = + UriComponentsBuilder + .fromHttpUrl(zitadelConfiguration.baseUrl) + .path(path) + .build() + .toUri() } \ No newline at end of file diff --git a/backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/CategorisationServiceTests.kt b/backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/CategorisationServiceTests.kt index 50ff710..f082152 100644 --- a/backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/CategorisationServiceTests.kt +++ b/backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/CategorisationServiceTests.kt @@ -1,8 +1,6 @@ package nl.stevenbontenbal.chorister.service -import io.mockk.Answer import io.mockk.every -import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.slot import nl.stevenbontenbal.chorister.configuration.ChoristerConfiguration @@ -14,19 +12,22 @@ import nl.stevenbontenbal.chorister.repository.CategoryRepository import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.runner.RunWith -import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.junit4.SpringRunner @RunWith(SpringRunner::class) @ContextConfiguration(classes = [ChoristerConfiguration::class]) -class CategorisationServiceTests @Autowired constructor( -) { +class CategorisationServiceTests { @Test fun `when createDefaultCategories then exist categories`() { // Arrange - val properties = ChoristerProperties("", "", "", ChoristerProperties.DefaultCategories(listOf("Test1", "Test2"), listOf("Test3"))) + val properties = ChoristerProperties( + "", + "", + "", + ChoristerProperties.DefaultCategories(listOf("Test1", "Test2"), listOf("Test3")) + ) val choir = Choir.create(null) val slot = slot>() val categoryRepository: CategoryRepository = mockk(relaxed = true) @@ -38,5 +39,4 @@ class CategorisationServiceTests @Autowired constructor( assertThat(slot.captured.iterator().hasNext()).isTrue } - } \ No newline at end of file diff --git a/backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserServiceTests.kt b/backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserServiceTests.kt new file mode 100644 index 0000000..59f2235 --- /dev/null +++ b/backend/src/test/kotlin/nl/stevenbontenbal/chorister/service/ZitadelUserServiceTests.kt @@ -0,0 +1,108 @@ +package nl.stevenbontenbal.chorister.service + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import nl.stevenbontenbal.chorister.configuration.ZitadelProperties +import nl.stevenbontenbal.chorister.model.dto.AcceptInviteRequest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.runner.RunWith +import org.springframework.http.HttpStatus +import org.springframework.test.context.junit4.SpringRunner +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.ExchangeFunction +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + + +@RunWith(SpringRunner::class) +class ZitadelUserServiceTests { + val zitadelProperties: ZitadelProperties = mockk() + val exchangeFunction: ExchangeFunction = mockk() + + @BeforeEach + fun init() { + every { zitadelProperties.baseUrl } returns "https://myurl/" + every { zitadelProperties.adminAccessToken } returns "123abc" + } + + @Test + fun `when postUser then webclient does request`() { + // Arrange + val request = AcceptInviteRequest( + displayName = "Test", + email = "test@example.com", + password = "123456", + token = "qwerty" + ) + val webclient = WebClient.builder().exchangeFunction(exchangeFunction).build() + every { exchangeFunction.exchange(any()) } returns Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("content-type", "application/json") + .body("{ \"userId\" : \"789\"}") + .build() + ) + val target = ZitadelUserService(zitadelProperties, webclient) + + // Act + val actual = target.postUser(request) + + // Assert + verify(exactly = 1) { exchangeFunction.exchange(any()) } + assertThat(actual.isSuccess) + assertThat(actual.getOrNull()).isEqualTo("789") + } + + @Test + fun `when setEmailAddress with different email then webclient puts email`() { + // Arrange + val webclient = WebClient.builder().exchangeFunction(exchangeFunction).build() + val firstResponse = Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("content-type", "application/json") + .body("{ \"details\" : {}, \"email\": {\"email\": \"oldEmail@example.com\"}}") + .build() + ) + val secondResponse = Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("content-type", "application/json") + .body("{ \"details\" : {}}") + .build() + ) + every { exchangeFunction.exchange(any()) } returnsMany listOf(firstResponse, secondResponse, secondResponse) + val target = ZitadelUserService(zitadelProperties, webclient) + + // Act + val actual = target.setEmailAddress("789", "newEmail@example.com") + + // Assert + verify(exactly = 3) { exchangeFunction.exchange(any()) } + assertThat(actual.isSuccess) + assertThat(actual.getOrNull()).isEqualTo("newEmail@example.com") + } + + @Test + fun `when setEmailAddress with same email then no subsequent calls`() { + // Arrange + val webclient = WebClient.builder().exchangeFunction(exchangeFunction).build() + val firstResponse = Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("content-type", "application/json") + .body("{ \"details\" : {}, \"email\": {\"email\": \"oldEmail@example.com\"}}") + .build() + ) + every { exchangeFunction.exchange(any()) } returns firstResponse + val target = ZitadelUserService(zitadelProperties, webclient) + + // Act + val actual = target.setEmailAddress("789", "oldEmail@example.com") + + // Assert + verify(exactly = 1) { exchangeFunction.exchange(any()) } + assertThat(actual.isSuccess) + assertThat(actual.getOrNull()).isEqualTo("oldEmail@example.com") + } + +} \ No newline at end of file diff --git a/frontend/src/stores/userStore.js b/frontend/src/stores/userStore.js index 53f37db..5ece9a8 100644 --- a/frontend/src/stores/userStore.js +++ b/frontend/src/stores/userStore.js @@ -61,7 +61,8 @@ export const useUsers = defineStore('users', { this.users.set(user.id, user); let changes = { email: user.email, - username: user.username + username: user.username, + displayName: user.displayName } console.log(changes); return api.updateUserForId(user.id, changes); diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue index d9193ed..ca618f8 100644 --- a/frontend/src/views/Profile.vue +++ b/frontend/src/views/Profile.vue @@ -11,6 +11,13 @@

User details

+
+ +
+ +
+
+
@@ -72,7 +79,7 @@ export default { .finally(() => saving.value = false); } - return { + return { auth, user, draftValues, loading, saving, save }