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; }) => (
+
+ )}
+
+ >
);
}
\ 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,