WIP: Implement config in Frontend

This commit is contained in:
Simon Grimme 2024-09-10 16:28:26 +02:00
parent 0a3245ddf9
commit 3b97b6bbfa
23 changed files with 464 additions and 15697 deletions

View File

@ -4,7 +4,7 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--stacktrace" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>

View File

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080" useFirstLineBreakpoints="true">
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080" useFirstLineBreakpoints="true">
<method v="2" />
</configuration>
</component>

15631
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
import {useField} from "formik";
import {Checkbox} from "@nextui-org/react";
// @ts-ignore
const CheckboxInput = ({label, ...props}) => {
// @ts-ignore
const [field] = useField(props);
return (
<div className="flex flex-row flex-grow items-center gap-2 my-2">
<Checkbox
{...field}
id={field.name}>
{label}
</Checkbox>
</div>
);
}
export default CheckboxInput;

View File

@ -8,12 +8,12 @@ const Input = ({label, ...props}) => {
const [field, meta] = useField(props);
return (
<div className="grid w-full max-w-sm items-center gap-1.5">
<div className="grid w-full max-w-sm items-center gap-2 my-2">
<NextUiInput
{...props}
{...field}
id={label}
placeholder={label}
label={label}
isInvalid={meta.touched && !!meta.error}
errorMessage={
<small className="flex flex-row items-center gap-1 text-danger">

View File

@ -11,12 +11,12 @@ export default function ProfileMenu() {
{
label: "My Profile",
icon: <User/>,
onClick: () => navigate('/profile')
onClick: () => navigate('/profile/')
},
{
label: "Administration",
icon: <GearFine/>,
onClick: () => alert("Administration"),
onClick: () => navigate("/administration/libraries"),
showIf: state.user?.roles?.some(a => a?.includes("ADMIN"))
},
{

View File

@ -0,0 +1,29 @@
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import React from "react";
import Input from "Frontend/components/Input";
import CheckboxInput from "Frontend/components/CheckboxInput";
export default function ConfigFormField({configElement}: {
configElement: ConfigEntryDto | undefined
}) {
function inputElement(configElement: ConfigEntryDto) {
switch (configElement.type) {
case "Boolean":
return (
<CheckboxInput label={configElement.description} name={configElement.key}/>
);
case "String":
return (
<Input label={configElement.description} name={configElement.key} type="text"/>
);
case "Int" || "Float":
return (
<Input label={configElement.description} name={configElement.key} type="number"/>
);
default:
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
}
}
return (inputElement(configElement!));
}

View File

@ -0,0 +1,153 @@
import React, {useEffect, useRef, useState} from "react";
import {ConfigController} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import {Button, Divider, Skeleton} from "@nextui-org/react";
import {toast} from "sonner";
type NestedConfig = {
[field: string]: any;
}
type ConfigValuePair = {
key: string;
value: string | number | boolean | null | undefined;
}
export function LibraryManagement() {
const isInitialized = useRef(false);
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
useEffect(() => {
ConfigController.getConfigs("library").then((response: any) => {
setConfigDtos(response as ConfigEntryDto[]);
isInitialized.current = true;
});
}, []);
async function handleSubmit(values: NestedConfig) {
const configValues = toConfigValuePair(values);
await Promise.all(configValues.map(async (c: ConfigValuePair) => {
if (c.value === null || c.value === undefined) {
await ConfigController.deleteConfig(c.key);
return;
}
await ConfigController.setConfig(c.key, c.value.toString());
}));
toast.success("Configuration saved");
}
function getConfig(key: string) {
return configDtos.find((configDto: ConfigEntryDto) => configDto.key === key);
}
function toNestedConfig(configArray: ConfigEntryDto[]): NestedConfig {
const nestedConfig: NestedConfig = {};
configArray.forEach(item => {
const keys = item.key!.split('.');
let currentLevel = nestedConfig;
// Traverse the nested structure and create objects as needed
keys.forEach((key, index) => {
if (index === keys.length - 1) {
// Convert value to the appropriate type
let value: any;
switch (item.type) {
case 'Boolean':
value = item.value === 'true';
break;
case 'Int':
value = parseInt(item.value!);
break;
case 'Float':
value = parseFloat(item.value!);
break;
case 'String':
default:
value = item.value;
break;
}
currentLevel[key] = value;
} else {
if (!currentLevel[key]) {
currentLevel[key] = {};
}
currentLevel = currentLevel[key];
}
});
});
return nestedConfig;
}
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePair[] {
let result: ConfigValuePair[] = [];
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
result = result.concat(toConfigValuePair(obj[key], newKey));
} else {
result.push({key: newKey, value: obj[key]});
}
}
}
return result;
}
if (!isInitialized.current) {
return (
<>
<Skeleton className="h-3 w-3/5 rounded-md"/>
<Skeleton className="h-3 w-4/5 rounded-md"/>
</>
)
}
return (
<Formik
initialValues={toNestedConfig(configDtos)}
onSubmit={handleSubmit}
>
{(formik: { values: any; isSubmitting: any; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h1 className="text-2xl font-bold">Library Management</h1>
<Button
color="secondary"
isLoading={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button>
</div>
<div className="mb-8 flex flex-col flex-grow">
<ConfigFormField configElement={getConfig("library.allow-public-access")}></ConfigFormField>
<ConfigFormField
configElement={getConfig("library.scan.enable-filesystem-watcher")}></ConfigFormField>
<h2 className="text-xl font-bold mt-4">Metadata</h2>
<Divider/>
<div className="flex flex-row">
<ConfigFormField
configElement={getConfig("library.metadata.update.enabled")}></ConfigFormField>
<ConfigFormField
configElement={getConfig("library.metadata.update.schedule")}></ConfigFormField>
</div>
<ConfigFormField
configElement={getConfig("library.display.games-per-page")}></ConfigFormField>
</div>
<pre>{JSON.stringify(formik.values, null, 2)}</pre>
</Form>
)}
</Formik>
);
}

View File

@ -52,7 +52,6 @@ const Wizard = ({children, initialValues, onSubmit}: {
{(formik: { values: any; isSubmitting: any; }) => (
<Form className="flex flex-col h-full">
<div className="w-full mb-8">
{/*<p>Step {stepNumber + 1} of {steps.length}</p>*/}
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
lineClassName="bg-foreground"
placeholder={undefined}

View File

@ -7,6 +7,8 @@ import SetupView from "Frontend/views/SetupView";
import ProfileView from "Frontend/views/ProfileView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
import AdministrationView from "Frontend/views/AdministrationView";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
export const routes = protectRoutes([
{
@ -26,6 +28,13 @@ export const routes = protectRoutes([
children: [
{path: 'appearance', element: <ThemeSelector/>}
]
},
{
path: 'administration',
element: <AdministrationView/>,
children: [
{path: 'libraries', element: <LibraryManagement/>},
]
}
]
},

View File

@ -0,0 +1,13 @@
import {useEffect, useRef} from "react";
export default function useUpdateEffect(effect: Function, dependencies?: [any]) {
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
return effect();
}
}, dependencies);
}

View File

@ -0,0 +1,45 @@
import {Outlet, useNavigate} from "react-router-dom";
import {Envelope, GameController, Users} from "@phosphor-icons/react";
import {Listbox, ListboxItem} from "@nextui-org/react";
export default function AdministrationView() {
const navigate = useNavigate();
const menuItems = [
{
title: "Libraries",
key: "libraries",
icon: <GameController/>,
action: () => navigate('libraries')
},
{
title: "Users",
key: "users",
icon: <Users/>,
action: () => navigate('users')
},
{
title: "Notifications",
icon: <Envelope/>,
key: "notifications",
action: () => navigate('notifications')
}
]
return (
<div className="flex flex-row">
<div className="flex flex-col pr-8">
<Listbox className="min-w-60">
{menuItems.map((i) => (
<ListboxItem key={i.key} onPress={i.action} startContent={i.icon}>
{i.title}
</ListboxItem>
))}
</Listbox>
</div>
<div className="flex flex-col flex-grow">
<Outlet/>
</div>
</div>
);
}

View File

@ -5,7 +5,6 @@ import {useState} from "react";
export default function ProfileView() {
const navigate = useNavigate();
const [selectedKeys, setSelectedKeys] = useState(new Set(["profile"]));
const menuItems = [
{

View File

@ -1,6 +1,7 @@
package de.grimsi.gameyfin.config
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.meta.Roles
import jakarta.annotation.security.RolesAllowed
@ -10,12 +11,16 @@ class ConfigController(
private val appConfigService: ConfigService
) {
fun getConfigs(prefix: String?): List<ConfigEntryDto> {
return appConfigService.getAllConfigValues(prefix)
}
fun getConfig(key: String): String {
return appConfigService.getConfigValue(key)
}
fun setConfig(config: Pair<String, String>) {
appConfigService.setConfigValue(config.first, config.second)
fun setConfig(key: String, value: String) {
appConfigService.setConfigValue(key, value)
}
fun resetConfig(key: String) {

View File

@ -0,0 +1,90 @@
package de.grimsi.gameyfin.config
import java.io.Serializable
import kotlin.reflect.KClass
sealed class ConfigProperties<T : Serializable>(
val type: KClass<T>,
val key: String,
val description: String,
val default: T? = null
) {
/** Libraries */
data object LibraryAllowPublicAccess :
ConfigProperties<Boolean>(
Boolean::class,
"library.allow-public-access",
"Allow access to game libraries without login",
false
)
data object LibraryEnableFilesystemWatcher :
ConfigProperties<Boolean>(
Boolean::class,
"library.scan.enable-filesystem-watcher",
"Enable automatic library scanning using file system watchers",
true
)
data object LibraryMetadataUpdateEnabled :
ConfigProperties<Boolean>(
Boolean::class,
"library.metadata.update.enabled",
"Enable periodic refresh of video game meta-data",
true
)
data object LibraryMetadataUpdateSchedule :
ConfigProperties<String>(
String::class,
"library.metadata.update.schedule",
"Schedule for periodic metadata refresh in cron format",
"0 0 * * 0"
)
data object LibraryGamesPerPage :
ConfigProperties<Int>(
Int::class,
"library.display.games-per-page",
"How many games should be displayed per page",
25
)
data object LibraryRatingCutoff :
ConfigProperties<Float>(
Float::class,
"library.display.rating-cutoff",
"Minimum rating for games to be displayed",
4.5f
)
/** User management */
data object UsersAllowNewSignUps : ConfigProperties<Boolean>(
Boolean::class,
"users.sign-ups.allow",
"Allow new users to sign up by themselves",
false
)
data object UsersConfirmNewSignUps :
ConfigProperties<Boolean>(
Boolean::class,
"users.sign-ups.confirm",
"Admins need to confirm new sign-ups before they are allowed to log in",
false
)
/** Notifications */
data object NotificationsEmailHost :
ConfigProperties<String>(String::class, "notifications.email.host", "URL of the email server")
data object NotificationsEmailPort :
ConfigProperties<String>(String::class, "notifications.email.port", "Port of the email server")
data object NotificationsEmailUsername :
ConfigProperties<String>(String::class, "notifications.email.username", "Username for the email account")
data object NotificationsEmailPassword :
ConfigProperties<String>(String::class, "notifications.email.password", "Password for the email account")
}

View File

@ -1,38 +0,0 @@
package de.grimsi.gameyfin.config
import java.io.Serializable
import kotlin.reflect.KClass
sealed class ConfigProperty<T : Serializable>(val type: KClass<T>, val key: String, val default: T? = null) {
/** Libraries */
// Allow access to game libraries without login
data object LibraryAllowPublicAccess :
ConfigProperty<Boolean>(Boolean::class, "library.allow-public-access", false)
// Enable automatic library scanning using file system watchers
data object LibraryEnableFilesystemWatcher :
ConfigProperty<Boolean>(Boolean::class, "library.scan.enable-filesystem-watcher", true)
// Enable periodic refresh of video game meta-data and set the schedule (default is once per week)
data object LibraryMetadataUpdateEnabled :
ConfigProperty<Boolean>(Boolean::class, "library.metadata.update.enabled", true)
data object LibraryMetadataUpdateSchedule :
ConfigProperty<String>(String::class, "library.metadata.update.schedule", "0 0 * * 0")
/** User management */
// Allow new users to sign up by themselves
data object UsersAllowNewSignUps : ConfigProperty<Boolean>(Boolean::class, "users.sign-ups.allow", false)
// If an administrator needs to confirm new sign-ups before they are allowed to log in
data object UsersConfirmNewSignUps :
ConfigProperty<Boolean>(Boolean::class, "users.sign-ups.confirm", false)
/** Notifications */
// Settings for the mail server used by Gameyfin to send notifications
data object NotificationsEmailHost : ConfigProperty<String>(String::class, "notifications.email.host")
data object NotificationsEmailPort : ConfigProperty<String>(String::class, "notifications.email.port")
data object NotificationsEmailUsername : ConfigProperty<String>(String::class, "notifications.email.username")
data object NotificationsEmailPassword : ConfigProperty<String>(String::class, "notifications.email.password")
}

View File

@ -1,11 +1,11 @@
package de.grimsi.gameyfin.config
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import java.io.Serializable
import kotlin.reflect.safeCast
@Service
@Transactional
@ -13,6 +13,33 @@ class ConfigService(
private val appConfigRepository: ConfigRepository
) {
/**
* Get all known config values.
*
* @param prefix: Optional prefix to filter the config values
* @return A map of all config values
*/
fun getAllConfigValues(prefix: String?): List<ConfigEntryDto> {
var configProperties = ConfigProperties::class.sealedSubclasses.flatMap { subclass ->
subclass.objectInstance?.let { listOf(it) } ?: listOf()
}
if (prefix != null) {
configProperties = configProperties.filter { it.key.startsWith(prefix) }
}
return configProperties.map { configProperty ->
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
ConfigEntryDto(
key = configProperty.key,
value = appConfig?.value ?: configProperty.default?.toString(),
defaultValue = configProperty.default?.toString(),
type = configProperty.type.simpleName ?: "Unknown",
description = configProperty.description
)
}
}
/**
* Get the current value of a config property in a type-safe way.
* Used internally.
@ -21,7 +48,7 @@ class ConfigService(
* @return The current value if set or the default value
* @throws IllegalArgumentException if no value is set and no default value exists
*/
fun <T : Serializable> getConfigValue(configProperty: ConfigProperty<T>): T {
fun <T : Serializable> getConfigValue(configProperty: ConfigProperties<T>): T {
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
return if (appConfig != null) {
getValue(appConfig.value, configProperty)
@ -61,12 +88,18 @@ class ConfigService(
fun <T : Serializable> setConfigValue(key: String, value: T) {
val configKey = findConfigProperty(key)
if (configKey.type.safeCast(value) == null) {
throw IllegalArgumentException("Type mismatch for key: ${configKey.key}")
// Check if the value can be cast to the type defined for the config property
val castedValue = getValue(value.toString(), configKey)
var configEntry = appConfigRepository.findById(key).orElse(null)
if (configEntry == null) {
configEntry = ConfigEntry(configKey.key, castedValue.toString())
} else {
configEntry.value = castedValue.toString()
}
val appConfig = ConfigEntry(configKey.key, value.toString())
appConfigRepository.save(appConfig)
appConfigRepository.save(configEntry)
}
/**
@ -104,11 +137,11 @@ class ConfigService(
* Get the value of the config property in a type-safe way.
*/
@Suppress("UNCHECKED_CAST")
private fun <T : Serializable> getValue(value: String, configProperty: ConfigProperty<T>): T {
private fun <T : Serializable> getValue(value: String, configProperty: ConfigProperties<T>): T {
return when (configProperty.type) {
String::class -> value as T
Boolean::class -> value.toBoolean() as T
Number::class -> value.toInt() as T
Int::class -> value.toInt() as T
Float::class -> value.toFloat() as T
else -> {
throw RuntimeException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
@ -119,9 +152,9 @@ class ConfigService(
/**
* Returns a config property
*/
private fun findConfigProperty(key: String): ConfigProperty<*> {
private fun findConfigProperty(key: String): ConfigProperties<*> {
// Use reflection to get all objects defined within ConfigKey
val configProperties = ConfigProperty::class.sealedSubclasses.flatMap { subclass ->
val configProperties = ConfigProperties::class.sealedSubclasses.flatMap { subclass ->
subclass.objectInstance?.let { listOf(it) } ?: listOf()
}

View File

@ -0,0 +1,13 @@
package de.grimsi.gameyfin.config.dto
import com.fasterxml.jackson.annotation.JsonInclude
import jakarta.annotation.Nonnull
@JsonInclude(JsonInclude.Include.ALWAYS)
data class ConfigEntryDto(
@Nonnull val key: String,
val value: String?,
val defaultValue: String?,
@Nonnull val type: String,
@Nonnull val description: String
)

View File

@ -11,9 +11,10 @@ import jakarta.validation.constraints.NotNull
class ConfigEntry(
@Id
@NotNull
@Column(unique = true)
@Column(name = "`key`", unique = true)
val key: String,
@NotNull
@Column(name = "`value`")
var value: String
)

View File

@ -1,14 +1,22 @@
package de.grimsi.gameyfin.meta
import de.grimsi.gameyfin.meta.annotations.DynamicAccessInterceptor
import de.grimsi.gameyfin.meta.development.DelayInterceptor
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig(val dynamicAccessInterceptor: DynamicAccessInterceptor) : WebMvcConfigurer {
class WebConfig(
private val dynamicAccessInterceptor: DynamicAccessInterceptor
) : WebMvcConfigurer {
@Autowired(required = false)
private var delayInterceptor: DelayInterceptor? = null
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(dynamicAccessInterceptor)
delayInterceptor?.let { registry.addInterceptor(it) }
}
}

View File

@ -1,6 +1,6 @@
package de.grimsi.gameyfin.meta.annotations
import de.grimsi.gameyfin.config.ConfigProperty
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
@ -24,7 +24,7 @@ class DynamicAccessInterceptor(
// Check if method is annotated with @DynamicPublicAccess
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
// Check if user is authenticated or public access is enabled
if (request.userPrincipal != null || configService.getConfigValue(ConfigProperty.LibraryAllowPublicAccess)) {
if (request.userPrincipal != null || configService.getConfigValue(ConfigProperties.LibraryAllowPublicAccess)) {
return true
}

View File

@ -0,0 +1,19 @@
package de.grimsi.gameyfin.meta.development
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
@Component
@Profile("development")
class DelayInterceptor : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
Thread.sleep(2000)
return true
}
}

View File

@ -8,14 +8,14 @@ import jakarta.validation.constraints.NotNull
@Entity
@Table(name = "users")
class User(
@NotNull
@Column(unique = true)
var username: String,
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@NotNull
@Column(unique = true)
var username: String,
@NotNull
var password: String,