From 0fe91a19807de8efa85d840f93161f8cc95b209e Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:01:47 +0200 Subject: [PATCH] Implemented profile management Refactored shared code into components Added parallel session handling --- .../administration/ProfileManagement.tsx | 111 +++++++++++++++++- .../frontend/components/general/Input.tsx | 14 ++- .../components/general/SmallInfoField.tsx | 12 ++ .../gameyfin/config/dto/ConfigEntryDto.kt | 6 +- .../de/grimsi/gameyfin/meta/SecurityConfig.kt | 16 ++- .../meta/annotations/NullOrNotBlank.kt | 15 +++ .../annotations/NullOrNotBlankValidator.kt | 11 ++ .../grimsi/gameyfin/setup/SetupDataLoader.kt | 6 +- .../grimsi/gameyfin/users/SessionService.kt | 22 ++++ .../de/grimsi/gameyfin/users/UserEndpoint.kt | 28 ++++- .../de/grimsi/gameyfin/users/UserService.kt | 38 +++++- .../gameyfin/users/dto/UserDetailsDto.kt | 7 ++ .../grimsi/gameyfin/users/dto/UserInfoDto.kt | 2 +- .../gameyfin/users/dto/UserUpdateDto.kt | 12 ++ .../de/grimsi/gameyfin/users/entities/User.kt | 2 +- 15 files changed, 275 insertions(+), 27 deletions(-) create mode 100644 src/main/frontend/components/general/SmallInfoField.tsx create mode 100644 src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlank.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlankValidator.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/dto/UserUpdateDto.kt diff --git a/src/main/frontend/components/administration/ProfileManagement.tsx b/src/main/frontend/components/administration/ProfileManagement.tsx index e848319..39aa1bf 100644 --- a/src/main/frontend/components/administration/ProfileManagement.tsx +++ b/src/main/frontend/components/administration/ProfileManagement.tsx @@ -1,11 +1,112 @@ import Section from "Frontend/components/general/Section"; +import Input from "Frontend/components/general/Input"; +import {Form, Formik} from "formik"; +import {Button} from "@nextui-org/react"; +import {Check, Info} from "@phosphor-icons/react"; +import React, {useEffect, useState} from "react"; +import {useAuth} from "Frontend/util/auth"; +import * as Yup from "yup"; +import UserUpdateDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserUpdateDto"; +import {UserEndpoint} from "Frontend/generated/endpoints"; +import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; +import {toast} from "sonner"; export default function ProfileManagement() { + const [configSaved, setConfigSaved] = useState(false); + const auth = useAuth(); + + useEffect(() => { + if (configSaved) { + setTimeout(() => setConfigSaved(false), 2000); + } + }, [configSaved]) + + async function handleSubmit(values: any) { + const userUpdate: UserUpdateDto = { + username: values.username, + email: values.email + } + + if (values.newPassword.length > 0) { + userUpdate.password = values.newPassword; + } + + await UserEndpoint.updateUser(userUpdate); + setConfigSaved(true); + + if (values.newPassword.length > 0) { + toast.success("Password changed", { + description: "Please log in again" + }); + setTimeout(() => { + auth.logout(); + }, 500); + } + } + return ( -
-

My Profile

-
- {/* TODO */} -
+ <> + + {(formik: { values: any; isSubmitting: any; }) => ( +
+
+

My Profile

+ +
+ {formik.values.newPassword.length > 0 && + + } + +
+
+ +
+
+
+ + +
+ +
+
+ + +
+
+
+ )} +
+ ); } \ No newline at end of file diff --git a/src/main/frontend/components/general/Input.tsx b/src/main/frontend/components/general/Input.tsx index 7fdedc6..d782c63 100644 --- a/src/main/frontend/components/general/Input.tsx +++ b/src/main/frontend/components/general/Input.tsx @@ -1,6 +1,7 @@ import {useField} from "formik"; -import {XCircle} from "@phosphor-icons/react"; import {Input as NextUiInput} from "@nextui-org/react"; +import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; +import {XCircle} from "@phosphor-icons/react"; // @ts-ignore const Input = ({label, ...props}) => { @@ -8,18 +9,19 @@ const Input = ({label, ...props}) => { const [field, meta] = useField(props); return ( -
+
- {meta.error} - } /> +
+ {meta.touched && meta.error && ( + + )} +
); } diff --git a/src/main/frontend/components/general/SmallInfoField.tsx b/src/main/frontend/components/general/SmallInfoField.tsx new file mode 100644 index 0000000..6e7b0e7 --- /dev/null +++ b/src/main/frontend/components/general/SmallInfoField.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +// @ts-ignore +export function SmallInfoField({icon: IconComponent, message, ...props}) { + return ( +
+ + {message} + +
+ ); +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt b/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt index 01b837c..fcb45b4 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigEntryDto.kt @@ -5,9 +5,9 @@ import jakarta.annotation.Nonnull @JsonInclude(JsonInclude.Include.ALWAYS) data class ConfigEntryDto( - @Nonnull val key: String, + @field:Nonnull val key: String, val value: String?, val defaultValue: String?, - @Nonnull val type: String, - @Nonnull val description: String + @field:Nonnull val type: String, + @field:Nonnull val description: String ) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt index d1cd233..2be29a9 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt @@ -8,16 +8,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer +import org.springframework.security.core.session.SessionRegistry +import org.springframework.security.core.session.SessionRegistryImpl import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder - -@EnableWebSecurity @Configuration +@EnableWebSecurity class SecurityConfig( private val environment: Environment ) : VaadinWebSecurity() { + @Bean + fun sessionRegistry(): SessionRegistry { + return SessionRegistryImpl() + } + @Throws(Exception::class) override fun configure(http: HttpSecurity) { // Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher @@ -26,6 +32,12 @@ class SecurityConfig( .requestMatchers("/public/**").permitAll() } + http.sessionManagement { sessionManagement -> + sessionManagement + .maximumSessions(3) + .sessionRegistry(sessionRegistry()) + } + super.configure(http) setLoginView(http, "/login") } diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlank.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlank.kt new file mode 100644 index 0000000..120553f --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlank.kt @@ -0,0 +1,15 @@ +package de.grimsi.gameyfin.meta.annotations + +import jakarta.validation.Constraint +import jakarta.validation.Payload +import kotlin.reflect.KClass + +@MustBeDocumented +@Constraint(validatedBy = [NullOrNotBlankValidator::class]) +@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class NullOrNotBlank( + val message: String = "must be null or not blank", + val groups: Array> = [], + val payload: Array> = [] +) diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlankValidator.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlankValidator.kt new file mode 100644 index 0000000..dca84e3 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/NullOrNotBlankValidator.kt @@ -0,0 +1,11 @@ +package de.grimsi.gameyfin.meta.annotations + +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext + +class NullOrNotBlankValidator : ConstraintValidator { + + override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean { + return value == null || value.isNotBlank() + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/setup/SetupDataLoader.kt b/src/main/kotlin/de/grimsi/gameyfin/setup/SetupDataLoader.kt index 91ae929..b7a8fb9 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/setup/SetupDataLoader.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/setup/SetupDataLoader.kt @@ -66,14 +66,16 @@ class SetupDataLoader( val superadmin = User( username = "admin", - password = "admin" + password = "admin", + email = "admin@gameyfin.org" ) registerUserIfNotFound(superadmin, Roles.SUPERADMIN) val user = User( username = "user", - password = "user" + password = "user", + email = "user@gameyfin.org" ) registerUserIfNotFound(user, Roles.USER) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt new file mode 100644 index 0000000..efe1d06 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt @@ -0,0 +1,22 @@ +package de.grimsi.gameyfin.users + +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.session.SessionInformation +import org.springframework.security.core.session.SessionRegistry +import org.springframework.stereotype.Service + +@Service +class SessionService(private val sessionRegistry: SessionRegistry) { + + fun logoutAllSessions() { + val auth: Authentication? = SecurityContextHolder.getContext().authentication + if (auth != null) { + val sessions: List = sessionRegistry.getAllSessions(auth.principal, false) + for (sessionInfo in sessions) { + sessionInfo.expireNow() + } + SecurityContextHolder.clearContext() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt index 5753735..389be88 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt @@ -4,10 +4,11 @@ import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.meta.Roles import de.grimsi.gameyfin.users.dto.UserInfoDto import de.grimsi.gameyfin.users.dto.UserRegistrationDto +import de.grimsi.gameyfin.users.dto.UserUpdateDto import de.grimsi.gameyfin.users.entities.User import jakarta.annotation.security.PermitAll +import jakarta.annotation.security.RolesAllowed import org.springframework.security.core.Authentication -import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.context.SecurityContextHolder @Endpoint @@ -18,8 +19,7 @@ class UserEndpoint( @PermitAll fun getUserInfo(): UserInfoDto { val auth: Authentication = SecurityContextHolder.getContext().authentication - val authorities: List = auth.authorities.map { g: GrantedAuthority -> g.authority } - return UserInfoDto(username = auth.name, roles = authorities) + return userService.getUserInfo(auth.name) } @PermitAll @@ -28,6 +28,28 @@ class UserEndpoint( return userService.toUserInfo(user) } + @PermitAll + fun updateUser(updates: UserUpdateDto) { + val auth: Authentication = SecurityContextHolder.getContext().authentication + userService.updateUser(auth.name, updates) + } + + @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN) + fun updateUser(username: String, updates: UserUpdateDto) { + userService.updateUser(username, updates) + } + + @PermitAll + fun deleteUser() { + val auth: Authentication = SecurityContextHolder.getContext().authentication + userService.deleteUser(auth.name) + } + + @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN) + fun deleteUser(username: String) { + userService.deleteUser(username) + } + private fun registerUser(registration: UserRegistrationDto, roles: List): User { val user = User( username = registration.username, diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index ef488ed..fe7bd05 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -2,6 +2,7 @@ package de.grimsi.gameyfin.users import de.grimsi.gameyfin.meta.Roles import de.grimsi.gameyfin.users.dto.UserInfoDto +import de.grimsi.gameyfin.users.dto.UserUpdateDto import de.grimsi.gameyfin.users.entities.Role import de.grimsi.gameyfin.users.entities.User import de.grimsi.gameyfin.users.persistence.UserRepository @@ -20,12 +21,12 @@ import org.springframework.stereotype.Service class UserService( private val userRepository: UserRepository, private val passwordEncoder: PasswordEncoder, - private val roleService: RoleService + private val roleService: RoleService, + private val sessionService: SessionService ) : UserDetailsService { override fun loadUserByUsername(username: String): UserDetails { - val user = - userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'") + val user = userByUsername(username) return org.springframework.security.core.userdetails.User( user.username, @@ -33,13 +34,18 @@ class UserService( user.enabled, true, true, - user.enabled, + true, toAuthorities(user.roles) ) } fun existsByUsername(username: String): Boolean = userRepository.findByUsername(username) != null + fun getUserInfo(username: String): UserInfoDto { + val user = userByUsername(username) + return toUserInfo(user) + } + fun registerUser(user: User, role: Roles): User { return registerUser(user, listOf(role)) } @@ -50,6 +56,26 @@ class UserService( return userRepository.save(user) } + fun updateUser(username: String, updates: UserUpdateDto) { + val user = userByUsername(username) + + updates.username?.let { user.username = it } + updates.password?.let { user.password = passwordEncoder.encode(it) } + updates.email?.let { user.email = it } + + userRepository.save(user) + + // If user changes password, all sessions should be invalidated + if (updates.password != null) { + sessionService.logoutAllSessions() + } + } + + fun deleteUser(username: String) { + val user = userByUsername(username) + userRepository.delete(user) + } + fun toUserInfo(user: User): UserInfoDto { return UserInfoDto( username = user.username, @@ -61,4 +87,8 @@ class UserService( private fun toAuthorities(roles: Collection): List { return roles.map { r -> SimpleGrantedAuthority(r.rolename) } } + + private fun userByUsername(username: String): User { + return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'") + } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt new file mode 100644 index 0000000..cc97f39 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt @@ -0,0 +1,7 @@ +package de.grimsi.gameyfin.users.dto + +data class UserDetailsDto( + val id: Long, + val username: String, + val email: String +) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt index 4c4e956..5ccb9e5 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt @@ -2,6 +2,6 @@ package de.grimsi.gameyfin.users.dto data class UserInfoDto( val username: String, - val email: String? = null, + val email: String, val roles: List ) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserUpdateDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserUpdateDto.kt new file mode 100644 index 0000000..996f8c1 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserUpdateDto.kt @@ -0,0 +1,12 @@ +package de.grimsi.gameyfin.users.dto + +import de.grimsi.gameyfin.meta.annotations.NullOrNotBlank + +data class UserUpdateDto( + @field:NullOrNotBlank + val username: String?, + @field:NullOrNotBlank + val password: String?, + @field:NullOrNotBlank + val email: String? +) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt index e3cd2d6..fdd8e2b 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt @@ -21,7 +21,7 @@ class User( @Nullable @Column(unique = true) - var email: String? = null, + var email: String, var email_confirmed: Boolean = false,