Implement user preference storage in backend

This commit is contained in:
grimsi
2024-10-05 10:37:47 +02:00
parent 47565e7fd2
commit f82fa04ccd
8 changed files with 240 additions and 16 deletions
@@ -4,6 +4,7 @@ import {Button, Card, Divider, Select, Selection, SelectItem} from "@nextui-org/
import {themes} from "Frontend/theming/themes";
import {Theme} from "Frontend/theming/theme";
import ThemePreview from "Frontend/components/theming/ThemePreview";
import {UserPreferencesEndpoint} from "Frontend/generated/endpoints";
export function ThemeSelector() {
@@ -19,8 +20,11 @@ export function ThemeSelector() {
useEffect(updateTheme, [selectedTheme, selectedMode]);
function updateTheme() {
if (selectedMode instanceof Set)
setTheme(`${selectedTheme}-${selectedMode.values().next().value}`)
if (selectedMode instanceof Set) {
let theme = `${selectedTheme}-${selectedMode.values().next().value}`;
setTheme(theme);
UserPreferencesEndpoint.set("preferred-theme", theme).catch(console.error);
}
}
return (
+17
View File
@@ -8,16 +8,20 @@ import {Outlet, useNavigate} from "react-router-dom";
import {useAuth} from "Frontend/util/auth";
import {Heart} from "@phosphor-icons/react";
import Confetti, {ConfettiProps} from "react-confetti-boom";
import {UserPreferencesEndpoint} from "Frontend/generated/endpoints";
import {useTheme} from "next-themes";
export default function MainLayout() {
const navigate = useNavigate();
const auth = useAuth();
const routeMetadata = useRouteMetadata();
const {setTheme} = useTheme();
const [isExploding, setIsExploding] = useState(false);
useEffect(() => {
let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin';
window.addEventListener('popstate', () => document.title = newTitle);
loadUserTheme().catch(console.error);
}, []);
const confettiProps: ConfettiProps = {
@@ -30,6 +34,19 @@ export default function MainLayout() {
effectInterval: 10000
}
async function loadUserTheme() {
let theme = localStorage.getItem('theme');
if (theme) {
await UserPreferencesEndpoint.set("preferred-theme", theme);
} else {
let preferredTheme = await UserPreferencesEndpoint.get("preferred-theme");
if (preferredTheme) {
setTheme(preferredTheme);
}
}
}
function easterEgg() {
if (isExploding) return;
setIsExploding(true);
@@ -50,7 +50,7 @@ class UserService(
get() = config.get(ConfigProperties.Users.SignUps.Allow) == true
override fun loadUserByUsername(username: String): UserDetails {
val user = userByUsername(username)
val user = getByUsernameNonNull(username)
return org.springframework.security.core.userdetails.User(
user.username,
@@ -80,6 +80,10 @@ class UserService(
return userRepository.findByUsername(username)
}
fun getByUsernameNonNull(username: String): User {
return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
}
fun getUserInfo(auth: Authentication): UserInfoDto {
val principal = auth.principal
@@ -92,12 +96,12 @@ class UserService(
return userInfoDto
}
val user = userByUsername(auth.name)
val user = getByUsernameNonNull(auth.name)
return toUserInfo(user)
}
fun getAvatar(username: String): Avatar? {
val user = userByUsername(username)
val user = getByUsernameNonNull(username)
return user.avatar
}
@@ -106,7 +110,7 @@ class UserService(
}
fun setAvatar(username: String, file: MultipartFile) {
val user = userByUsername(username)
val user = getByUsernameNonNull(username)
if (user.avatar == null) {
user.avatar = Avatar(mimeType = file.contentType)
@@ -117,7 +121,7 @@ class UserService(
}
fun deleteAvatar(username: String) {
val user = userByUsername(username)
val user = getByUsernameNonNull(username)
if (user.avatar == null) return
@@ -188,7 +192,7 @@ class UserService(
}
fun updateUser(username: String, updates: UserUpdateDto) {
val user = userByUsername(username)
val user = getByUsernameNonNull(username)
updates.username?.let { user.username = it }
@@ -218,7 +222,7 @@ class UserService(
}
val currentUser = SecurityContextHolder.getContext().authentication
val targetUser = userByUsername(username)
val targetUser = getByUsernameNonNull(username)
if (!canManage(targetUser)) {
log.error { "User ${currentUser.name} tried to assign roles to user with higher or equal power level to their own" }
@@ -240,7 +244,7 @@ class UserService(
}
fun canManage(targetUsername: String): Boolean {
val targetUser = userByUsername(targetUsername)
val targetUser = getByUsernameNonNull(targetUsername)
return canManage(targetUser)
}
@@ -252,14 +256,14 @@ class UserService(
}
fun setUserEnabled(username: String, enabled: Boolean) {
val user = userByUsername(username)
val user = getByUsernameNonNull(username)
user.enabled = enabled
userRepository.save(user)
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
}
fun deleteUser(username: String) {
val user = userByUsername(username)
val user = getByUsernameNonNull(username)
userRepository.delete(user)
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl()))
}
@@ -279,8 +283,4 @@ class UserService(
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
}
private fun userByUsername(username: String): User {
return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
}
}
@@ -0,0 +1,27 @@
package de.grimsi.gameyfin.users.preferences
import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import java.io.Serializable
@Entity
class UserPreference(
@NotNull
@EmbeddedId
val id: UserPreferenceKey,
@NotNull
@Column(name = "`value`")
@Convert(converter = EncryptionConverter::class)
var value: String
)
@Embeddable
data class UserPreferenceKey(
@Column(name = "`key`")
val key: String,
@Column(name = "user_id")
val userId: Long
) : Serializable
@@ -0,0 +1,5 @@
package de.grimsi.gameyfin.users.preferences
import org.springframework.data.jpa.repository.JpaRepository
interface UserPreferenceRepository : JpaRepository<UserPreference, UserPreferenceKey>
@@ -0,0 +1,12 @@
package de.grimsi.gameyfin.users.preferences
import java.io.Serializable
import kotlin.reflect.KClass
sealed class UserPreferences<T : Serializable>(
val type: KClass<T>,
val key: String,
val allowedValues: List<T>? = null
) {
data object PreferredTheme : UserPreferences<String>(String::class, "preferred-theme")
}
@@ -0,0 +1,18 @@
package de.grimsi.gameyfin.users.preferences
import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.PermitAll
@Endpoint
@PermitAll
class UserPreferencesEndpoint(
private val userPreferences: UserPreferencesService
) {
fun get(key: String): String? {
return userPreferences.get(key)
}
fun set(key: String, value: String) {
userPreferences.set(key, value)
}
}
@@ -0,0 +1,141 @@
package de.grimsi.gameyfin.users.preferences
import de.grimsi.gameyfin.users.UserService
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import java.io.Serializable
@Service
@Transactional
class UserPreferencesService(
private val userPreferenceRepository: UserPreferenceRepository,
private val userService: UserService
) {
private val log = KotlinLogging.logger {}
/**
* Get the current value of a user preference in a type-safe way.
* Used internally.
*
* @param userPreference: The user preference containing necessary type information
* @return The current value if set or the default value or null if no value is set and no default value exists
*/
fun <T : Serializable> get(userPreference: UserPreferences<T>): T? {
log.info { "Getting user preference '${userPreference.key}'" }
val id = id(userPreference.key)
val appConfig = userPreferenceRepository.findById(id).orElse(null)
return if (appConfig != null) {
getValue(appConfig.value, userPreference)
} else {
return null
}
}
/**
* Get the current value of a user preference in a *not* type-safe way.
* Used for the external API.
*
* @param key: The key of the user preference
* @return The current value if set or the default value or null if no value is set and no default value exists
*/
fun get(key: String): String? {
log.info { "Getting user preference '$key'" }
val userPreference = findUserPreference(key)
val id = id(key)
val appConfig = userPreferenceRepository.findById(id).orElse(null)
return if (appConfig != null) {
getValue(appConfig.value, userPreference).toString()
} else {
return null
}
}
/**
* Set the value for a specified key in a type-safe way.
*
* @param userPreference: The target user preference
* @param value: Value to set the user preference to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the user preference
*/
fun <T : Serializable> set(userPreference: UserPreferences<T>, value: T) {
return set(userPreference.key, value)
}
/**
* Set the value for a specified key.
* Checks if the value can be cast to the type defined for the user preference.
*
* @param key: Key of the target user preference
* @param value: Value to set the user preference to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the user preference
*/
fun <T : Serializable> set(key: String, value: T) {
log.info { "Set user preference '$key'" }
val userPreferenceKey = findUserPreference(key)
// Check if the value can be cast to the type defined for the user preference
val castedValue = getValue(value.toString(), userPreferenceKey)
val id = id(key)
var userPreference = userPreferenceRepository.findById(id).orElse(null)
if (userPreference == null) {
userPreference = UserPreference(id, castedValue.toString())
} else {
userPreference.value = castedValue.toString()
}
userPreferenceRepository.save(userPreference)
}
/**
* Get the value of the user preference in a type-safe way.
*/
@Suppress("UNCHECKED_CAST")
private fun <T : Serializable> getValue(value: String, userPreference: UserPreferences<T>): T {
return when (userPreference.type) {
String::class -> value as T
Boolean::class -> value.toBoolean() as T
Int::class -> value.toFloat().toInt() as T
Float::class -> value.toFloat() as T
else -> {
if (userPreference.type.java.isEnum) {
val enumConstants = userPreference.type.java.enumConstants
enumConstants.firstOrNull { it.toString() == value }
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${userPreference.key}")
} else {
throw IllegalArgumentException("Unknown config type ${userPreference.type}: '$value' for key ${userPreference.key}")
}
}
}
}
/**
* Returns a user preference
*/
private fun findUserPreference(key: String): UserPreferences<*> {
// Use reflection to get all objects defined within ConfigKey
val configProperties = UserPreferences::class.sealedSubclasses.flatMap { subclass ->
subclass.objectInstance?.let { listOf(it) } ?: listOf()
}
// Find the matching config key based on the string key
return configProperties.find { it.key == key }
?: throw IllegalArgumentException("Unknown user preference key: $key")
}
private fun id(key: String): UserPreferenceKey {
val auth = SecurityContextHolder.getContext().authentication
val user = userService.getByUsernameNonNull(auth.name)
return UserPreferenceKey(key, user.id!!)
}
}