mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +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 {themes} from "Frontend/theming/themes";
|
||||||
import {Theme} from "Frontend/theming/theme";
|
import {Theme} from "Frontend/theming/theme";
|
||||||
import ThemePreview from "Frontend/components/theming/ThemePreview";
|
import ThemePreview from "Frontend/components/theming/ThemePreview";
|
||||||
|
import {UserPreferencesEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
|
||||||
export function ThemeSelector() {
|
export function ThemeSelector() {
|
||||||
|
|
||||||
@@ -19,8 +20,11 @@ export function ThemeSelector() {
|
|||||||
useEffect(updateTheme, [selectedTheme, selectedMode]);
|
useEffect(updateTheme, [selectedTheme, selectedMode]);
|
||||||
|
|
||||||
function updateTheme() {
|
function updateTheme() {
|
||||||
if (selectedMode instanceof Set)
|
if (selectedMode instanceof Set) {
|
||||||
setTheme(`${selectedTheme}-${selectedMode.values().next().value}`)
|
let theme = `${selectedTheme}-${selectedMode.values().next().value}`;
|
||||||
|
setTheme(theme);
|
||||||
|
UserPreferencesEndpoint.set("preferred-theme", theme).catch(console.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,16 +8,20 @@ import {Outlet, useNavigate} from "react-router-dom";
|
|||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import {Heart} from "@phosphor-icons/react";
|
import {Heart} from "@phosphor-icons/react";
|
||||||
import Confetti, {ConfettiProps} from "react-confetti-boom";
|
import Confetti, {ConfettiProps} from "react-confetti-boom";
|
||||||
|
import {UserPreferencesEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import {useTheme} from "next-themes";
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const routeMetadata = useRouteMetadata();
|
const routeMetadata = useRouteMetadata();
|
||||||
|
const {setTheme} = useTheme();
|
||||||
const [isExploding, setIsExploding] = useState(false);
|
const [isExploding, setIsExploding] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin';
|
let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin';
|
||||||
window.addEventListener('popstate', () => document.title = newTitle);
|
window.addEventListener('popstate', () => document.title = newTitle);
|
||||||
|
loadUserTheme().catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const confettiProps: ConfettiProps = {
|
const confettiProps: ConfettiProps = {
|
||||||
@@ -30,6 +34,19 @@ export default function MainLayout() {
|
|||||||
effectInterval: 10000
|
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() {
|
function easterEgg() {
|
||||||
if (isExploding) return;
|
if (isExploding) return;
|
||||||
setIsExploding(true);
|
setIsExploding(true);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class UserService(
|
|||||||
get() = config.get(ConfigProperties.Users.SignUps.Allow) == true
|
get() = config.get(ConfigProperties.Users.SignUps.Allow) == true
|
||||||
|
|
||||||
override fun loadUserByUsername(username: String): UserDetails {
|
override fun loadUserByUsername(username: String): UserDetails {
|
||||||
val user = userByUsername(username)
|
val user = getByUsernameNonNull(username)
|
||||||
|
|
||||||
return org.springframework.security.core.userdetails.User(
|
return org.springframework.security.core.userdetails.User(
|
||||||
user.username,
|
user.username,
|
||||||
@@ -80,6 +80,10 @@ class UserService(
|
|||||||
return userRepository.findByUsername(username)
|
return userRepository.findByUsername(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getByUsernameNonNull(username: String): User {
|
||||||
|
return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
|
||||||
|
}
|
||||||
|
|
||||||
fun getUserInfo(auth: Authentication): UserInfoDto {
|
fun getUserInfo(auth: Authentication): UserInfoDto {
|
||||||
val principal = auth.principal
|
val principal = auth.principal
|
||||||
|
|
||||||
@@ -92,12 +96,12 @@ class UserService(
|
|||||||
return userInfoDto
|
return userInfoDto
|
||||||
}
|
}
|
||||||
|
|
||||||
val user = userByUsername(auth.name)
|
val user = getByUsernameNonNull(auth.name)
|
||||||
return toUserInfo(user)
|
return toUserInfo(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAvatar(username: String): Avatar? {
|
fun getAvatar(username: String): Avatar? {
|
||||||
val user = userByUsername(username)
|
val user = getByUsernameNonNull(username)
|
||||||
return user.avatar
|
return user.avatar
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +110,7 @@ class UserService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setAvatar(username: String, file: MultipartFile) {
|
fun setAvatar(username: String, file: MultipartFile) {
|
||||||
val user = userByUsername(username)
|
val user = getByUsernameNonNull(username)
|
||||||
|
|
||||||
if (user.avatar == null) {
|
if (user.avatar == null) {
|
||||||
user.avatar = Avatar(mimeType = file.contentType)
|
user.avatar = Avatar(mimeType = file.contentType)
|
||||||
@@ -117,7 +121,7 @@ class UserService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAvatar(username: String) {
|
fun deleteAvatar(username: String) {
|
||||||
val user = userByUsername(username)
|
val user = getByUsernameNonNull(username)
|
||||||
|
|
||||||
if (user.avatar == null) return
|
if (user.avatar == null) return
|
||||||
|
|
||||||
@@ -188,7 +192,7 @@ class UserService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateUser(username: String, updates: UserUpdateDto) {
|
fun updateUser(username: String, updates: UserUpdateDto) {
|
||||||
val user = userByUsername(username)
|
val user = getByUsernameNonNull(username)
|
||||||
|
|
||||||
updates.username?.let { user.username = it }
|
updates.username?.let { user.username = it }
|
||||||
|
|
||||||
@@ -218,7 +222,7 @@ class UserService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val currentUser = SecurityContextHolder.getContext().authentication
|
val currentUser = SecurityContextHolder.getContext().authentication
|
||||||
val targetUser = userByUsername(username)
|
val targetUser = getByUsernameNonNull(username)
|
||||||
|
|
||||||
if (!canManage(targetUser)) {
|
if (!canManage(targetUser)) {
|
||||||
log.error { "User ${currentUser.name} tried to assign roles to user with higher or equal power level to their own" }
|
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 {
|
fun canManage(targetUsername: String): Boolean {
|
||||||
val targetUser = userByUsername(targetUsername)
|
val targetUser = getByUsernameNonNull(targetUsername)
|
||||||
return canManage(targetUser)
|
return canManage(targetUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,14 +256,14 @@ class UserService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setUserEnabled(username: String, enabled: Boolean) {
|
fun setUserEnabled(username: String, enabled: Boolean) {
|
||||||
val user = userByUsername(username)
|
val user = getByUsernameNonNull(username)
|
||||||
user.enabled = enabled
|
user.enabled = enabled
|
||||||
userRepository.save(user)
|
userRepository.save(user)
|
||||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteUser(username: String) {
|
fun deleteUser(username: String) {
|
||||||
val user = userByUsername(username)
|
val user = getByUsernameNonNull(username)
|
||||||
userRepository.delete(user)
|
userRepository.delete(user)
|
||||||
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl()))
|
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl()))
|
||||||
}
|
}
|
||||||
@@ -279,8 +283,4 @@ class UserService(
|
|||||||
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
|
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
|
||||||
return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
|
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