diff --git a/komga-webui/src/components/UsersList.vue b/komga-webui/src/components/UsersList.vue index 418f202e9..f40929e92 100644 --- a/komga-webui/src/components/UsersList.vue +++ b/komga-webui/src/components/UsersList.vue @@ -142,7 +142,7 @@ export default Vue.extend({ modalDeleteUser: false, userToDelete: {} as UserDto, modalEditSharedLibraries: false, - userToEditSharedLibraries: {} as UserWithSharedLibrariesDto, + userToEditSharedLibraries: {} as UserDto, modalEditUser: false, userToEdit: {} as UserDto, modalChangePassword: false, @@ -150,7 +150,7 @@ export default Vue.extend({ usersLastActivity: {} as any, }), computed: { - users(): UserWithSharedLibrariesDto[] { + users(): UserDto[] { return this.$store.state.komgaUsers.users }, me(): UserDto { @@ -175,7 +175,7 @@ export default Vue.extend({ this.userToDelete = user this.modalDeleteUser = true }, - editSharedLibraries(user: UserWithSharedLibrariesDto) { + editSharedLibraries(user: UserDto) { this.userToEditSharedLibraries = user this.modalEditSharedLibraries = true }, diff --git a/komga-webui/src/components/dialogs/UserEditDialog.vue b/komga-webui/src/components/dialogs/UserEditDialog.vue index 902819252..e6417c8d1 100644 --- a/komga-webui/src/components/dialogs/UserEditDialog.vue +++ b/komga-webui/src/components/dialogs/UserEditDialog.vue @@ -104,11 +104,11 @@ export default Vue.extend({ }, async editUser() { try { - const roles = { + const patch = { roles: this.roles, - } as RolesUpdateDto + } as UserUpdateDto - await this.$store.dispatch('updateUserRoles', {userId: this.user.id, roles: roles}) + await this.$store.dispatch('updateUser', {userId: this.user.id, patch: patch}) } catch (e) { this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent) } diff --git a/komga-webui/src/components/dialogs/UserSharedLibrariesEditDialog.vue b/komga-webui/src/components/dialogs/UserSharedLibrariesEditDialog.vue index 88ab6d81a..648e15656 100644 --- a/komga-webui/src/components/dialogs/UserSharedLibrariesEditDialog.vue +++ b/komga-webui/src/components/dialogs/UserSharedLibrariesEditDialog.vue @@ -91,12 +91,12 @@ export default Vue.extend({ }, }, methods: { - dialogReset(user: UserWithSharedLibrariesDto) { + dialogReset(user: UserDto) { this.allLibraries = user.sharedAllLibraries if (user.sharedAllLibraries) { this.selectedLibraries = this.libraries.map(x => x.id) } else { - this.selectedLibraries = user.sharedLibraries.map(x => x.id) + this.selectedLibraries = user.sharedLibrariesIds } }, dialogCancel() { @@ -110,12 +110,14 @@ export default Vue.extend({ }, async editUser() { try { - const sharedLibraries = { - all: this.allLibraries, - libraryIds: this.selectedLibraries, - } as SharedLibrariesUpdateDto + const patch = { + sharedLibraries: { + all: this.allLibraries, + libraryIds: this.selectedLibraries, + }, + } as UserUpdateDto - await this.$store.dispatch('updateUserSharedLibraries', {user: this.user, sharedLibraries: sharedLibraries}) + await this.$store.dispatch('updateUser', {userId: this.user.id, patch: patch}) } catch (e) { this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent) } diff --git a/komga-webui/src/plugins/komga-users.plugin.ts b/komga-webui/src/plugins/komga-users.plugin.ts index a26475539..8ce209fde 100644 --- a/komga-webui/src/plugins/komga-users.plugin.ts +++ b/komga-webui/src/plugins/komga-users.plugin.ts @@ -9,7 +9,7 @@ let service: KomgaUsersService const vuexModule: Module = { state: { me: {} as UserDto, - users: [] as UserWithSharedLibrariesDto[], + users: [] as UserDto[], }, getters: { meAdmin: state => state.me.hasOwnProperty('roles') && state.me.roles.includes(UserRoles.ADMIN), @@ -21,7 +21,7 @@ const vuexModule: Module = { setMe (state, user: UserDto) { state.me = user }, - setAllUsers (state, users: UserWithSharedLibrariesDto[]) { + setAllUsers (state, users: UserDto[]) { state.users = users }, }, @@ -46,18 +46,14 @@ const vuexModule: Module = { await service.postUser(user) dispatch('getAllUsers') }, - async updateUserRoles ({ dispatch }, { userId, roles }: { userId: string, roles: RolesUpdateDto }) { - await service.patchUserRoles(userId, roles) + async updateUser ({ dispatch }, { userId, patch }: { userId: string, patch: UserUpdateDto }) { + await service.patchUser(userId, patch) dispatch('getAllUsers') }, async deleteUser ({ dispatch }, user: UserDto) { await service.deleteUser(user) dispatch('getAllUsers') }, - async updateUserSharedLibraries ({ dispatch }, { user, sharedLibraries }: { user: UserDto, sharedLibraries: SharedLibrariesUpdateDto }) { - await service.patchUserSharedLibraries(user, sharedLibraries) - dispatch('getAllUsers') - }, }, } diff --git a/komga-webui/src/services/komga-users.service.ts b/komga-webui/src/services/komga-users.service.ts index 553c03455..2a970a211 100644 --- a/komga-webui/src/services/komga-users.service.ts +++ b/komga-webui/src/services/komga-users.service.ts @@ -2,7 +2,7 @@ import {AxiosInstance} from 'axios' const qs = require('qs') -const API_USERS = '/api/v1/users' +const API_USERS = '/api/v2/users' export default class KomgaUsersService { private http: AxiosInstance @@ -50,7 +50,7 @@ export default class KomgaUsersService { } } - async getAll(): Promise { + async getAll(): Promise { try { return (await this.http.get(`${API_USERS}`)).data } catch (e) { @@ -74,9 +74,9 @@ export default class KomgaUsersService { } } - async patchUserRoles(userId: string, roles: RolesUpdateDto): Promise { + async patchUser(userId: string, patch: UserUpdateDto) { try { - return (await this.http.patch(`${API_USERS}/${userId}`, roles)).data + await this.http.patch(`${API_USERS}/${userId}`, patch) } catch (e) { let msg = `An error occurred while trying to patch user '${userId}'` if (e.response.data.message) { @@ -100,7 +100,7 @@ export default class KomgaUsersService { async patchUserPassword(user: UserDto, newPassword: PasswordUpdateDto) { try { - return (await this.http.patch(`${API_USERS}/${user.id}/password`, newPassword)).data + await this.http.patch(`${API_USERS}/${user.id}/password`, newPassword) } catch (e) { let msg = `An error occurred while trying to update password for user ${user.email}` if (e.response.data.message) { @@ -110,21 +110,9 @@ export default class KomgaUsersService { } } - async patchUserSharedLibraries(user: UserDto, sharedLibrariesUpdateDto: SharedLibrariesUpdateDto) { - try { - return (await this.http.patch(`${API_USERS}/${user.id}/shared-libraries`, sharedLibrariesUpdateDto)).data - } catch (e) { - let msg = `An error occurred while trying to update shared libraries for user ${user.email}` - if (e.response.data.message) { - msg += `: ${e.response.data.message}` - } - throw new Error(msg) - } - } - async logout() { try { - await this.http.post(`${API_USERS}/logout`) + await this.http.post('api/logout') } catch (e) { let msg = 'An error occurred while trying to logout' if (e.response.data.message) { diff --git a/komga-webui/src/types/komga-users.ts b/komga-webui/src/types/komga-users.ts index 2853c947e..d55a01168 100644 --- a/komga-webui/src/types/komga-users.ts +++ b/komga-webui/src/types/komga-users.ts @@ -1,19 +1,9 @@ interface UserDto { - id: string, - email: string, - roles: string[] -} - -interface UserWithSharedLibrariesDto { id: string, email: string, roles: string[], sharedAllLibraries: boolean, - sharedLibraries: SharedLibraryDto[] -} - -interface SharedLibraryDto { - id: string + sharedLibrariesIds: string[] } interface UserCreationDto { @@ -25,13 +15,12 @@ interface PasswordUpdateDto { password: string } -interface SharedLibrariesUpdateDto { - all: boolean, - libraryIds: string[] -} - -interface RolesUpdateDto { - roles: string[] +interface UserUpdateDto { + roles?: string[], + sharedLibraries?: { + all: boolean, + libraryIds: string[] + }, } interface AuthenticationActivityDto { diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt index 7b450642d..03ad25d39 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt @@ -67,7 +67,7 @@ class SecurityConfiguration( it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource) } .logout { - it.logoutUrl("/api/v1/users/logout") + it.logoutUrl("/api/logout") it.deleteCookies(sessionCookieName) it.invalidateHttpSession(true) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClaimController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClaimController.kt index 7a90a78bf..fc7a32689 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClaimController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ClaimController.kt @@ -2,8 +2,8 @@ package org.gotson.komga.interfaces.api.rest import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.service.KomgaUserLifecycle -import org.gotson.komga.interfaces.api.rest.dto.UserDto -import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.gotson.komga.interfaces.api.rest.dto.UserDtoV2 +import org.gotson.komga.interfaces.api.rest.dto.toDtoV2 import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.validation.annotation.Validated @@ -30,7 +30,7 @@ class ClaimController( fun claimAdmin( @Email(regexp = ".+@.+\\..+") @RequestHeader("X-Komga-Email") email: String, @NotBlank @RequestHeader("X-Komga-Password") password: String, - ): UserDto { + ): UserDtoV2 { if (userDetailsLifecycle.countUsers() > 0) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed") @@ -40,7 +40,7 @@ class ClaimController( password = password, roleAdmin = true, ), - ).toDto() + ).toDtoV2() } data class ClaimStatus( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV1Controller.kt similarity index 93% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV1Controller.kt index 0d32b5027..00e28b0ac 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV1Controller.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package org.gotson.komga.interfaces.api.rest import io.swagger.v3.oas.annotations.Parameter @@ -46,9 +48,10 @@ import javax.validation.Valid private val logger = KotlinLogging.logger {} +@Deprecated("User api/v2/users instead") @RestController @RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE]) -class UserController( +class UserV1Controller( private val userLifecycle: KomgaUserLifecycle, private val userRepository: KomgaUserRepository, private val libraryRepository: LibraryRepository, @@ -59,10 +62,12 @@ class UserController( private val demo = env.activeProfiles.contains("demo") @GetMapping("me") + @Deprecated("User api/v2/users instead") fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDto = principal.user.toDto() @PatchMapping("me/password") + @Deprecated("User api/v2/users instead") @ResponseStatus(HttpStatus.NO_CONTENT) fun updateMyPassword( @AuthenticationPrincipal principal: KomgaPrincipal, @@ -76,12 +81,14 @@ class UserController( @GetMapping @PreAuthorize("hasRole('$ROLE_ADMIN')") + @Deprecated("User api/v2/users instead") fun getAll(): List = userRepository.findAll().map { it.toWithSharedLibrariesDto() } @PostMapping @ResponseStatus(HttpStatus.CREATED) @PreAuthorize("hasRole('$ROLE_ADMIN')") + @Deprecated("User api/v2/users instead") fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto = try { userLifecycle.createUser(newUser.toDomain()).toDto() @@ -92,6 +99,7 @@ class UserController( @DeleteMapping("{id}") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") + @Deprecated("User api/v2/users instead") fun delete( @PathVariable id: String, @AuthenticationPrincipal principal: KomgaPrincipal, @@ -104,6 +112,7 @@ class UserController( @PatchMapping("{id}") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") + @Deprecated("User api/v2/users instead") fun updateUserRoles( @PathVariable id: String, @Valid @RequestBody patch: RolesUpdateDto, @@ -123,6 +132,7 @@ class UserController( @PatchMapping("{id}/password") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") + @Deprecated("User api/v2/users instead") fun updatePassword( @PathVariable id: String, @AuthenticationPrincipal principal: KomgaPrincipal, @@ -137,6 +147,7 @@ class UserController( @PatchMapping("{id}/shared-libraries") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('$ROLE_ADMIN')") + @Deprecated("User api/v2/users instead") fun updateSharesLibraries( @PathVariable id: String, @Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto, @@ -156,6 +167,7 @@ class UserController( @GetMapping("me/authentication-activity") @PageableAsQueryParam + @Deprecated("User api/v2/users instead") fun getMyAuthenticationActivity( @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, @@ -180,6 +192,7 @@ class UserController( @GetMapping("authentication-activity") @PageableAsQueryParam @PreAuthorize("hasRole('$ROLE_ADMIN')") + @Deprecated("User api/v2/users instead") fun getAuthenticationActivity( @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, @Parameter(hidden = true) page: Pageable, @@ -201,6 +214,7 @@ class UserController( @GetMapping("{id}/authentication-activity/latest") @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") + @Deprecated("User api/v2/users instead") fun getLatestAuthenticationActivityForUser( @PathVariable id: String, @AuthenticationPrincipal principal: KomgaPrincipal, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV2Controller.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV2Controller.kt new file mode 100644 index 000000000..d5b62f7c1 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV2Controller.kt @@ -0,0 +1,197 @@ +package org.gotson.komga.interfaces.api.rest + +import io.swagger.v3.oas.annotations.Parameter +import mu.KotlinLogging +import org.gotson.komga.domain.model.ROLE_ADMIN +import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD +import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING +import org.gotson.komga.domain.model.UserEmailAlreadyExistsException +import org.gotson.komga.domain.persistence.AuthenticationActivityRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto +import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.UserCreationDto +import org.gotson.komga.interfaces.api.rest.dto.UserDtoV2 +import org.gotson.komga.interfaces.api.rest.dto.UserUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.gotson.komga.interfaces.api.rest.dto.toDtoV2 +import org.springdoc.core.converters.models.PageableAsQueryParam +import org.springframework.core.env.Environment +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import javax.validation.Valid + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("api/v2/users", produces = [MediaType.APPLICATION_JSON_VALUE]) +class UserV2Controller( + private val userLifecycle: KomgaUserLifecycle, + private val userRepository: KomgaUserRepository, + private val libraryRepository: LibraryRepository, + private val authenticationActivityRepository: AuthenticationActivityRepository, + env: Environment, +) { + + private val demo = env.activeProfiles.contains("demo") + + @GetMapping("me") + fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDtoV2 = + principal.toDtoV2() + + @PatchMapping("me/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun updateMyPassword( + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody newPasswordDto: PasswordUpdateDto, + ) { + if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) + userRepository.findByEmailIgnoreCaseOrNull(principal.username)?.let { user -> + userLifecycle.updatePassword(user, newPasswordDto.password, false) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping + @PreAuthorize("hasRole('$ROLE_ADMIN')") + fun getAll(): List = + userRepository.findAll().map { it.toDtoV2() } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasRole('$ROLE_ADMIN')") + fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDtoV2 = + try { + userLifecycle.createUser(newUser.toDomain()).toDtoV2() + } catch (e: UserEmailAlreadyExistsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists") + } + + @DeleteMapping("{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") + fun delete( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + userRepository.findByIdOrNull(id)?.let { + userLifecycle.deleteUser(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PatchMapping("{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") + fun updateUser( + @PathVariable id: String, + @Valid @RequestBody patch: UserUpdateDto, + @AuthenticationPrincipal principal: KomgaPrincipal, + ) { + userRepository.findByIdOrNull(id)?.let { existing -> + val updatedUser = with(patch) { + existing.copy( + roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin, + roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload, + rolePageStreaming = if (isSet("roles")) roles!!.contains(ROLE_PAGE_STREAMING) else existing.rolePageStreaming, + sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries, + sharedLibrariesIds = if (isSet("sharedLibraries")) { + if (sharedLibraries!!.all) emptySet() + else libraryRepository.findAllByIds(sharedLibraries!!.libraryIds).map { it.id }.toSet() + } else existing.sharedLibrariesIds, + ) + } + userRepository.update(updatedUser) + logger.info { "Updated user: $updatedUser" } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PatchMapping("{id}/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") + fun updatePassword( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + @Valid @RequestBody newPasswordDto: PasswordUpdateDto, + ) { + if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN) + userRepository.findByIdOrNull(id)?.let { user -> + userLifecycle.updatePassword(user, newPasswordDto.password, user.id != principal.user.id) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping("me/authentication-activity") + @PageableAsQueryParam + fun getMyAuthenticationActivity( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + if (demo && !principal.user.roleAdmin) throw ResponseStatusException(HttpStatus.FORBIDDEN) + val sort = + if (page.sort.isSorted) page.sort + else Sort.by(Sort.Order.desc("dateTime")) + + val pageRequest = + if (unpaged) UnpagedSorted(sort) + else PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return authenticationActivityRepository.findAllByUser(principal.user, pageRequest).map { it.toDto() } + } + + @GetMapping("authentication-activity") + @PageableAsQueryParam + @PreAuthorize("hasRole('$ROLE_ADMIN')") + fun getAuthenticationActivity( + @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, + @Parameter(hidden = true) page: Pageable, + ): Page { + val sort = + if (page.sort.isSorted) page.sort + else Sort.by(Sort.Order.desc("dateTime")) + + val pageRequest = + if (unpaged) UnpagedSorted(sort) + else PageRequest.of( + page.pageNumber, + page.pageSize, + sort, + ) + + return authenticationActivityRepository.findAll(pageRequest).map { it.toDto() } + } + + @GetMapping("{id}/authentication-activity/latest") + @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") + fun getLatestAuthenticationActivityForUser( + @PathVariable id: String, + @AuthenticationPrincipal principal: KomgaPrincipal, + ): AuthenticationActivityDto = + userRepository.findByIdOrNull(id)?.let { user -> + authenticationActivityRepository.findMostRecentByUser(user)?.toDto() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt index b128eec54..4d66c31d9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt @@ -8,12 +8,14 @@ import org.gotson.komga.infrastructure.security.KomgaPrincipal import javax.validation.constraints.Email import javax.validation.constraints.NotBlank +@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead") data class UserDto( val id: String, val email: String, val roles: List, ) +@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead") fun KomgaUser.toDto() = UserDto( id = id, @@ -21,8 +23,29 @@ fun KomgaUser.toDto() = roles = roles().toList(), ) +@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead") fun KomgaPrincipal.toDto() = user.toDto() +data class UserDtoV2( + val id: String, + val email: String, + val roles: Set, + val sharedAllLibraries: Boolean, + val sharedLibrariesIds: Set, +) + +fun KomgaUser.toDtoV2() = + UserDtoV2( + id = id, + email = email, + roles = roles(), + sharedAllLibraries = sharedAllLibraries, + sharedLibrariesIds = sharedLibrariesIds, + ) + +fun KomgaPrincipal.toDtoV2() = user.toDtoV2() + +@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead") data class UserWithSharedLibrariesDto( val id: String, val email: String, @@ -31,10 +54,12 @@ data class UserWithSharedLibrariesDto( val sharedLibraries: List, ) +@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead") data class SharedLibraryDto( val id: String, ) +@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead") fun KomgaUser.toWithSharedLibrariesDto() = UserWithSharedLibrariesDto( id = id, @@ -63,11 +88,7 @@ data class PasswordUpdateDto( @get:NotBlank val password: String, ) -data class SharedLibrariesUpdateDto( - val all: Boolean, - val libraryIds: Set, -) - +@Deprecated("Deprecated since 0.153.0") data class RolesUpdateDto( val roles: List, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt new file mode 100644 index 000000000..7cffdcf87 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt @@ -0,0 +1,32 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import kotlin.properties.Delegates + +class UserUpdateDto { + private val isSet = mutableMapOf() + fun isSet(prop: String) = isSet.getOrDefault(prop, false) + +// @get:NullOrNotBlank +// val title: String? = null + +// @get:PositiveOrZero +// var ageRating: Int? +// by Delegates.observable(null) { prop, _, _ -> +// isSet[prop.name] = true +// } + + var roles: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var sharedLibraries: SharedLibrariesUpdateDto? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } +} + +data class SharedLibrariesUpdateDto( + val all: Boolean, + val libraryIds: Set, +) diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerDemoTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerDemoTest.kt index 8e152a5e7..4bb575937 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerDemoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerDemoTest.kt @@ -9,6 +9,7 @@ import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.patch @ExtendWith(SpringExtension::class) @@ -24,11 +25,20 @@ class UserControllerDemoTest( fun `given demo profile is active when a user tries to update its password via api then returns forbidden`() { val jsonString = """{"password":"new"}""" - mockMvc.patch("/api/v1/users/me/password") { + mockMvc.patch("/api/v2/users/me/password") { contentType = MediaType.APPLICATION_JSON content = jsonString }.andExpect { status { isForbidden() } } } + + @Test + @WithMockCustomUser + fun `given demo profile is active when a user tries to retrieve own authentication activity then returns forbidden`() { + mockMvc.get("/api/v2/users/me/authentication-activity") + .andExpect { + status { isForbidden() } + } + } } diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt index b0960151b..b104b43f2 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt @@ -1,6 +1,20 @@ package org.gotson.komga.interfaces.api.rest +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.ROLE_ADMIN +import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD +import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.LibraryLifecycle +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource @@ -11,6 +25,7 @@ import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.patch import org.springframework.test.web.servlet.post @ExtendWith(SpringExtension::class) @@ -19,19 +34,192 @@ import org.springframework.test.web.servlet.post @ActiveProfiles("test") class UserControllerTest( @Autowired private val mockMvc: MockMvc, - + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, ) { + private val admin = KomgaUser("admin@example.org", "", true, id = "admin") + + @BeforeAll + fun setup() { + libraryRepository.insert(makeLibrary(id = "1")) + libraryRepository.insert(makeLibrary(id = "2")) + userRepository.insert(admin) + } + + @AfterAll + fun teardown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun deleteUsers() { + userRepository.findAll() + .filterNot { it.email == admin.email } + .forEach { userLifecycle.deleteUser(it) } + } + @ParameterizedTest @ValueSource(strings = ["user", "user@domain"]) @WithMockCustomUser(roles = [ROLE_ADMIN]) fun `when creating a user with invalid email then returns bad request`(email: String) { val jsonString = """{"email":"$email","password":"password"}""" - mockMvc.post("/api/v1/users") { + mockMvc.post("/api/v2/users") { contentType = MediaType.APPLICATION_JSON content = jsonString }.andExpect { status { isBadRequest() } } } + + @Nested + inner class Update { + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user without roles when updating roles then roles are updated`() { + val user = KomgaUser("user@example.org", "", false, id = "user", roleFileDownload = false, rolePageStreaming = false) + userLifecycle.createUser(user) + + val jsonString = """ + { + "roles": ["$ROLE_FILE_DOWNLOAD","$ROLE_PAGE_STREAMING"] + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.roleFileDownload).isTrue + assertThat(this.rolePageStreaming).isTrue + assertThat(this.roleAdmin).isFalse + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user with roles when updating roles then roles are updated`() { + val user = KomgaUser("user@example.org", "", true, id = "user", roleFileDownload = true, rolePageStreaming = true) + userLifecycle.createUser(user) + + val jsonString = """ + { + "roles": [] + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.roleFileDownload).isFalse + assertThat(this.rolePageStreaming).isFalse + assertThat(this.roleAdmin).isFalse + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user with library restrictions when updating available libraries then they are updated`() { + val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("1")) + userLifecycle.createUser(user) + + val jsonString = """ + { + "sharedLibraries": { + "all": "false", + "libraryIds" : ["1", "2"] + } + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.sharedAllLibraries).isFalse + assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("1", "2") + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user without library restrictions when restricting libraries then they restrictions are updated`() { + val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = true) + userLifecycle.createUser(user) + + val jsonString = """ + { + "sharedLibraries": { + "all": "false", + "libraryIds" : ["2"] + } + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.sharedAllLibraries).isFalse + assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("2") + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user with library restrictions when removing restrictions then they restrictions are updated`() { + val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("2")) + userLifecycle.createUser(user) + + val jsonString = """ + { + "sharedLibraries": { + "all": "true", + "libraryIds": [] + } + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.sharedAllLibraries).isTrue + assertThat(this.sharedLibrariesIds).isEmpty() + } + } + } }