mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement user preference storage in backend
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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!!)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user