Preparation for plugins

This commit is contained in:
grimsi
2024-10-08 21:15:49 +02:00
parent aef4d124e7
commit fa685dc541
201 changed files with 4339 additions and 2981 deletions
+93
View File
@@ -0,0 +1,93 @@
group = "de.grimsi"
plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
id("com.vaadin")
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
java
}
allOpen {
annotations("javax.persistence.Entity", "javax.persistence.MappedSuperclass", "javax.persistence.Embedabble")
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
maven {
setUrl("https://maven.vaadin.com/vaadin-addons")
}
}
dependencies {
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.cloud:spring-cloud-starter")
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
// Kotlin extensions
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Reactive
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
// Vaadin Hilla
implementation("com.vaadin:vaadin-core") {
exclude("com.vaadin:flow-react")
}
api("com.vaadin:vaadin-spring-boot-starter")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
// Persistence & I/O
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14")
implementation("commons-io:commons-io:2.16.1")
// SSO
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.security:spring-security-oauth2-jose")
// Notifications
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("ch.digitalfondue.mjml4j:mjml4j:1.0.3")
// Plugins
implementation(project(":plugin-api"))
implementation("org.pf4j:pf4j-spring:${rootProject.extra["pf4jSpringVersion"]}") {
exclude("org.slf4j")
}
// Development
developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
runtimeOnly("com.h2database:h2")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}
dependencyManagement {
imports {
mavenBom("com.vaadin:vaadin-bom:${rootProject.extra["vaadinVersion"]}")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${rootProject.extra["springCloudVersion"]}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/main/frontend/main.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+26
View File
@@ -0,0 +1,26 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
com-h2database-h2 = "2.2.224"
dev-hilla-hilla-react = "2.5.6"
dev-hilla-hilla-spring-boot-starter = "2.5.6"
org-parttio-line-awesome = "2.0.0"
org-springframework-boot-spring-boot-devtools = "3.2.2"
org-springframework-boot-spring-boot-starter-data-jpa = "3.2.2"
org-springframework-boot-spring-boot-starter-oauth2-resource-server = "3.2.2"
org-springframework-boot-spring-boot-starter-security = "3.2.2"
org-springframework-boot-spring-boot-starter-test = "3.2.2"
org-springframework-boot-spring-boot-starter-validation = "3.2.2"
[libraries]
com-h2database-h2 = { module = "com.h2database:h2", version.ref = "com-h2database-h2" }
dev-hilla-hilla-react = { module = "dev.hilla:hilla-react", version.ref = "dev-hilla-hilla-react" }
dev-hilla-hilla-spring-boot-starter = { module = "dev.hilla:hilla-spring-boot-starter", version.ref = "dev-hilla-hilla-spring-boot-starter" }
org-parttio-line-awesome = { module = "org.parttio:line-awesome", version.ref = "org-parttio-line-awesome" }
org-springframework-boot-spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools", version.ref = "org-springframework-boot-spring-boot-devtools" }
org-springframework-boot-spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "org-springframework-boot-spring-boot-starter-data-jpa" }
org-springframework-boot-spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server", version.ref = "org-springframework-boot-spring-boot-starter-oauth2-resource-server" }
org-springframework-boot-spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "org-springframework-boot-spring-boot-starter-security" }
org-springframework-boot-spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "org-springframework-boot-spring-boot-starter-test" }
org-springframework-boot-spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "org-springframework-boot-spring-boot-starter-validation" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+7
View File
@@ -0,0 +1,7 @@
import {NextUIPluginConfig} from "@nextui-org/react";
import {compileThemes, themes} from "./src/main/frontend/theming/themes"
export const NextUIConfig: NextUIPluginConfig = {
prefix: "gf",
themes: compileThemes(themes)
};
+26037
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
{
"name": "gameyfin",
"version": "2.0.0-ALPHA",
"type": "module",
"dependencies": {
"@material-tailwind/react": "^2.1.9",
"@nextui-org/react": "^2.4.8",
"@phosphor-icons/react": "^2.1.7",
"@polymer/polymer": "3.5.1",
"@vaadin/bundles": "24.4.10",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.4.9",
"@vaadin/hilla-frontend": "24.4.9",
"@vaadin/hilla-lit-form": "24.4.9",
"@vaadin/hilla-react-auth": "24.4.9",
"@vaadin/hilla-react-crud": "24.4.9",
"@vaadin/hilla-react-form": "24.4.9",
"@vaadin/hilla-react-i18n": "24.4.9",
"@vaadin/hilla-react-signals": "24.4.9",
"@vaadin/polymer-legacy-adapter": "24.4.10",
"@vaadin/react-components": "24.4.10",
"@vaadin/router": "1.7.5",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.4.10",
"@vaadin/vaadin-material-styles": "24.4.10",
"@vaadin/vaadin-themable-mixin": "24.4.10",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"construct-style-sheets-polyfill": "3.1.0",
"cron-validator": "^1.3.1",
"date-fns": "2.29.3",
"formik": "^2.4.6",
"framer-motion": "^11.3.28",
"http-status-codes": "^2.3.0",
"lit": "3.1.4",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"next-themes": "^0.3.0",
"react": "18.3.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "18.3.1",
"react-router-dom": "6.26.2",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"yup": "^1.4.0"
},
"devDependencies": {
"@babel/preset-react": "7.24.7",
"@lit-labs/react": "^2.1.3",
"@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0",
"@types/node": "^22.4.0",
"@types/react": "18.3.4",
"@types/react-dom": "18.3.0",
"@vaadin/hilla-generator-cli": "24.4.9",
"@vaadin/hilla-generator-core": "24.4.9",
"@vaadin/hilla-generator-plugin-backbone": "24.4.9",
"@vaadin/hilla-generator-plugin-barrel": "24.4.9",
"@vaadin/hilla-generator-plugin-client": "24.4.9",
"@vaadin/hilla-generator-plugin-model": "24.4.9",
"@vaadin/hilla-generator-plugin-push": "24.4.9",
"@vaadin/hilla-generator-plugin-subtypes": "24.4.9",
"@vaadin/hilla-generator-utils": "24.4.9",
"@vitejs/plugin-react": "4.3.1",
"@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6",
"autoprefixer": "^10.4.20",
"glob": "10.4.5",
"postcss": "^8.4.41",
"postcss-import": "^16.1.0",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.12.0",
"strip-css-comments": "5.0.0",
"tailwindcss": "^3.4.10",
"transform-ast": "2.4.4",
"typescript": "5.4.5",
"vite": "5.4.6",
"vite-plugin-checker": "0.6.4",
"workbox-build": "7.1.1",
"workbox-core": "7.1.0",
"workbox-precaching": "7.1.0"
},
"overrides": {
"classnames": "$classnames",
"react": "$react",
"react-dom": "18.3.1",
"react-router-dom": "$react-router-dom",
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@vaadin/router": "$@vaadin/router",
"@polymer/polymer": "$@polymer/polymer",
"@phosphor-icons/react": "$@phosphor-icons/react",
"formik": "$formik",
"yup": "$yup",
"class-variance-authority": "$class-variance-authority",
"clsx": "$clsx",
"next-themes": "$next-themes",
"tailwind-merge": "$tailwind-merge",
"@nextui-org/react": "$@nextui-org/react",
"framer-motion": "$framer-motion",
"@material-tailwind/react": "$@material-tailwind/react",
"sonner": "$sonner",
"http-status-codes": "$http-status-codes",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/react-components": "$@vaadin/react-components",
"@vaadin/hilla-frontend": "$@vaadin/hilla-frontend",
"@vaadin/hilla-react-auth": "$@vaadin/hilla-react-auth",
"@vaadin/hilla-react-crud": "$@vaadin/hilla-react-crud",
"@vaadin/hilla-file-router": "$@vaadin/hilla-file-router",
"@vaadin/hilla-react-i18n": "$@vaadin/hilla-react-i18n",
"@vaadin/hilla-lit-form": "$@vaadin/hilla-lit-form",
"@vaadin/hilla-react-form": "$@vaadin/hilla-react-form",
"@vaadin/hilla-react-signals": "$@vaadin/hilla-react-signals",
"cron-validator": "$cron-validator",
"moment": "$moment",
"moment-timezone": "$moment-timezone",
"react-confetti-boom": "$react-confetti-boom",
"date-fns": "$date-fns",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.1",
"@vaadin/bundles": "24.4.10",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.4.9",
"@vaadin/hilla-frontend": "24.4.9",
"@vaadin/hilla-lit-form": "24.4.9",
"@vaadin/hilla-react-auth": "24.4.9",
"@vaadin/hilla-react-crud": "24.4.9",
"@vaadin/hilla-react-form": "24.4.9",
"@vaadin/hilla-react-i18n": "24.4.9",
"@vaadin/hilla-react-signals": "24.4.9",
"@vaadin/polymer-legacy-adapter": "24.4.10",
"@vaadin/react-components": "24.4.10",
"@vaadin/router": "1.7.5",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.4.10",
"@vaadin/vaadin-material-styles": "24.4.10",
"@vaadin/vaadin-themable-mixin": "24.4.10",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.1.4",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.26.2"
},
"devDependencies": {
"@babel/preset-react": "7.24.7",
"@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0",
"@types/react": "18.3.4",
"@types/react-dom": "18.3.0",
"@vaadin/hilla-generator-cli": "24.4.9",
"@vaadin/hilla-generator-core": "24.4.9",
"@vaadin/hilla-generator-plugin-backbone": "24.4.9",
"@vaadin/hilla-generator-plugin-barrel": "24.4.9",
"@vaadin/hilla-generator-plugin-client": "24.4.9",
"@vaadin/hilla-generator-plugin-model": "24.4.9",
"@vaadin/hilla-generator-plugin-push": "24.4.9",
"@vaadin/hilla-generator-plugin-subtypes": "24.4.9",
"@vaadin/hilla-generator-utils": "24.4.9",
"@vitejs/plugin-react": "4.3.1",
"async": "3.2.6",
"glob": "10.4.5",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.12.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.4.5",
"vite": "5.4.6",
"vite-plugin-checker": "0.6.4",
"workbox-build": "7.1.1",
"workbox-core": "7.1.0",
"workbox-precaching": "7.1.0"
},
"hash": "a68514b338dad1fa4545d4146d922460e94886a6976ced0ad34d92de450f092b"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,58 @@
import * as React from "react"
import {cva, type VariantProps} from "class-variance-authority"
import {cn} from "Frontend/util/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({className, variant, ...props}, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({variant}), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({className, ...props}, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({className, ...props}, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export {Alert, AlertTitle, AlertDescription}
@@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-content1 group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }
+30
View File
@@ -0,0 +1,30 @@
import {Outlet, useHref, useNavigate} from 'react-router-dom';
import "./main.css";
import "Frontend/util/custom-validators";
import {NextUIProvider} from "@nextui-org/react";
import {ThemeProvider as NextThemesProvider} from "next-themes";
import {themeNames} from "Frontend/theming/themes";
import {AuthProvider} from "Frontend/util/auth";
import {IconContext} from "@phosphor-icons/react";
import {Toaster} from "Frontend/@/components/ui/sonner";
import client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
export default function App() {
const navigate = useNavigate();
client.middlewares = [ErrorHandlingMiddleware];
return (
<NextUIProvider className="size-full" navigate={navigate} useHref={useHref}>
<NextThemesProvider attribute="class" themes={themeNames()} defaultTheme="gameyfin-violet-dark">
<AuthProvider>
<IconContext.Provider value={{size: 20}}>
<Outlet/>
<Toaster/>
</IconContext.Provider>
</AuthProvider>
</NextThemesProvider>
</NextUIProvider>
);
}
@@ -0,0 +1,83 @@
import {useAuth} from "Frontend/util/auth";
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@nextui-org/react";
import {useNavigate} from "react-router-dom";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import {CollectionElement} from "@react-types/shared";
export default function ProfileMenu() {
const auth = useAuth();
const navigate = useNavigate();
async function logout() {
if (auth.state.user?.managedBySso) {
window.location.href = await ConfigEndpoint.getLogoutUrl() || "/";
} else {
await auth.logout();
}
}
const profileMenuItems = [
{
label: "My Profile",
icon: <User/>,
onClick: () => navigate('/settings/profile')
},
{
label: "Administration",
icon: <GearFine/>,
onClick: () => navigate("/administration/libraries"),
showIf: auth.state.user?.roles?.some(a => a?.includes("ADMIN"))
},
{
label: "Help",
icon: <Question/>,
onClick: () => window.open("https://github.com/gameyfin/gameyfin/tree/v2", "_blank")
},
{
label: "Sign Out",
icon: <SignOut/>,
onClick: logout,
color: "primary"
},
];
// @ts-ignore
return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
{/* div is necessary so dropdown menu will appear in the correct place */}
<div>
<Avatar radius="full"
as="button"
className="transition-transform size-8"
classNames={{
base: "gradient-primary",
icon: "text-background/80"
}}
/>
</div>
</DropdownTrigger>
<DropdownMenu disabledKeys={["username"]}>
<DropdownItem key="username">
<p className="font-bold">Signed in as {auth.state.user?.username}</p>
</DropdownItem>
{profileMenuItems.filter(item => item.showIf !== false).map(({label, icon, onClick, color}) => {
return (
<DropdownItem
key={label}
onClick={onClick}
startContent={<div color={color}>{icon}</div>}
/* @ts-ignore */
color={color ? color : ""}
className={`text-${color} hover:bg-primary/20`}
>
{label}
</DropdownItem>
);
}) as unknown as CollectionElement<object>}
</DropdownMenu>
</Dropdown>
);
}
@@ -0,0 +1,43 @@
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import React from "react";
import Input from "Frontend/components/general/Input";
import CheckboxInput from "Frontend/components/general/CheckboxInput";
import SelectInput from "Frontend/components/general/SelectInput";
export default function ConfigFormField({configElement, ...props}: any) {
function inputElement(configElement: ConfigEntryDto) {
if (configElement.allowedValues != null && configElement.allowedValues.length > 0) {
return (
<SelectInput label={configElement.description} name={configElement.key}
values={configElement.allowedValues} {...props}/>
);
}
switch (configElement.type) {
case "Boolean":
return (
<CheckboxInput label={configElement.description} name={configElement.key} {...props}/>
);
case "String":
return (
<Input label={configElement.description} name={configElement.key}
type={props.type && "text"} {...props}/>
);
case "Float":
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="0.1" {...props}/>
);
case "Int":
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="1" {...props}/>
);
default:
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
}
}
return (inputElement(configElement!));
}
@@ -0,0 +1,41 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
function LibraryManagementLayout({getConfig, formik}: any) {
return (
<div className="flex flex-col">
<Section title="Library"/>
{/* TODO */}
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
<Section title="Scanning"/>
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
<Section title="Metadata"/>
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
// @ts-ignore
schedule: Yup.string().cron()
})
})
})
});
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", "library", validationSchema);
@@ -0,0 +1,90 @@
import React, {useEffect, useRef, useState} from "react";
import {LogEndpoint} from "Frontend/generated/endpoints";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import {toast} from "sonner";
import {Button, Code, Divider, Tooltip} from "@nextui-org/react";
import {ArrowUDownLeft, SortAscending} from "@phosphor-icons/react";
function LogManagementLayout({getConfig, formik}: any) {
const [logEntries, setLogEntries] = useState<string[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [softWrap, setSoftWrap] = useState(false);
const logEndRef = useRef<null | HTMLDivElement>(null);
useEffect(() => {
const sub = LogEndpoint.getApplicationLogs().onNext((newEntry: string | undefined) =>
setLogEntries((currentEntries) => [...currentEntries, newEntry as string])
);
return () => sub.cancel();
}, []);
useEffect(() => {
if (formik.isSubmitting == false && formik.submitCount > 0) {
LogEndpoint.reloadLogConfig()
.catch(() => toast.error("Failed to apply log configuration"));
}
}, [formik.isSubmitting]);
useEffect(() => {
if (autoScroll) {
scrollToBottom();
}
}, [logEntries, autoScroll, softWrap]);
function scrollToBottom() {
logEndRef.current?.scrollIntoView();
}
return (
<div className="flex flex-col">
<div className="flex flex-row gap-4">
<ConfigFormField configElement={getConfig("logs.folder")}/>
<ConfigFormField configElement={getConfig("logs.max-history-days")}/>
<ConfigFormField configElement={getConfig("logs.level")}/>
</div>
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between items-baseline">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
<div className="flex flex-row gap-1">
<Tooltip content="Soft-wrap" placement="bottom">
<Button isIconOnly
onPress={() => setSoftWrap(!softWrap)}
variant={softWrap ? "solid" : "ghost"}
>
<ArrowUDownLeft/>
</Button>
</Tooltip>
<Tooltip content="Auto-scroll" placement="bottom">
<Button isIconOnly
onPress={() => setAutoScroll(!autoScroll)}
variant={autoScroll ? "solid" : "ghost"}
>
<SortAscending/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
</div>
<Code size="sm" radius="none"
className={`flex flex-col h-[50vh] max-h-[50vh] text-sm overflow-auto ${softWrap ? "whitespace-normal break-words" : "whitespace-nowrap"}`}>
{logEntries.map((entry, index) => <p key={index}>{entry}</p>)}
<div ref={logEndRef}/>
</Code>
</div>
);
}
const validationSchema = Yup.object({
logs: Yup.object({
folder: Yup.string().required("Required"),
"max-history-days": Yup.number().required("Required"),
level: Yup.string().required("Required")
})
});
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", "logs", validationSchema);
@@ -0,0 +1,122 @@
import React, {useEffect, useState} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {Button, Card, Tooltip, useDisclosure} from "@nextui-org/react";
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
function MessageManagementLayout({getConfig, getConfigs, formik}: any) {
const editorModal = useDisclosure();
const testNotificationModal = useDisclosure();
const [availableTemplates, setAvailableTemplates] = useState<MessageTemplateDto[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<MessageTemplateDto | null>(null);
useEffect(() => {
MessageTemplateEndpoint.getAll().then((response: any) => {
setAvailableTemplates(response as MessageTemplateDto[]);
});
}, []);
async function verifyCredentials(provider: string) {
const credentials: Record<string, any> = {
host: formik.values.messages.providers.email.host,
port: formik.values.messages.providers.email.port,
username: formik.values.messages.providers.email.username,
password: formik.values.messages.providers.email.password
}
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
if (areCredentialsValid) {
toast.success("Credentials are valid")
} else {
toast.error("Credentials are invalid")
}
}
async function openEditor(template: MessageTemplateDto) {
setSelectedTemplate(template);
editorModal.onOpen();
}
function openTestNotification(template: MessageTemplateDto) {
setSelectedTemplate(template);
testNotificationModal.onOpen();
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<div className="flex flex-row gap-8">
<div className="flex flex-col flex-1 h-fit">
<Section title="E-Mail"/>
<ConfigFormField configElement={getConfig("messages.providers.email.enabled")}/>
<ConfigFormField configElement={getConfig("messages.providers.email.host")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.port")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.username")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.password")}
type="password"
isDisabled={!formik.values.messages.providers.email.enabled}/>
<Button onPress={() => verifyCredentials("email")}
isDisabled={!(
formik.values.messages.providers.email.enabled &&
formik.values.messages.providers.email.host &&
formik.values.messages.providers.email.port &&
formik.values.messages.providers.email.username)}>Test</Button>
</div>
<div className="flex flex-col flex-1 h-fit">
<Section title="Message Templates"/>
<div className="flex flex-col gap-4">
{availableTemplates.map((template: MessageTemplateDto) =>
<Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
<Tooltip content="Edit template">
<Button isIconOnly
size="sm"
onPress={() => openEditor(template)}
>
<Pencil/>
</Button>
</Tooltip>
<Tooltip content="Send test notification">
<Button isIconOnly
size="sm"
onPress={() => openTestNotification(template)}
>
<PaperPlaneRight/>
</Button>
</Tooltip>
<p className="text-lg">{template.description}</p>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
<EditTemplateModal
isOpen={editorModal.isOpen}
onOpenChange={editorModal.onOpenChange}
selectedTemplate={selectedTemplate}
/>
<SendTestNotificationModal
isOpen={testNotificationModal.isOpen}
onOpenChange={testNotificationModal.onOpenChange}
selectedTemplate={selectedTemplate}
/>
</div>
);
}
export const MessageManagement = withConfigPage(MessageManagementLayout, "Messages", "messages");
@@ -0,0 +1,168 @@
import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/Input";
import {Button, Input as NextUiInput, Tooltip} from "@nextui-org/react";
import {Form, Formik} from "formik";
import {ArrowCounterClockwise, Check, Info, Trash} 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 {EmailConfirmationEndpoint, MessageEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {toast} from "sonner";
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
import Avatar from "Frontend/components/general/Avatar";
export default function ProfileManagement() {
const auth = useAuth();
const [avatar, setAvatar] = useState<any>();
const [configSaved, setConfigSaved] = useState(false);
const [messagesEnabled, setMessagesEnabled] = useState(false);
useEffect(() => {
MessageEndpoint.isEnabled().then(setMessagesEnabled);
}, []);
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
function onFileSelected(event: any) {
setAvatar(event.target.files[0]);
}
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 (
<>
<Formik
initialValues={{
username: auth.state.user?.username,
email: auth.state.user?.email,
newPassword: "",
passwordRepeat: ""
}}
onSubmit={handleSubmit}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
newPassword: Yup.string()
.min(8, 'Password must be at least 8 characters long'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('newPassword')], 'Passwords do not match')
})}
>
{(formik: { values: any; isSubmitting: any; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2>
{auth.state.user?.managedBySso &&
<p className="text-warning">Your account is managed externally.</p>}
<div className="flex flex-row items-center gap-4">
{formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info}
message="You will be logged out of all current sessions"
className="text-foreground/70"
/>
}
<Button
color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<div className="flex flex-row flex-1 justify-between gap-16">
<div className="flex flex-col basis-1/4 mt-8 gap-4">
<div className="flex flex-row justify-center">
<Avatar className="size-40 m-4 flex flex-row"/>
</div>
<div className="flex flex-row gap-2">
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}
isDisabled={auth.state.user?.managedBySso}/>
<Button onClick={() => uploadAvatar(avatar)} isDisabled={avatar == null}
color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onClick={removeAvatar} isIconOnly color="danger"
isDisabled={auth.state.user?.managedBySso}><Trash/></Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col flex-grow">
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"
isDisabled={auth.state.user?.managedBySso}/>
<div className="flex flex-row gap-4">
<Input name="email" label="Email" type="email" autocomplete="email"
isDisabled={auth.state.user?.managedBySso || !messagesEnabled}/>
{(auth.state.user?.emailConfirmed === false && !auth.state.user.managedBySso) &&
<Tooltip content="Resend email confirmation message">
<Button isIconOnly
onPress={() => {
EmailConfirmationEndpoint.resendEmailConfirmation().then(
() => toast.success("You will receive an email shortly")
)
}}
isDisabled={!messagesEnabled}
variant="ghost"
className="size-14"
>
<ArrowCounterClockwise size={26}/>
</Button>
</Tooltip>
}
</div>
{!messagesEnabled &&
<div className="flex flex-row gap-2 text-warning -mt-5">
<Info/>
<small>
Email services are disabled. Please contact your administrator.
</small>
</div>
}
<Section title="Security"/>
<Input name="newPassword" label="New Password" type="password"
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
<Input name="passwordRepeat" label="Repeat password" type="password"
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
</div>
</div>
</Form>
)}
</Formik>
</>
);
}
@@ -0,0 +1,121 @@
import React, {useEffect} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {Button} from "@nextui-org/react";
import {MagicWand} from "@phosphor-icons/react";
import {toast} from "sonner";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => {
if (formik.dirty) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
} else {
setSaveMessage(null);
}
}, [formik.dirty]);
function isAutoPopulateDisabled() {
return !formik.values.sso.oidc.enabled || !formik.values.sso.oidc["issuer-url"];
}
async function autoPopulate() {
let issuerUrl: string = formik.values.sso.oidc["issuer-url"];
if (issuerUrl.endsWith("/")) issuerUrl = issuerUrl.slice(0, -1);
try {
const response = await fetch(issuerUrl + "/.well-known/openid-configuration");
const data = await response.json();
formik.setFieldValue("sso.oidc.authorize-url", data.authorization_endpoint);
formik.setFieldValue("sso.oidc.token-url", data.token_endpoint);
formik.setFieldValue("sso.oidc.userinfo-url", data.userinfo_endpoint);
formik.setFieldValue("sso.oidc.logout-url", data.end_session_endpoint);
formik.setFieldValue("sso.oidc.jwks-url", data.jwks_uri);
} catch (e) {
toast.error("Failed to auto-populate SSO configuration");
}
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/>
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
<Section title="SSO provider configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
type="password"
isDisabled={!formik.values.sso.oidc.enabled}/>
<div className="flex flex-row gap-2">
<ConfigFormField configElement={getConfig("sso.oidc.issuer-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<Button
isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate}
className="h-14"><MagicWand className="min-w-5"/> Auto-populate</Button>
</div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.token-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.userinfo-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.logout-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.jwks-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
</div>
</div>
);
}
const validationSchema = Yup.object({
sso: Yup.object({
oidc: Yup.object({
enabled: Yup.boolean(),
"auto-register-new-users": Yup.boolean().required(),
"match-existing-users-by": Yup.string().required(),
"client-id": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client ID is required") : schema
),
"client-secret": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client Secret is required") : schema
),
"issuer-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Issuer URL is required") : schema
),
"authorize-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Authorize URL is required") : schema
),
"token-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Token URL is required") : schema
),
"userinfo-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Userinfo URL is required") : schema
),
"logout-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Logout URL is required") : schema
),
"jwks-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("JWKS URL is required") : schema
)
})
})
});
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", "sso", validationSchema);
@@ -0,0 +1,59 @@
import React, {useEffect, useState} from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import {ConfigEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import {UserManagementCard} from "Frontend/components/general/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info, UserPlus} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@nextui-org/react";
import InviteUserModal from "Frontend/components/general/InviteUserModal";
function UserManagementLayout({getConfig, formik}: any) {
const inviteUserModal = useDisclosure();
const [users, setUsers] = useState<UserInfoDto[]>([]);
const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true);
useEffect(() => {
UserEndpoint.getAllUsers().then(
(response) => setUsers(response as UserInfoDto[])
);
ConfigEndpoint.get("sso.oidc.auto-register-new-users").then(
(response) => setAutoRegisterNewUsers(response === "true")
);
}, []);
return (
<div className="flex flex-col flex-grow">
<Section title="Sign-Ups"/>
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("users.sign-ups.allow")}/>
<ConfigFormField configElement={getConfig("users.sign-ups.confirmation-required")}
isDisabled={!formik.values.users["sign-ups"].allow}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Users</h2>
{!autoRegisterNewUsers &&
<SmallInfoField className="mb-4 text-warning" icon={Info}
message="Automatic user registration for SSO users is disabled"/>
}
<Tooltip content="Invite new user">
<Button isIconOnly variant="faded" onPress={inviteUserModal.onOpen}>
<UserPlus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
<div className="grid grid-cols-300px gap-4">
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
</div>
<InviteUserModal isOpen={inviteUserModal.isOpen} onOpenChange={inviteUserModal.onOpenChange}/>
</div>
);
}
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
@@ -0,0 +1,126 @@
import React, {useEffect, useState} from "react";
import {
Button,
Chip,
Link,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea
} from "@nextui-org/react";
import {toast} from "sonner";
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/messages/templates/TemplateType";
interface EditTemplateModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto | null;
}
export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplate}: EditTemplateModalProps) {
const [templateContent, setTemplateContent] = useState<string>("");
const [defaultPlaceholders, setDefaultPlaceholders] = useState<string[]>([]);
useEffect(() => {
if (!isOpen) return;
MessageTemplateEndpoint.read(selectedTemplate?.key as string, TemplateType.MJML).then((response: any) => {
setTemplateContent(response as string);
});
MessageTemplateEndpoint.getDefaultPlaceholders(TemplateType.MJML).then((response: any) => {
setDefaultPlaceholders(response as string[]);
});
}, [isOpen]);
async function saveTemplate(template: MessageTemplateDto) {
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
}
function templateContainsAllRequiredPlaceholders(): boolean {
if (!selectedTemplate || !selectedTemplate.availablePlaceholders) return false;
return selectedTemplate.availablePlaceholders
.every((p) => templateContent.includes(`{${p}}`))
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader
className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
<ModalBody>
<div className="flex flex-row justify-between items-end">
<table cellPadding="4rem">
<tbody>
<tr>
<td>Required placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "danger"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
<tr>
<td>Optional placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{defaultPlaceholders.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "default"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
</tbody>
</table>
<small className="text-right">Powered by <Link href="https://documentation.mjml.io/"
target="_blank">mjml.io</Link></small>
</div>
<Textarea
size="lg"
autoFocus
disableAutosize
value={templateContent}
onChange={(e) => {
setTemplateContent(e.target.value)
}}
classNames={{
input: "resize-y min-h-[500px]"
}}
/>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isDisabled={!templateContainsAllRequiredPlaceholders()}
onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate);
toast.success("Template saved");
onClose();
}
}}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,73 @@
import React from "react";
import {Form, Formik} from "formik";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {toast} from "sonner";
import Input from "Frontend/components/general/Input";
import {MessageEndpoint} from "Frontend/generated/endpoints";
import * as Yup from "yup";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
interface SendTestNotificationModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto | null;
}
export default function SendTestNotificationModal({
isOpen,
onOpenChange,
selectedTemplate
}: SendTestNotificationModalProps) {
function generateValidationSchema(placeholders: string[]) {
const shape: { [key: string]: Yup.StringSchema } = {};
placeholders.forEach(placeholder => {
shape[placeholder] = Yup.string().required(`Placeholder ${placeholder} is required`);
});
return Yup.object().shape(shape);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="3xl">
<ModalContent>
{(onClose) => (
<>
<Formik
initialValues={{}}
isInitialValid={false}
onSubmit={async (values) => {
await MessageEndpoint.sendTestNotification(selectedTemplate?.key, values);
toast.success("Test notification to you has been sent");
onClose();
}}
validationSchema={generateValidationSchema(selectedTemplate?.availablePlaceholders as string[])}
>
{(formik) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Send {selectedTemplate?.name} Test Message
</ModalHeader>
<ModalBody>
<p className="text-ls font-semibold mb-4">Fill the placeholders of the
template</p>
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Input key={placeholder} label={placeholder} name={placeholder}/>
)}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" type="submit" isDisabled={!formik.isValid}>
Send
</Button>
</ModalFooter>
</Form>
)}
</Formik>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,160 @@
import React, {useEffect, useRef, useState} from "react";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import {Button, Skeleton} from "@nextui-org/react";
import {Check, Info} from "@phosphor-icons/react";
import ConfigValuePairDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigValuePairDto";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
type NestedConfig = {
[field: string]: any;
}
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, configPrefix: string, validationSchema?: any) {
return function ConfigPage(props: any) {
const isInitialized = useRef(false);
const [configSaved, setConfigSaved] = useState(false);
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
const [nestedConfigDtos, setNestedConfigDtos] = useState<NestedConfig>({});
const [saveMessage, setSaveMessage] = useState<string>();
useEffect(() => {
ConfigEndpoint.getAll(configPrefix).then((response: any) => {
setConfigDtos(response as ConfigEntryDto[]);
setNestedConfigDtos(toNestedConfig(response as ConfigEntryDto[]));
isInitialized.current = true;
});
}, []);
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
async function handleSubmit(values: NestedConfig): Promise<void> {
const configValues = toConfigValuePair(values);
await ConfigEndpoint.setAll(configValues);
setNestedConfigDtos(values);
setConfigSaved(true);
}
function getConfig(key: string): ConfigEntryDto | undefined {
return configDtos.find((configDto: ConfigEntryDto) => configDto.key === key);
}
function getConfigs(prefix: string): ConfigEntryDto[] {
return configDtos.filter((configDto: ConfigEntryDto) => configDto.key?.startsWith(prefix));
}
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 = ''): ConfigValuePairDto[] {
let result: ConfigValuePairDto[] = [];
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 (
[...Array(4)].map((_e, i) =>
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
<Skeleton className="h-10 w-full rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<div className="flex flex-row gap-8">
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
</div>
</div>
)
)
}
return (
<Formik
initialValues={nestedConfigDtos}
onSubmit={handleSubmit}
validationSchema={validationSchema}
enableReinitialize={true}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex flex-row items-center gap-4">
{saveMessage && <SmallInfoField icon={Info}
message={saveMessage}
className="text-warning"/>}
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<WrappedComponent {...props}
getConfig={getConfig}
getConfigs={getConfigs}
formik={formik}
setSaveMessage={setSaveMessage}/>
</Form>
)}
</Formik>
);
}
}
@@ -0,0 +1,122 @@
import React, {useEffect, useState} from "react";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectedItems,
Selection,
SelectItem
} from "@nextui-org/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import RoleAssignmentResult from "Frontend/generated/de/grimsi/gameyfin/users/enums/RoleAssignmentResult";
interface AssignRolesModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
interface Role {
id: string;
}
export default function AssignRolesModal({
isOpen,
onOpenChange,
user
}: AssignRolesModalProps) {
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
const [selectedRole, setSelectedRole] = useState<Selection>();
const [error, setError] = useState<string>();
useEffect(() => {
setSelectedRole(rolesToSelection(user.roles!));
UserEndpoint.getRolesBelow().then((availableRoles) => {
setAvailableRoles(availableRoles!.map((role) => ({id: role!.toString()})));
});
}, []);
function rolesToSelection(roles: Array<string | undefined>): Selection {
return new Set(roles.map((role) => role!.toString()));
}
async function assignRoles() {
if (!selectedRole) return;
let selectedRolesArray = Array.from(selectedRole).map((role) => role.toString());
let result = await UserEndpoint.assignRoles(user.username, selectedRolesArray);
if (!result) return;
switch (result) {
case RoleAssignmentResult.SUCCESS:
window.location.reload();
break;
case RoleAssignmentResult.NO_ROLES_PROVIDED:
setError("Select at least one role");
break;
case RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH:
setError("Power level of user too high");
break;
case RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH:
setError("Power level of assigned role too high");
break;
default:
setError("An error occurred");
break;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Assign roles to {user.username}</ModalHeader>
<ModalBody className="flex flex-col gap-2">
<Select
items={availableRoles}
selectionMode="single"
disallowEmptySelection={true}
selectedKeys={selectedRole}
onSelectionChange={setSelectedRole}
placeholder="Select roles"
renderValue={(items: SelectedItems<Role>) => {
return (
<div className="flex flex-grow flex-wrap gap-2">
{items.map((item) => (
<RoleChip key={item.key} role={item.textValue as string}/>
))}
</div>
);
}}
>
{(role) => (
<SelectItem key={role.id} textValue={role.id}>
<RoleChip key={role.id} role={role.id}/>
</SelectItem>
)}
</Select>
{error &&
<small className="text-danger">{error}</small>
}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRole}>
Assign roles
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,27 @@
import {useAuth} from "Frontend/util/auth";
import {Avatar as NextUiAvatar} from "@nextui-org/react";
// @ts-ignore
const Avatar = ({...props}) => {
const auth = useAuth();
const username = getUsername();
function getUsername() {
if (props.username === undefined || props.username === null || props.username == "") {
return auth.state.user?.username;
}
return props.username;
}
// TODO: Check if avatar can be loaded from SSO
return (
<NextUiAvatar
showFallback
src={`/images/avatar?username=${username}`}
{...props}
/>
);
}
export default Avatar;
@@ -0,0 +1,23 @@
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-1 items-center gap-2 mb-2">
<Checkbox
{...field}
{...props}
id={field.name}
isSelected={field.value}
>
{label}
</Checkbox>
</div>
);
}
export default CheckboxInput;
@@ -0,0 +1,56 @@
import React, {useEffect, useState} from "react";
import {Button, Code, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
interface ConfirmUserDeletionModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
export default function ConfirmUserDeletionModal({
isOpen,
onOpenChange,
user
}: ConfirmUserDeletionModalProps) {
const [confirmUsername, setConfirmUsername] = useState<string>("");
useEffect(() => {
setConfirmUsername("");
}, []);
async function deleteUser() {
await UserEndpoint.deleteUserByName(user.username);
window.location.reload();
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader>
<ModalBody>
<p>
Confirm deletion of user <Code>{user.username}</Code> by entering the username
below
</p>
<Input onChange={(e) => setConfirmUsername(e.target.value)}/>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="danger" onPress={deleteUser}
isDisabled={confirmUsername != user.username}>
Confirm deletion
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,29 @@
import {useField} from "formik";
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}) => {
// @ts-ignore
const [field, meta] = useField(props);
return (
<div className="flex flex-col flex-1 items-start gap-2">
<NextUiInput
{...props}
{...field}
id={label}
label={label}
isInvalid={meta.touched && !!meta.error}
/>
<div className="min-h-6 text-danger">
{meta.touched && meta.error && meta.error.trim().length > 0 && (
<SmallInfoField icon={XCircle} message={meta.error}/>
)}
</div>
</div>
);
}
export default Input;
@@ -0,0 +1,65 @@
import React, {useEffect, useState} from "react";
import {Button, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
interface InviteUserModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function InviteUserModal({
isOpen,
onOpenChange
}: InviteUserModalProps) {
const [email, setEmail] = useState<string | null>();
const [error, setError] = useState<string | null>();
useEffect(() => {
setEmail(null);
setError(null);
}, []);
async function inviteUser(onClose: () => void) {
if (email === null) return;
if (await UserEndpoint.existsByMail(email)) {
setError("User with this email already exists");
return;
}
try {
await RegistrationEndpoint.createInvitation(email);
toast.success("Invitation has been sent");
onClose();
} catch (e) {
setError("Failed to create invitation");
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Invite a new user</ModalHeader>
<ModalBody>
<p>Enter the email address of the user you want to invite:</p>
<Input errorMessage={error} onChange={(e) => setEmail(e.target.value)} type="email"/>
{error && <small className="text-danger">{error}</small>}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" onPress={() => inviteUser(onClose)}
isDisabled={email === null || email === undefined || email.length < 1}>
Send invitation
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,71 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {Input as NextInput} from "@nextui-org/input";
import {WarningCircle} from "@phosphor-icons/react";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
interface PasswordResetModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function PasswordResetModal({
isOpen,
onOpenChange
}: PasswordResetModalProps) {
const [canResetPassword, setCanResetPassword] = useState(false);
const [resetEmail, setResetEmail] = useState<string>();
useEffect(() => {
MessageEndpoint.isEnabled().then(setCanResetPassword);
}, []);
async function resetPassword() {
await PasswordResetEndpoint.requestPasswordReset(resetEmail);
toast.success("If the email address is registered, you will receive a message with further instructions.");
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
<ModalBody>
{canResetPassword ?
<NextInput
onChange={(event: any) => {
setResetEmail(event.target.value);
}}
type="email"
placeholder="Email"
/> :
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<p>
Password self-service is disabled.<br/>
To reset your password please contact your administrator.
</p>
</div>
}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isDisabled={!canResetPassword}
onPress={async () => {
await resetPassword();
onClose();
}}>
Send request
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,69 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@nextui-org/react";
import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto";
import {timeUntil} from "Frontend/util/utils";
interface PasswordResetTokenModalProps {
isOpen: boolean;
onOpenChange: () => void;
token: TokenDto;
}
export default function PasswordResetTokenModal({
isOpen,
onOpenChange,
token
}: PasswordResetTokenModalProps) {
const [timeUntilExpiry, setTimeUntilExpiry] = useState<string>("");
const timeoutRefresh = setInterval(updateTimeUntilExpiry, 1000);
useEffect(updateTimeUntilExpiry, [token]);
useEffect(() => {
return () => {
clearInterval(timeoutRefresh);
};
}, []);
function passwordResetLink() {
return `${document.baseURI}reset-password?token=${token.secret}`;
}
function updateTimeUntilExpiry() {
if (!token) return;
setTimeUntilExpiry(timeUntil(token.expiresAt as string));
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable={false}
backdrop="opaque" size="4xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
The user can reset their password using the following link
</ModalHeader>
<ModalBody>
<Snippet symbol="">{passwordResetLink()}</Snippet>
{
!timeUntilExpiry.startsWith("-")
? <small className="text-warning">
This link will expire in {timeUntilExpiry}
</small>
: <small className="text-danger">
This link has expired {timeUntilExpiry.substring(1)} ago
</small>
}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
OK
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,10 @@
import {Chip} from "@nextui-org/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
export default function RoleChip({role}: { role: string }) {
return (
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
{roleToRoleName(role)}
</Chip>
);
}
@@ -0,0 +1,10 @@
import {Divider} from "@nextui-org/react";
export default function Section({title}: { title: string }) {
return (
<>
<h2 className={"text-xl font-bold mt-8 mb-1"}>{title}</h2>
<Divider className="mb-4"/>
</>
);
}
@@ -0,0 +1,29 @@
import {useField} from "formik";
import {Select, SelectItem} from "@nextui-org/react";
// @ts-ignore
const SelectInput = ({label, values, ...props}) => {
// @ts-ignore
const [field] = useField(props);
return (
<div className="flex flex-row flex-1 justify-center gap-2">
<Select
{...field}
{...props}
id={field.name}
label={label}
defaultSelectedKeys={[field.value]}
disallowEmptySelection
>
{values.map((value: string) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</Select>
</div>
);
}
export default SelectInput;
@@ -0,0 +1,104 @@
import React from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import UserRegistrationDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserRegistrationDto";
import {Form, Formik} from "formik";
import * as Yup from "yup";
import Input from "Frontend/components/general/Input";
import {toast} from "sonner";
interface SignUpModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function SignUpModal({
isOpen,
onOpenChange
}: SignUpModalProps) {
async function signUp(registration: UserRegistrationDto, onClose: () => void) {
try {
await RegistrationEndpoint.registerUser({
username: registration.username,
password: registration.password,
email: registration.email
});
onClose();
toast.success('You will receive an email with further instructions shortly.');
} catch (_) {
toast.error('An error occurred while registering your account.');
return;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{}}
onSubmit={async (values: any, {setFieldError}) => {
let usernameAvailable = await RegistrationEndpoint.isUsernameAvailable(values.username);
if (!usernameAvailable) {
setFieldError('username', 'Username already taken');
return;
} else {
await signUp(values, onClose);
}
}}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}>
<Form>
<ModalHeader className="flex flex-col gap-1">Register a new account</ModalHeader>
<ModalBody>
<div className="flex flex-col">
<Input
label="Username"
name="username"
type="text"
/>
<Input
label="E-Mail"
name="email"
type="email"
/>
<Input
label="Password"
name="password"
type="password"
/>
<Input
label="Password (repeat)"
name="passwordRepeat"
type="password"
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" type="submit">
Create account
</Button>
</ModalFooter>
</Form>
</Formik>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,12 @@
import React from 'react';
// @ts-ignore
export function SmallInfoField({icon: IconComponent, message, ...props}) {
return (
<div {...props}>
<small className="flex flex-row items-center gap-1">
<IconComponent weight="fill" size={14}/> {message}
</small>
</div>
);
}
@@ -0,0 +1,159 @@
import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react";
import {DotsThreeVertical} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react";
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import ConfirmUserDeletionModal from "Frontend/components/general/ConfirmUserDeletionModal";
import PasswordResetTokenModal from "Frontend/components/general/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/AssignRolesModal";
import Role from "Frontend/generated/de/grimsi/gameyfin/core/Role";
export function UserManagementCard({user}: { user: UserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure();
const passwordResetTokenModal = useDisclosure();
const roleAssignmentModal = useDisclosure();
const [userEnabled, setUserEnabled] = useState(true);
const [disabledKeys, setDisabledKeys] = useState<string[]>([]);
const [dropdownItems, setDropdownItems] = useState<any[]>([]);
const [passwordResetToken, setPasswordResetToken] = useState<TokenDto>();
const auth = useAuth();
useEffect(() => {
setUserEnabled(user.enabled);
let keysToBeDisabled: string[] = [];
MessageEndpoint.isEnabled().then((isEnabled) => {
if (isEnabled) keysToBeDisabled.push("resetPassword");
if (!user.hasAvatar) keysToBeDisabled.push("removeAvatar");
setDisabledKeys(keysToBeDisabled);
});
UserEndpoint.canCurrentUserManage(user.username).then((canManage) => {
if (!canManage) keysToBeDisabled.push("assignRole", "disableUser", "delete");
setDisabledKeys(keysToBeDisabled);
});
}, []);
useEffect(() => {
setDropdownItems(getDropdownItems());
}, [userEnabled]);
async function resetPassword() {
let token = await PasswordResetEndpoint.createPasswordResetTokenForUser(user.username);
if (token === undefined) return;
setPasswordResetToken(token);
passwordResetTokenModal.onOpen();
}
function getDropdownItems() {
let items = [];
if (!user.managedBySso) {
if (!userEnabled) {
items.push(
{
key: "enableUser",
onPress: () => {
UserEndpoint.setUserEnabled(user.username, true).then(() => {
setUserEnabled(true);
})
},
label: "Enable user"
}
);
} else {
items.push(
{
key: "disableUser",
onPress: () => {
UserEndpoint.setUserEnabled(user.username, false).then(() => {
setUserEnabled(false);
})
},
label: "Disable user"
}
);
}
items.push(
{
key: "removeAvatar",
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
label: "Remove avatar"
},
{
key: "assignRole",
onPress: roleAssignmentModal.onOpen,
label: "Assign role"
},
{
key: "resetPassword",
onPress: resetPassword,
label: "Reset password"
}
);
}
items.push({
key: "delete",
onPress: userDeletionConfirmationModal.onOpen,
label: "Delete user"
}
);
return items;
}
return (
<>
<Card
className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"} ${user.managedBySso ? "text-foreground/50" : ""}`}>
<div className="flex flex-row items-center gap-4">
<Avatar username={user.username}
name={user.username?.charAt(0)}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl",
}}/>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) => (
<RoleChip role={role as Role}/>
))}
</div>
</div>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<DotsThreeVertical cursor="pointer"/>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
{(item) => (
<DropdownItem
key={item.key}
onPress={item.onPress}
color={item.key === "delete" ? "danger" : "default"}
className={item.key === "delete" ? "text-danger" : ""}
>
{item.label}
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
</Card>
<ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
onOpenChange={userDeletionConfirmationModal.onOpenChange}
user={user}/>
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
onOpenChange={passwordResetTokenModal.onOpenChange}
token={passwordResetToken as TokenDto}/>
<AssignRolesModal isOpen={roleAssignmentModal.isOpen} onOpenChange={roleAssignmentModal.onOpenChange}
user={user}/>
</>
)
}
@@ -0,0 +1,60 @@
import {Outlet} from "react-router-dom";
import {Icon} from "@phosphor-icons/react";
import {Listbox, ListboxItem} from "@nextui-org/react";
import {ReactElement, useState} from "react";
export type MenuItem = {
title: string,
url: string,
icon: ReactElement<Icon>
}
export default function withSideMenu(menuItems: MenuItem[]) {
return function PageWithSideMenu() {
const [selectedItem, setSelectedItem] = useState<string>(initialSelected)
/**
* Remove a "/" at the start if it exists
*/
function key(k: string): string {
return k.replace(/^(\/)/, "")
}
/**
* If the key starts with "/" assume it's an absolute link, else assume it's relative
*/
function link(l: string): string {
if (l.startsWith("/")) return l;
const p = window.location.pathname
return p.substring(0, p.lastIndexOf("/") + 1) + l;
}
/**
* Match the initially selected item by current URL path
*/
function initialSelected(): string {
const p = window.location.pathname
return p.substring(p.lastIndexOf("/") + 1, p.length);
}
return (
<div className="flex flex-row">
<div className="flex flex-col pr-8">
<Listbox className="min-w-60"
color="primary">
{menuItems.map((i) => (
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
onPress={() => setSelectedItem(i.url)}
className={`h-12 ${key(i.url) === selectedItem ? "bg-primary" : ""}`}>
<p>{i.title}</p>
</ListboxItem>
))}
</Listbox>
</div>
<div className="flex-1 overflow-auto">
<Outlet/>
</div>
</div>
);
}
}
@@ -0,0 +1,16 @@
export default function GameyfinLogo({className}: {
className?: string
}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.58 336.34" className={className}>
<polygon points="190.1 49.13 190.1 69.24 207.98 44.13 190.1 49.13"/>
<polygon points="365.58 0 263.22 28.66 205.64 95.97 365.58 51.18 365.58 0"/>
<polygon
points="190.1 283.11 248.6 266.73 248.6 149.74 365.58 116.99 365.58 73.12 190.1 122.25 190.1 283.11"/>
<polygon
points="58.49 144.48 155.98 117.18 175.48 89.79 175.48 53.23 0 102.36 0 336.34 58.49 254.15 58.49 144.48"/>
<polygon
points="116.99 199.59 116.99 245.09 65.81 259.42 0 336.34 175.48 287.2 175.48 170.22 131.61 182.5 116.99 199.59"/>
</svg>
);
}
@@ -0,0 +1,23 @@
import {Theme} from "Frontend/theming/theme";
import {Tooltip} from "@nextui-org/react";
export default function ThemePreview({theme, isSelected}: {
theme: Theme,
isSelected?: boolean
}) {
return (
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom">
<div className={`flex flex-col flex-grow aspect-square border-2 rounded-large overflow-hidden
${theme.name}-dark
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
<div className="flex-1 bg-primary"/>
<div className="basis-1/4 flex flex-row">
<div className="flex-1 bg-secondary"/>
<div className="flex-1 bg-success"/>
<div className="flex-1 bg-warning"/>
<div className="flex-1 bg-danger"/>
</div>
</div>
</Tooltip>
);
}
@@ -0,0 +1,79 @@
import {useTheme} from "next-themes";
import React, {useEffect, useState} from "react";
import {Button, Card, Divider, Select, Selection, SelectItem} from "@nextui-org/react";
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() {
const {theme, setTheme} = useTheme();
const [selectedTheme, setSelectedTheme] = useState(theme?.substring(0, theme?.lastIndexOf("-")));
const [selectedMode, setSelectedMode] = useState<Selection>();
useEffect(() => {
if (!selectedMode)
setSelectedMode(new Set([theme?.split('-').pop() ?? "dark"]));
}, [theme]);
useEffect(updateTheme, [selectedTheme, selectedMode]);
function updateTheme() {
if (selectedMode instanceof Set) {
let theme = `${selectedTheme}-${selectedMode.values().next().value}`;
setTheme(theme);
UserPreferencesEndpoint.set("preferred-theme", theme).catch(console.error);
}
}
return (
<div className="flex flex-col items-center gap-8">
<Select label="Theme mode" className="max-w-xs"
disallowEmptySelection
selectionMode={"single"}
defaultSelectedKeys={selectedMode}
onSelectionChange={setSelectedMode}
selectedKeys={selectedMode}>
<SelectItem key="light">
Light
</SelectItem>
<SelectItem key="dark">
Dark
</SelectItem>
</Select>
<div className="grid grid-flow-row grid-cols-8 gap-8">
{
//min-w-[468px]
themes.map(((t: Theme) => (
<div className="size-[10vh] min-h-[50px] min-w-[50px]"
key={t.name}
onClick={() => setSelectedTheme(t.name)}>
<ThemePreview
theme={t}
isSelected={selectedTheme === t.name}/>
</div>
)))
}
</div>
<p className="text-2xl font-semibold mt-8">Preview</p>
<Divider/>
<div className="flex flex-row gap-8 items-baseline">
<div className="flex flex-row gap-4">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success">Success</Button>
<Button color="warning">Warning</Button>
<Button color="danger">Danger</Button>
</div>
<Card className="flex flex-row gap-4 p-4">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success">Success</Button>
<Button color="warning">Warning</Button>
<Button color="danger">Danger</Button>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,97 @@
import React, {ReactNode, useState} from "react";
import {Form, Formik, FormikBag, FormikHelpers} from "formik";
import {ArrowLeft, ArrowRight, Check} from "@phosphor-icons/react";
import {Button} from "@nextui-org/react";
import {Step, Stepper} from "@material-tailwind/react";
const Wizard = ({children, initialValues, onSubmit}: {
children: ReactNode,
initialValues: any,
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>
}) => {
const [stepNumber, setStepNumber] = useState(0);
const steps = React.Children.toArray(children);
const [snapshot, setSnapshot] = useState(initialValues);
const step = steps[stepNumber];
const totalSteps = steps.length;
const isFirstStep = stepNumber === 0;
const isLastStep = stepNumber === totalSteps - 1;
const next = (values: any) => {
setSnapshot(values);
setStepNumber(Math.min(stepNumber + 1, totalSteps - 1));
};
const previous = (values: any) => {
setSnapshot(values);
setStepNumber(Math.max(stepNumber - 1, 0));
};
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => {
/*// @ts-ignore*/
if (step.props.onSubmit) {
/*// @ts-ignore*/
await step.props.onSubmit(values, bag);
}
if (isLastStep) {
return onSubmit(values, bag);
} else {
await bag.setTouched({});
next(values);
}
};
return (
<Formik
initialValues={snapshot}
onSubmit={handleSubmit}
/*// @ts-ignore*/
validationSchema={step.props.validationSchema}
>
{(formik) => (
<Form className="flex flex-col h-full">
<div className="w-full mb-8">
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
lineClassName="bg-foreground"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}>
{steps.map((child, index) => (
<Step key={index}
className="bg-foreground text-background"
activeClassName="bg-primary"
completedClassName="bg-primary"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}>
{/*@ts-ignore*/}
{child.props.icon}
</Step>
))}
</Stepper>
</div>
<div className="flex grow">
{step}
</div>
<div className="left-8 right-8 absolute bottom-8 -z-1">
<div className="flex justify-between">
<Button color="primary" onClick={() => previous(formik.values)} isDisabled={isFirstStep}>
<ArrowLeft/>
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : isLastStep ? <Check/> : <ArrowRight/>}
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
};
export default Wizard;
@@ -0,0 +1,11 @@
import {JSX, ReactNode} from "react";
import * as Yup from 'yup';
export default function WizardStep({children, icon, validationSchema}: {
children: ReactNode,
icon: JSX.Element,
validationSchema?: Yup.Schema,
onSubmit?: (...values: any) => Promise<void>
}) {
return children;
}
@@ -0,0 +1,41 @@
import {fetchWithAuth} from "Frontend/util/utils";
import {toast} from "sonner";
export async function uploadAvatar(avatar: any) {
const formData = new FormData();
formData.append("file", avatar);
const response = await fetchWithAuth("avatar/upload", formData);
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
toast.error("Error uploading avatar", {description: result});
}
}
export async function removeAvatar() {
const response = await fetchWithAuth("avatar/delete")
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
toast.error("Error removing avatar", {description: result});
}
}
export async function removeAvatarByName(name: string) {
const response = await fetchWithAuth("avatar/deleteByName?" + new URLSearchParams({name: name}))
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
toast.error("Error removing avatar", {description: result});
}
}
@@ -0,0 +1,3 @@
import * as AvatarEndpoint from './AvatarEndpoint'
export {AvatarEndpoint}
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Gameyfin</title>
<style>
body {
margin: 0;
width: 100vw;
height: 100vh;
}
#outlet {
height: 100%;
width: 100%;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<div id="outlet"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import {createRoot} from 'react-dom/client';
import {StrictMode} from "react";
import {RouterProvider} from "react-router-dom";
import router from "./routes";
const container = document.getElementById('outlet')!;
const root = createRoot(container);
root.render(
<StrictMode>
<RouterProvider router={router}/>
</StrictMode>
);
+13
View File
@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.gradient-primary {
@apply bg-gradient-to-br from-primary-400 to-primary-700;
}
.button-secondary {
@apply bg-primary-300 text-background/80;
}
}
+73
View File
@@ -0,0 +1,73 @@
import {protectRoutes} from '@vaadin/hilla-react-auth';
import {createBrowserRouter, RouteObject} from 'react-router-dom';
import LoginView from "Frontend/views/LoginView";
import MainLayout from "Frontend/views/MainLayout";
import TestView from "Frontend/views/TestView";
import SetupView from "Frontend/views/SetupView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
import {UserManagement} from "Frontend/components/administration/UserManagement";
import ProfileManagement from "Frontend/components/administration/ProfileManagement";
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView";
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
import {LogManagement} from "Frontend/components/administration/LogManagement";
import PasswordResetView from "Frontend/views/PasswordResetView";
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView";
export const routes = protectRoutes([
{
element: <App/>,
handle: {requiresLogin: false},
children: [
{
element: <MainLayout/>,
handle: {requiresLogin: true},
children: [
{
index: true, element: <TestView/>
},
{
path: 'settings',
element: <ProfileView/>,
children: [
{path: 'profile', element: <ProfileManagement/>},
{path: 'appearance', element: <ThemeSelector/>}
]
},
{
path: 'administration',
element: <AdministrationView/>,
children: [
{path: 'libraries', element: <LibraryManagement/>},
{path: 'users', element: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>},
{path: 'messages', element: <MessageManagement/>},
{path: 'logs', element: <LogManagement/>}
]
}
]
},
{
path: '/login', element: <LoginView/>, handle: {requiresLogin: false}
},
{
path: '/setup', element: <SetupView/>, handle: {requiresLogin: false}
},
{
path: '/accept-invitation', element: <InvitationRegistrationView/>, handle: {requiresLogin: false}
},
{
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
},
{
path: '/confirm-email', element: <EmailConfirmationView/>, handle: {requiresLogin: true}
},
],
}
]) as RouteObject[];
export default createBrowserRouter(routes);
@@ -0,0 +1,26 @@
type ColorPalette = {
100: string,
200: string,
300: string,
400: string,
500: string,
600: string,
700: string,
800: string,
900: string,
DEFAULT: string
}
export type Theme = {
name?: string,
colors: {
background?: string,
foreground?: string,
primary: ColorPalette,
secondary?: ColorPalette,
success?: ColorPalette,
warning?: ColorPalette,
danger?: ColorPalette,
focus?: string,
}
}
@@ -0,0 +1,47 @@
import {GameyfinClassic} from "./themes/gameyfin-classic";
import {GameyfinBlue} from "./themes/gameyfin-blue";
import {GameyfinViolet} from "./themes/gameyfin-violet";
import {Purple} from "./themes/purple";
import {Neutral} from "./themes/neutral";
import {Slate} from "./themes/slate";
import {Red} from "./themes/red";
import {Rose} from "./themes/rose";
import {Blue} from "./themes/blue";
import {Yellow} from "./themes/yellow";
import {Violet} from "./themes/violet";
import {Orange} from "./themes/orange";
import {Colorblind} from "./themes/colorblind";
import {Theme} from "./theme";
import {ConfigTheme, ConfigThemes} from "@nextui-org/react";
function light(c: Theme): ConfigTheme {
let t: Theme = structuredClone(c);
delete t.name;
(t as ConfigTheme).extend = "light";
return t;
}
function dark(c: Theme): ConfigTheme {
let t: Theme = structuredClone(c);
delete t.name;
(t as ConfigTheme).extend = "dark";
return t;
}
export function compileThemes(themes: Theme[]): ConfigThemes {
let compiledThemes: any = {};
themes.forEach((c: Theme) => {
compiledThemes[`${c.name}-light`] = light(c);
compiledThemes[`${c.name}-dark`] = dark(c);
})
return compiledThemes;
}
export function themeNames(): string[] {
return Object.keys(compileThemes(themes));
}
export const themes: Theme[] = [GameyfinBlue, GameyfinViolet, GameyfinClassic, Neutral, Slate, Red, Rose, Orange, Purple, Blue, Yellow, Violet, Colorblind];
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Blue: Theme = {
name: 'blue',
colors: {
primary: {
DEFAULT: '#2563EB',
100: '#b6cdfe',
200: '#88abf7',
300: '#5b8af1',
400: '#2d69ec',
500: '#134fd2',
600: '#0b3da4',
700: '#052c77',
800: '#001a4a',
900: '#00091e'
},
secondary: {
DEFAULT: '#d29613',
100: '#faebcc',
200: '#f5d898',
300: '#f1c465',
400: '#ecb132',
500: '#d29613',
600: '#a87810',
700: '#7e5a0c',
800: '#543c08',
900: '#2a1e04'
}
}
};
@@ -0,0 +1,67 @@
import {Theme} from '../theme';
export const Colorblind: Theme = {
name: 'colorblind',
colors: {
primary: {
DEFAULT: '#cc79a7',
900: '#2f1222',
800: '#5f2444',
700: '#8e3666',
600: '#bb4b88',
500: '#cc79a7',
400: '#d795b9',
300: '#e1afca',
200: '#ebcadc',
100: '#f5e4ed'
},
secondary: {
DEFAULT: '#0072b2',
900: '#001724',
800: '#002d47',
700: '#00446b',
600: '#005a8f',
500: '#0072b2',
400: '#009bf5',
300: '#38b6ff',
200: '#7aceff',
100: '#bde7ff'
},
success: {
DEFAULT: '#009e73',
900: '#002017',
800: '#003f2e',
700: '#005f46',
600: '#007e5d',
500: '#009e73',
400: '#00e4a8',
300: '#2cffc7',
200: '#72ffd9',
100: '#b9ffec'
},
warning: {
DEFAULT: '#e69f00',
900: '#2e1f00',
800: '#5c3f00',
700: '#8a5e00',
600: '#b87d00',
500: '#e69f00',
400: '#ffb81f',
300: '#ffca57',
200: '#ffdb8f',
100: '#ffedc7'
},
danger: {
DEFAULT: '#d55e00',
900: '#2b1300',
800: '#562500',
700: '#813800',
600: '#ab4a00',
500: '#d55e00',
400: '#ff7912',
300: '#ff9a4e',
200: '#ffbc89',
100: '#ffddc4'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const GameyfinBlue: Theme = {
name: 'gameyfin-blue',
colors: {
primary: {
DEFAULT: '#2332c8',
100: '#bdc3f9',
200: '#919bee',
300: '#6672e5',
400: '#3c4add',
500: '#2231c3',
600: '#1a2699',
700: '#101b6f',
800: '#070f45',
900: '#02041d'
},
secondary: {
DEFAULT: '#c3b422',
100: '#f7f3cf',
200: '#eee6a0',
300: '#e6da70',
400: '#ddce40',
500: '#c3b422',
600: '#9c8f1c',
700: '#756b15',
800: '#4e480e',
900: '#272407',
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const GameyfinClassic: Theme = {
name: 'gameyfin-classic',
colors: {
primary: {
DEFAULT: '#16A34A',
100: '#b8f7cf',
200: '#8ef0b2',
300: '#62ea94',
400: '#38e476',
500: '#20ca5d',
600: '#159d47',
700: '#0b7032',
800: '#02431d',
900: '#001804'
},
secondary: {
DEFAULT: '#ca208d',
100: '#f8cfe9',
200: '#f0a0d3',
300: '#e970bc',
400: '#e140a6',
500: '#ca208d',
600: '#a21970',
700: '#7a1354',
800: '#510d38',
900: '#29061c'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const GameyfinViolet: Theme = {
name: 'gameyfin-violet',
colors: {
primary: {
DEFAULT: '#6441a5',
100: '#d5c7ed',
200: '#b7a4dd',
300: '#9a7fce',
400: '#7d5abe',
500: '#6441a5',
600: '#4e3281',
700: '#37235d',
800: '#21153a',
900: '#0d0519'
},
secondary: {
DEFAULT: '#82a541',
100: '#e7efd7',
200: '#cedfaf',
300: '#b6cf87',
400: '#9dbf5f',
500: '#82a541',
600: '#688334',
700: '#4e6227',
800: '#34421a',
900: '#1a210d'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Neutral: Theme = {
name: 'neutral',
colors: {
primary: {
DEFAULT: '#525252',
100: '#ddd7d9',
200: '#c1bfbf',
300: '#a6a6a6',
400: '#8c8c8c',
500: '#737373',
600: '#595959',
700: '#413f40',
800: '#292526',
900: '#16090d'
},
secondary: {
DEFAULT: '#8d8d8d',
100: '#e8e8e8',
200: '#d1d1d1',
300: '#bababa',
400: '#a3a3a3',
500: '#8d8d8d',
600: '#707070',
700: '#545454',
800: '#383838',
900: '#1c1c1c'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Orange: Theme = {
name: 'orange',
colors: {
primary: {
DEFAULT: '#EA580C',
100: '#ffcdb2',
200: '#fbac84',
300: '#f78c54',
400: '#f46c25',
500: '#da520b',
600: '#ab3f07',
700: '#7a2d03',
800: '#4b1900',
900: '#1f0600'
},
secondary: {
DEFAULT: '#0b93da',
100: '#caebfc',
200: '#94d6f9',
300: '#5fc2f7',
400: '#2aadf4',
500: '#0b93da',
600: '#0975ae',
700: '#075783',
800: '#053a57',
900: '#021d2c'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from "../theme";
export const Purple: Theme = {
name: 'purple',
colors: {
primary: {
DEFAULT: '#DD62ED',
100: '#f2b9f9',
200: '#e78df3',
300: '#dc5fed',
400: '#d132e6',
500: '#b91acd',
600: '#9012a0',
700: '#670b73',
800: '#3f0547',
900: '#19001b'
},
secondary: {
DEFAULT: '#2ecd1a',
100: '#d2f9cd',
200: '#a6f29c',
300: '#79ec6a',
400: '#4de538',
500: '#2ecd1a',
600: '#26a215',
700: '#1c7a10',
800: '#13510b',
900: '#092905'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Red: Theme = {
name: 'red',
colors: {
primary: {
DEFAULT: '#DC2626',
100: '#f9bbbb',
200: '#ef9090',
300: '#e76464',
400: '#df3939',
500: '#c62020',
600: '#9b1718',
700: '#6f0f11',
800: '#450708',
900: '#1e0000'
},
secondary: {
DEFAULT: '#20c6c6',
100: '#cff7f7',
200: '#9fefef',
300: '#6ee7e7',
400: '#3ee0e0',
500: '#20c6c6',
600: '#1a9e9e',
700: '#137676',
800: '#0d4f4f',
900: '#062727'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from "../theme";
export const Rose: Theme = {
name: 'rose',
colors: {
primary: {
DEFAULT: '#E11D48',
100: '#fbb9c8',
200: '#f28da4',
300: '#ec607f',
400: '#e5345b',
500: '#cb1a41',
600: '#9f1233',
700: '#730b23',
800: '#470415',
900: '#1e0006'
},
secondary: {
DEFAULT: '#1acba4',
100: '#cdf9ef',
200: '#9cf2df',
300: '#6aecd0',
400: '#38e5c0',
500: '#1acba4',
600: '#15a284',
700: '#107a63',
800: '#0b5142',
900: '#052921'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from "../theme";
export const Slate: Theme = {
name: 'slate',
colors: {
primary: {
DEFAULT: '#475569',
100: '#cfd7e4',
200: '#b2bdcd',
300: '#95a3b7',
400: '#7788a1',
500: '#5e6f88',
600: '#48566a',
700: '#323e4d',
800: '#1d2531',
900: '#040d17'
},
secondary: {
DEFAULT: '#88775e',
100: '#e8e4de',
200: '#d1c9bd',
300: '#baae9c',
400: '#a3937b',
500: '#88775e',
600: '#6c5f4b',
700: '#514738',
800: '#363026',
900: '#1b1813'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Violet: Theme = {
name: 'violet',
colors: {
primary: {
DEFAULT: '#6D28D9',
100: '#d4bdf8',
200: '#b592ee',
300: '#9867e5',
400: '#7b3cdd',
500: '#6122c3',
600: '#4b1a99',
700: '#36126e',
800: '#200944',
900: '#0d021c'
},
secondary: {
DEFAULT: '#84c322',
100: '#e8f7cf',
200: '#d0eea0',
300: '#b9e670',
400: '#a1dd40',
500: '#84c322',
600: '#6b9c1c',
700: '#507515',
800: '#354e0e',
900: '#1b2707'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Yellow: Theme = {
name: 'yellow',
colors: {
primary: {
DEFAULT: '#FACC15',
100: '#feefae',
200: '#fce47f',
300: '#fbd94e',
400: '#face1e',
500: '#e1b505',
600: '#af8c00',
700: '#7d6400',
800: '#4c3c00',
900: '#1c1400'
},
secondary: {
DEFAULT: '#0531e1',
100: '#c8d3fe',
200: '#91a7fd',
300: '#5a7afc',
400: '#234efb',
500: '#0531e1',
600: '#0427b4',
700: '#031d87',
800: '#02135a',
900: '#010a2d'
}
}
};
+16
View File
@@ -0,0 +1,16 @@
import {configureAuth} from '@vaadin/hilla-react-auth';
import {UserEndpoint} from 'Frontend/generated/endpoints';
// Configure auth to use `UserInfoService.getUserInfo`
const auth = configureAuth(UserEndpoint.getUserInfo);
// Export auth provider and useAuth hook, which are automatically
// typed to the result of `UserInfoService.getUserInfo`
export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;
export function getCsrfToken() {
const token = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
return token || '';
}
@@ -0,0 +1,10 @@
import * as Yup from "yup";
import {isValidCron} from "cron-validator";
// Custom validator for cron expressions
Yup.addMethod(Yup.string, 'cron', function (message) {
return this.test('cron', message, function (value) {
const {path, createError} = this;
return isValidCron(value as string) || createError({path, message: message || 'Invalid cron expression'});
});
});
@@ -0,0 +1,31 @@
import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend';
import {toast} from "sonner";
import {getReasonPhrase} from "http-status-codes";
export const ErrorHandlingMiddleware: Middleware = async function (
context: MiddlewareContext,
next: MiddlewareNext
) {
const {endpoint, method} = context;
let originalResponse = (await next(context));
if (!originalResponse.ok) {
// .clone() is necessary because response.json() is one-time only and Hilla accesses it in its internal error handler
// @see https://developer.mozilla.org/en-US/docs/Web/API/Response/clone
let response: Response = originalResponse.clone();
//Ignore calls to UserEndpoint.getUserInfo since they are managed by Hilla and called on initial load
if (endpoint == "UserEndpoint" && method == "getUserInfo") return originalResponse;
let json: any = await response.json();
if (json.type == "dev.hilla.exception.EndpointException") {
toast.error(`${getReasonPhrase(response.status)}`, {description: `${json.message}`});
} else {
toast.error(`${getReasonPhrase(response.status)}`, {description: `${endpoint}.${method}`})
}
}
return originalResponse;
}
@@ -0,0 +1,15 @@
import { useMatches } from 'react-router-dom';
type RouteMetadata = {
[key: string]: any;
};
/**
* Returns the `handle` object containing the metadata for the current route,
* or undefined if the route does not have defined a handle.
*/
export function useRouteMetadata(): RouteMetadata | undefined {
const matches = useMatches();
const match = matches[matches.length - 1];
return match?.handle as RouteMetadata | undefined;
}
+84
View File
@@ -0,0 +1,84 @@
import {type ClassValue, clsx} from "clsx"
import {twMerge} from "tailwind-merge"
import {getCsrfToken} from "Frontend/util/auth";
import moment from 'moment-timezone';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function cssVar(variable: string) {
return getComputedStyle(document.documentElement).getPropertyValue(`--${variable}`);
}
export function hsl(hsl: string) {
return `hsl(${hsl}`;
}
export function rand(min: number, max: number) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}
export function roleToRoleName(role: string) {
role = role.replace("ROLE_", "").toLowerCase();
return role.charAt(0).toUpperCase() + role.slice(1);
}
export function roleToColor(role: string) {
switch (role) {
case "ROLE_SUPERADMIN":
return "red";
case "ROLE_ADMIN":
return "orange";
case "ROLE_USER":
return "blue";
default:
return "gray";
}
}
export async function fetchWithAuth(url: string, body: any = null, method = "POST"): Promise<Response> {
return await fetch(url, {
headers: {
"X-CSRF-Token": getCsrfToken()
},
credentials: "same-origin",
method: method,
body: body
});
}
/**
* Calculate the time difference between a given Instant and the current time in the user's timezone.
* @param {string} instantString - The Instant string returned by the backend.
* @param {string} timeZone - The user's timezone.
* @returns {string} - The time difference in a human-readable format.
*/
export function timeUntil(instantString: string, timeZone: string = moment.tz.guess()): string {
const givenDate = moment.tz(instantString, timeZone);
const now = moment.tz(timeZone);
const diffInSeconds = givenDate.diff(now, 'seconds');
const units = [
{name: "year", seconds: 31536000},
{name: "month", seconds: 2592000},
{name: "day", seconds: 86400},
{name: "hour", seconds: 3600},
{name: "minute", seconds: 60},
{name: "second", seconds: 1}
];
const isPast = diffInSeconds < 0;
const absDiffInSeconds = Math.abs(diffInSeconds);
for (const unit of units) {
const value = Math.floor(absDiffInSeconds / unit.seconds);
if (value >= 1) {
return `${isPast ? '-' : ''}${value} ${unit.name}${value > 1 ? 's' : ''}`;
}
}
return "just now";
}
@@ -0,0 +1,33 @@
import {Envelope, GameController, LockKey, Log, Users} from "@phosphor-icons/react";
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [
{
title: "Libraries",
url: "libraries",
icon: <GameController/>
},
{
title: "Users",
url: "users",
icon: <Users/>
},
{
title: "SSO",
url: "sso",
icon: <LockKey/>
},
{
title: "Messages",
url: "messages",
icon: <Envelope/>
},
{
title: "Logs",
url: "logs",
icon: <Log/>
}
]
export const AdministrationView = withSideMenu(menuItems);
export default AdministrationView;
@@ -0,0 +1,76 @@
import {Card, CardBody, CardHeader} from "@nextui-org/react";
import {useNavigate, useSearchParams} from "react-router-dom";
import React, {useEffect, useState} from "react";
import {CheckCircle, Warning, WarningCircle} from "@phosphor-icons/react";
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
import {EmailConfirmationEndpoint} from "Frontend/generated/endpoints";
import {useAuth} from "Frontend/util/auth";
export default function EmailConfirmationView() {
const auth = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const [validationResult, setValidationResult] = useState<TokenValidationResult>(TokenValidationResult.INVALID);
useEffect(() => {
if (auth.state.user?.emailConfirmed === true) {
navigate("/");
}
}, []);
useEffect(() => {
let token = searchParams.get("token");
if (token) confirmEmail(token).then((result) => setValidationResult(result));
}, [searchParams]);
async function confirmEmail(token: string): Promise<TokenValidationResult> {
let result = await EmailConfirmationEndpoint.confirmEmail(token) as TokenValidationResult;
if (result === TokenValidationResult.VALID) {
setTimeout(() => window.location.reload(), 5000);
}
return result;
}
return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody className="flex flex-row justify-center">
{validationResult === TokenValidationResult.VALID ?
<div className="flex flex-row items-center gap-4 text-success">
<CheckCircle size={40}/>
<p>
Email confirmed<br/>
You will be redirected shortly
</p>
</div>
: validationResult === TokenValidationResult.EXPIRED ?
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<p>
Expired token<br/>
Please request a new one
</p>
</div>
:
<div className="flex flex-row items-center gap-4 text-danger">
<Warning size={40}/>
<p>
Invalid token<br/>
Please try again
</p>
</div>
}
</CardBody>
</Card>
</div>
);
}
@@ -0,0 +1,113 @@
import {Button, Card, CardBody, CardHeader} from "@nextui-org/react";
import {useNavigate, useSearchParams} from "react-router-dom";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/Input";
import * as Yup from "yup";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react";
import {toast} from "sonner";
import UserInvitationAcceptanceResult
from "Frontend/generated/de/grimsi/gameyfin/users/enums/UserInvitationAcceptanceResult";
export default function InvitationRegistrationView() {
const [searchParams, setSearchParams] = useSearchParams();
const [token, setToken] = useState<string>();
const [email, setEmail] = useState<string>();
const navigate = useNavigate();
useEffect(() => {
let token = searchParams.get("token");
if (token) {
setToken(token);
RegistrationEndpoint.getInvitationRecipientEmail(token).then(setEmail);
}
}, [searchParams]);
async function register(values: any, formik: any) {
let result = await RegistrationEndpoint.acceptInvitation(token, {
email: email,
username: values.username,
password: values.password
});
switch (result) {
case UserInvitationAcceptanceResult.SUCCESS:
toast.success("Registration successful");
navigate("/", {replace: true});
break;
case UserInvitationAcceptanceResult.USERNAME_TAKEN:
formik.setFieldError("username", "Username is already taken");
break;
case UserInvitationAcceptanceResult.TOKEN_EXPIRED:
toast.error("Token is expired");
break;
case UserInvitationAcceptanceResult.TOKEN_INVALID:
default:
toast.error("Token is invalid");
break
}
}
return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody>
{token ?
<Formik
enableReinitialize={true}
initialValues={{
username: "",
email: email,
password: "",
passwordRepeat: ""
}}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}
onSubmit={register}>
{(formik: { values: any; isSubmitting: any; isValid: boolean; }) => (
<Form>
<p className="text-xl text-center mb-8">Register a new account</p>
<Input label="Email" name="email" type="email" value={email} disabled/>
<Input label="Username" name="username" autoComplete="username"/>
<Input label="Password" name="password" type="password"
autoComplete="new-password"/>
<Input label="Password (repeat)" name="passwordRepeat" type="password"
autoComplete="new-password"/>
<Button type="submit" className="w-full mt-4" color="primary"
isDisabled={!formik.isValid || formik.isSubmitting}
isLoading={formik.isSubmitting}>
{formik.isSubmitting ? "" : "Create account"}
</Button>
</Form>
)}
</Formik>
:
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/>
Invalid token
</p>
}
</CardBody>
</Card>
</div>
);
}
@@ -0,0 +1,92 @@
import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react";
import {Button, Card, CardBody, CardHeader, Link, useDisclosure} from "@nextui-org/react";
import {useNavigate} from "react-router-dom";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/Input";
import PasswordResetModal from "Frontend/components/general/PasswordResetModal";
import SignUpModal from "Frontend/components/general/SignUpModal";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
export default function LoginView() {
const {state, login} = useAuth();
const navigate = useNavigate();
const passwordResetModal = useDisclosure();
const signUpModal = useDisclosure();
const [url, setUrl] = useState<string>();
const [signUpAllowed, setSignUpAllowed] = useState<boolean>(false);
useEffect(() => {
if (state.user) {
const path = url ? new URL(url, document.baseURI).pathname : '/'
navigate(path, {replace: true});
} else {
RegistrationEndpoint.isSelfRegistrationAllowed().then(setSignUpAllowed);
}
}, [state.user]);
async function tryLogin(values: any, formik: any) {
const {error} = await login(values.username, values.password);
if (error) {
formik.setFieldError("username", " "); // Mark the field red, but don't show an error message
formik.setFieldError("password", "Invalid username and/or password.");
}
}
return (
<div className="flex size-full gradient-primary">
<Card className="m-auto p-12">
<CardHeader>
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96">
<Formik
initialValues={{}}
onSubmit={tryLogin}>
{(formik: { isSubmitting: any; }) => (
<Form className="mb-1 flex flex-col gap-6">
<Input
name="username"
label="Username"
autoComplete="username"
/>
<Input
name="password"
label="Password"
autoComplete="current-password"
type="password"
/>
<div className="flex justify-between items-center">
<Link color="foreground" underline="always" href="#"
onPress={passwordResetModal.onOpen}>
Forgot password?
</Link>
<div className="flex flex-row gap-2">
{signUpAllowed &&
<Button color="default" variant="light"
onPress={signUpModal.onOpen}>
Sign up
</Button>
}
<Button color="primary" type="submit" isLoading={formik.isSubmitting}>
{formik.isSubmitting ? "" : "Log in"}
</Button>
</div>
</div>
</Form>
)}
</Formik>
</CardBody>
</Card>
<PasswordResetModal isOpen={passwordResetModal.isOpen} onOpenChange={passwordResetModal.onOpenChange}/>
<SignUpModal isOpen={signUpModal.isOpen} onOpenChange={signUpModal.onOpenChange}/>
</div>
);
}
@@ -0,0 +1,103 @@
import {useRouteMetadata} from 'Frontend/util/routing.js';
import {useEffect, useState} from 'react';
import ProfileMenu from "Frontend/components/ProfileMenu";
import {Divider, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from "@nextui-org/react";
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json";
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 = {
mode: 'boom',
x: 0.5,
y: 1,
particleCount: 1000,
spreadDeg: 90,
launchSpeed: 4,
effectInterval: 10000
}
async function loadUserTheme() {
let syncedTheme = await UserPreferencesEndpoint.get("preferred-theme");
if (syncedTheme) {
setTheme(syncedTheme);
} else {
let localTheme = localStorage.getItem('theme');
if (localTheme) {
setTheme(localTheme);
await UserPreferencesEndpoint.set("preferred-theme", localTheme);
}
}
}
function easterEgg() {
if (isExploding) return;
setIsExploding(true);
if (confettiProps.mode === "boom") {
setTimeout(() => setIsExploding(false), confettiProps.effectInterval);
}
}
return (
<div className="flex flex-col min-h-svh">
{isExploding ? <Confetti {...confettiProps}/> : <></>}
<div className="flex flex-col flex-grow w-full 2xl:w-3/4 m-auto">
<Navbar maxWidth="full">
<NavbarBrand as="button" onClick={() => navigate('/')}>
<GameyfinLogo className="h-10 fill-foreground"/>
</NavbarBrand>
<NavbarContent justify="end">
{auth.state.user?.emailConfirmed === false ?
<NavbarItem>
<small className="text-warning">Please confirm your email</small>
</NavbarItem>
:
""
}
<NavbarItem>
<ProfileMenu/>
</NavbarItem>
</NavbarContent>
</Navbar>
<div className="w-full overflow-hidden ml-2 pr-8 mt-4">
<Outlet/>
</div>
</div>
<Divider/>
<div className="flex flex-col w-full 2xl:w-3/4 m-auto">
<footer className="flex flex-row items-center justify-between py-4 px-12">
<p>Gameyfin {PackageJson.version}</p>
<p className="flex flex-row gap-1 items-baseline">
Made with
<Heart size={16} weight="fill" className="text-primary" onClick={easterEgg}/>
by
<Link href="https://github.com/grimsi" target="_blank">grimsi</Link> and
<Link href="https://github.com/gameyfin/gameyfin/graphs/contributors" target="_blank">
contributors
</Link>
</p>
</footer>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import {Button, Card, CardBody, CardHeader} from "@nextui-org/react";
import {useNavigate, useSearchParams} from "react-router-dom";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/Input";
import * as Yup from "yup";
import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react";
import {toast} from "sonner";
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
export default function PasswordResetView() {
const [searchParams, setSearchParams] = useSearchParams();
const [token, setToken] = useState<string>();
const navigate = useNavigate();
useEffect(() => {
let token = searchParams.get("token");
if (token) setToken(token);
}, [searchParams]);
async function resetPassword(values: any) {
let token = searchParams.get("token") as string;
let result = await PasswordResetEndpoint.resetPassword(token, values.password) as TokenValidationResult;
switch (result) {
case TokenValidationResult.VALID:
toast.success("Password reset successfully");
navigate("/", {replace: true});
break;
case TokenValidationResult.EXPIRED:
toast.error("Token is expired");
break;
case TokenValidationResult.INVALID:
default:
toast.error("Token is invalid");
break
}
}
return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody>
{token ?
<Formik
initialValues={{
password: "",
passwordRepeat: ""
}}
validationSchema={Yup.object({
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}
onSubmit={resetPassword}>
{(formik: { values: any; isSubmitting: any; isValid: boolean; }) => (
<Form>
<p className="text-xl text-center mb-8">Reset your password</p>
<Input label="Password" name="password" type="password"
autoComplete="new-password"/>
<Input label="Password (repeat)" name="passwordRepeat" type="password"
autoComplete="new-password"/>
<Button type="submit" className="w-full mt-4" color="primary"
isDisabled={!formik.isValid || formik.isSubmitting}
isLoading={formik.isSubmitting}>
{formik.isSubmitting ? "" : "Reset password"}
</Button>
</Form>
)}
</Formik>
:
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/>
Invalid token
</p>
}
</CardBody>
</Card>
</div>
);
}
@@ -0,0 +1,23 @@
import {GearFine, Palette, User} from "@phosphor-icons/react";
import withSideMenu from "Frontend/components/general/withSideMenu";
const menuItems = [
{
title: "My Profile",
url: "profile",
icon: <User/>
},
{
title: "Appearance",
url: "appearance",
icon: <Palette/>
},
{
title: "Manage account",
url: "account-management",
icon: <GearFine/>
}
]
export const ProfileView = withSideMenu(menuItems);
export default ProfileView;
@@ -0,0 +1,129 @@
import React from 'react';
import * as Yup from 'yup';
import Wizard from "Frontend/components/wizard/Wizard";
import WizardStep from "Frontend/components/wizard/WizardStep";
import Input from "Frontend/components/general/Input";
import {HandWaving, Palette, User} from "@phosphor-icons/react";
import {Card} from "@nextui-org/react";
import {SetupEndpoint} from "Frontend/generated/endpoints";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import {useNavigate} from "react-router-dom";
import {toast} from "sonner";
function WelcomeStep() {
return (
<div className="flex flex-col size-full items-center">
<div className="flex flex-col w-1/2 min-w-[468px] gap-12 items-center">
<h4>Welcome to Gameyfin 👋</h4>
<p className="place-content-center text-justify">
Gameyfin is a cutting-edge software tailored for gamers seeking efficient management of their
video
game collections. <br/><br/> With its intuitive interface and comprehensive features, Gameyfin
simplifies the organization of game libraries. Users can effortlessly add games through manual
input
or
automated recognition, categorize them based on various criteria like genre or platform, track
in-game
progress, and share achievements with friends. <br/><br/> Notably, Gameyfin stands out for its
user-friendly
design and adaptability, offering ample customization options to meet diverse user preferences.
</p>
<h5>Let's get started!</h5>
</div>
</div>
);
}
function ThemeStep() {
return (
<div className="flex flex-col flex-grow gap-6 items-center">
<p className="text-2xl font-bold">Choose your style</p>
<ThemeSelector/>
</div>
);
}
function UserStep() {
return (
<div className="flex flex-row flex-grow justify-center">
<div className="flex flex-col w-1/3 min-w-96 gap-6 items-center">
<p className="text-2xl font-bold">Create your account</p>
<p>This will set up the initial admin user account.</p>
<div className="flex flex-col w-full">
<Input
label="Username"
name="username"
type="text"
/>
<Input
label="E-Mail"
name="email"
type="email"
/>
<Input
label="Password"
name="password"
type="password"
/>
<Input
label="Password (repeat)"
name="passwordRepeat"
type="password"
/>
</div>
</div>
</div>
);
}
function SetupView() {
const navigate = useNavigate();
return (
<div className="flex flex-row size-full items-center justify-center gradient-primary">
<Card className="w-3/4 h-3/4 min-w-[500px] p-8">
<Wizard
initialValues={{username: '', email: '', password: '', passwordRepeat: ''}}
onSubmit={
async (values: any) => {
await SetupEndpoint.registerSuperAdmin({
username: values.username,
password: values.password,
email: values.email
});
toast.success("Setup finished", {description: "Have fun with Gameyfin!"});
navigate('/login');
}
}
>
<WizardStep icon={<HandWaving/>}>
<WelcomeStep/>
</WizardStep>
<WizardStep icon={<Palette/>}>
<ThemeStep/>
</WizardStep>
<WizardStep
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}
icon={<User/>}
>
<UserStep/>
</WizardStep>
</Wizard>
</Card>
</div>
);
}
export default SetupView;
@@ -0,0 +1,45 @@
import {Link} from "react-router-dom";
import {Button} from "@nextui-org/react";
import {toast} from "sonner";
import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints.js";
export default function TestView() {
return (
<div className="grow justify-center mt-12">
<div className="flex flex-col items-center gap-6">
<Link to="/setup">Setup</Link>
<div className="flex flex-row gap-4">
<Button onPress={
() => toast("Normal", {
description: "Description",
action: {
label: "OK",
onClick: () => {
},
}
})}>Toast (Normal)</Button>
<Button onPress={
() => toast.success("Success", {
description: "Description",
action: {
label: "OK",
onClick: () => {
},
}
})}>Toast (Success)</Button>
<Button onPress={
() => toast.error("Error", {
description: "Description",
action: {
label: "OK",
onClick: () => {
},
}
})}>Toast (Error)</Button>
</div>
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button>
<Button onPress={() => LibraryEndpoint.test("Tetris")}>Test IGDB plugin</Button>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
package de.grimsi.gameyfin
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication
@EnableScheduling
@EnableTransactionManagement
class GameyfinApplication
fun main(args: Array<String>) {
runApplication<GameyfinApplication>(*args)
}
@@ -0,0 +1,53 @@
package de.grimsi.gameyfin.config
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class ConfigEndpoint(
private val config: ConfigService
) {
/** CRUD endpoints for admins **/
fun getAll(prefix: String?): List<ConfigEntryDto> {
return config.getAll(prefix)
}
fun get(key: String): String? {
return config.get(key)
}
fun set(key: String, value: String) {
config.set(key, value)
}
fun setAll(configs: List<ConfigValuePairDto>) {
config.setAll(configs)
}
fun resetConfig(key: String) {
config.resetConfigValue(key)
}
fun deleteConfig(key: String) {
config.deleteConfig(key)
}
/** Specific read-only endpoint for all users **/
@PermitAll
fun isSsoEnabled(): Boolean? {
return config.get(ConfigProperties.SSO.OIDC.Enabled)
}
@PermitAll
fun getLogoutUrl(): String? {
return config.get(ConfigProperties.SSO.OIDC.LogoutUrl)
}
}
@@ -0,0 +1,218 @@
package de.grimsi.gameyfin.config
import org.springframework.boot.logging.LogLevel
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,
val allowedValues: List<T>? = null
) {
/** Libraries */
sealed class Libraries {
data object AllowPublicAccess : ConfigProperties<Boolean>(
Boolean::class,
"library.allow-public-access",
"Allow access to game libraries without login",
false
)
sealed class Scan {
data object EnableFilesystemWatcher : ConfigProperties<Boolean>(
Boolean::class,
"library.scan.enable-filesystem-watcher",
"Enable automatic library scanning using file system watchers",
true
)
data object GameFileExtensions : ConfigProperties<String>(
String::class,
"library.scan.game-file-extensions",
"File extensions to consider as games",
"zip, tar, gz, rar, 7z, bz2, xz, iso, jar, tgz, exe, bat, cmd, com, msi, bin, run, app, dmg, elf"
)
}
sealed class Metadata {
data object UpdateEnabled : ConfigProperties<Boolean>(
Boolean::class,
"library.metadata.update.enabled",
"Enable periodic refresh of video game metadata",
true
)
data object UpdateSchedule : ConfigProperties<String>(
String::class,
"library.metadata.update.schedule",
"Schedule for periodic metadata refresh in cron format",
"0 0 * * 0"
)
}
}
/** User management */
sealed class Users {
sealed class SignUps {
data object Allow : ConfigProperties<Boolean>(
Boolean::class,
"users.sign-ups.allow",
"Allow new users to sign up by themselves",
false
)
data object ConfirmationRequired : ConfigProperties<Boolean>(
Boolean::class,
"users.sign-ups.confirmation-required",
"Admins need to confirm new users",
true
)
}
}
/** Single Sign-On */
sealed class SSO {
sealed class OIDC {
data object Enabled : ConfigProperties<Boolean>(
Boolean::class,
"sso.oidc.enabled",
"Enable SSO via OIDC/OAuth2",
false
)
data object MatchExistingUsersBy : ConfigProperties<MatchUsersBy>(
MatchUsersBy::class,
"sso.oidc.match-existing-users-by",
"Match existing users by",
MatchUsersBy.username,
MatchUsersBy.entries
)
data object AutoRegisterNewUsers : ConfigProperties<Boolean>(
Boolean::class,
"sso.oidc.auto-register-new-users",
"Automatically create new users after registration",
true
)
data object ClientId : ConfigProperties<String>(
String::class,
"sso.oidc.client-id",
"Client ID"
)
data object ClientSecret : ConfigProperties<String>(
String::class,
"sso.oidc.client-secret",
"Client secret"
)
data object IssuerUrl : ConfigProperties<String>(
String::class,
"sso.oidc.issuer-url",
"Issuer URL"
)
data object AuthorizeUrl : ConfigProperties<String>(
String::class,
"sso.oidc.authorize-url",
"Authorize URL"
)
data object TokenUrl : ConfigProperties<String>(
String::class,
"sso.oidc.token-url",
"Token URL"
)
data object UserInfoUrl : ConfigProperties<String>(
String::class,
"sso.oidc.userinfo-url",
"Userinfo URL"
)
data object JwksUrl : ConfigProperties<String>(
String::class,
"sso.oidc.jwks-url",
"JWKS URL"
)
data object LogoutUrl : ConfigProperties<String>(
String::class,
"sso.oidc.logout-url",
"Logout URL"
)
}
}
/** Messages */
sealed class Messages {
sealed class Providers {
sealed class Email {
data object Enabled : ConfigProperties<Boolean>(
Boolean::class,
"messages.providers.email.enabled",
"Enable E-Mail notifications",
false
)
data object Host : ConfigProperties<String>(
String::class,
"messages.providers.email.host",
"URL of the email server"
)
data object Port : ConfigProperties<Int>(
Int::class,
"messages.providers.email.port",
"Port of the email server",
587
)
data object Username : ConfigProperties<String>(
String::class,
"messages.providers.email.username",
"Username for the email account"
)
data object Password : ConfigProperties<String>(
String::class,
"messages.providers.email.password",
"Password for the email account"
)
}
}
}
/** Logs */
sealed class Logs {
data object Folder : ConfigProperties<String>(
String::class,
"logs.folder",
"Storage folder for log files",
"./logs"
)
data object MaxHistoryDays : ConfigProperties<Int>(
Int::class,
"logs.max-history-days",
"Log retention in days",
30
)
data object Level : ConfigProperties<LogLevel>(
LogLevel::class,
"logs.level",
"Log level",
LogLevel.INFO,
LogLevel.entries
)
}
}
enum class MatchUsersBy {
username, email
}
@@ -0,0 +1,213 @@
package de.grimsi.gameyfin.config
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
import de.grimsi.gameyfin.config.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import java.io.Serializable
@Service
@Transactional
class ConfigService(
private val appConfigRepository: ConfigRepository
) {
private val log = KotlinLogging.logger {}
/**
* Get all known config values.
*
* @param prefix: Optional prefix to filter the config values
* @return A map of all config values
*/
fun getAll(prefix: String?): List<ConfigEntryDto> {
log.info { "Getting all config values for prefix '$prefix'" }
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,
allowedValues = configProperty.allowedValues?.map { it.toString() }
)
}
}
/**
* Get the current value of a config property in a type-safe way.
* Used internally.
*
* @param configProperty: The config property 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(configProperty: ConfigProperties<T>): T? {
log.info { "Getting config value '${configProperty.key}'" }
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
return if (appConfig != null) {
getValue(appConfig.value, configProperty)
} else {
configProperty.default ?: return null
}
}
/**
* Get the current value of a config property in a *not* type-safe way.
* Used for the external API.
*
* @param key: The key of the config property
* @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 config value '$key'" }
val configProperty = findConfigProperty(key)
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
return if (appConfig != null) {
getValue(appConfig.value, configProperty).toString()
} else {
configProperty.default?.toString() ?: return null
}
}
/**
* Set multiple config values at once.
* Configs with a null value will be deleted.
*
* @param configs: A map of key-value pairs to set
*/
fun setAll(configs: List<ConfigValuePairDto>) {
configs.forEach {
it.value?.let { value -> set(it.key, value) } ?: deleteConfig(it.key)
}
}
/**
* Set the value for a specified key in a type-safe way.
*
* @param configProperty: The target config property
* @param value: Value to set the config property to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/
fun <T : Serializable> set(configProperty: ConfigProperties<T>, value: T) {
return set(configProperty.key, value)
}
/**
* Set the value for a specified key.
* Checks if the value can be cast to the type defined for the config property.
*
* @param key: Key of the target config property
* @param value: Value to set the config property to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/
fun <T : Serializable> set(key: String, value: T) {
log.info { "Set config value '$key'" }
val configKey = findConfigProperty(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()
}
appConfigRepository.save(configEntry)
}
/**
* Reset a given config property to its default value if it has a default value.
* Otherwise, delete the config key from the database.
*
* @param key: Key of the config property
*/
fun resetConfigValue(key: String) {
log.info { "Reset config value '$key'" }
val configKey = findConfigProperty(key)
if (configKey.default == null) {
deleteConfig(key)
return
}
val appConfig = appConfigRepository.findById(configKey.key).orElse(null)
if (appConfig != null) {
appConfig.value = configKey.default.toString()
appConfigRepository.save(appConfig)
}
}
/**
* Remove a config property from the database
*
* @param key: Key of the config property
*/
fun deleteConfig(key: String) {
log.info { "Delete config value '$key'" }
val configKey = findConfigProperty(key)
appConfigRepository.deleteById(configKey.key)
}
/**
* Get the value of the config property in a type-safe way.
*/
@Suppress("UNCHECKED_CAST")
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
Int::class -> value.toFloat().toInt() as T
Float::class -> value.toFloat() as T
else -> {
if (configProperty.type.java.isEnum) {
val enumConstants = configProperty.type.java.enumConstants
enumConstants.firstOrNull { it.toString() == value }
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}")
} else {
throw IllegalArgumentException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
}
}
}
}
/**
* Returns a config property
*/
private fun findConfigProperty(key: String): ConfigProperties<*> {
// Use reflection to get all objects defined within ConfigKey
val configProperties = ConfigProperties::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 configuration key: $key")
}
}
@@ -0,0 +1,14 @@
package de.grimsi.gameyfin.config.dto
import com.fasterxml.jackson.annotation.JsonInclude
import jakarta.annotation.Nonnull
@JsonInclude(JsonInclude.Include.ALWAYS)
data class ConfigEntryDto(
@field:Nonnull val key: String,
val value: String?,
val defaultValue: String?,
@field:Nonnull val type: String,
@field:Nonnull val description: String,
val allowedValues: List<String>?
)
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.config.dto
data class ConfigValuePairDto(
val key: String,
val value: String?
)
@@ -0,0 +1,19 @@
package de.grimsi.gameyfin.config.entities
import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@Entity
@Table(name = "app_config")
class ConfigEntry(
@Id
@NotNull
@Column(name = "`key`", unique = true)
val key: String,
@NotNull
@Column(name = "`value`")
@Convert(converter = EncryptionConverter::class)
var value: String
)
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.config.persistence
import de.grimsi.gameyfin.config.entities.ConfigEntry
import org.springframework.data.jpa.repository.JpaRepository
interface ConfigRepository : JpaRepository<ConfigEntry, String>
@@ -0,0 +1,11 @@
package de.grimsi.gameyfin.core
import org.pf4j.spring.SpringPluginManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class PluginManagerConfig {
@Bean
fun pluginManager() = SpringPluginManager()
}
@@ -0,0 +1,32 @@
package de.grimsi.gameyfin.core
import com.fasterxml.jackson.annotation.JsonValue
import de.grimsi.gameyfin.users.RoleService.Companion.INTERNAL_ROLE_PREFIX
enum class Role(val roleName: String, val powerLevel: Int) {
SUPERADMIN(Names.SUPERADMIN, 3),
ADMIN(Names.ADMIN, 2),
USER(Names.USER, 1);
@JsonValue
override fun toString(): String {
return this.roleName
}
companion object {
fun safeValueOf(type: String): Role? {
val enumString = type.removePrefix(INTERNAL_ROLE_PREFIX)
return java.lang.Enum.valueOf(Role::class.java, enumString)
}
}
// necessary for the ability to use the Roles class in the @RolesAllowed annotation
class Names {
companion object {
const val SUPERADMIN = "${INTERNAL_ROLE_PREFIX}SUPERADMIN"
const val ADMIN = "${INTERNAL_ROLE_PREFIX}ADMIN"
const val USER = "${INTERNAL_ROLE_PREFIX}USER"
}
}
}
@@ -0,0 +1,74 @@
package de.grimsi.gameyfin.core
import de.grimsi.gameyfin.setup.SetupService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.entities.User
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.core.env.Environment
import org.springframework.stereotype.Service
import java.net.InetAddress
@Service
@Transactional
class SetupDataLoader(
private val userService: UserService,
private val setupService: SetupService,
private val env: Environment
) {
private val log = KotlinLogging.logger {}
@EventListener(ApplicationReadyEvent::class)
fun initialSetup() {
if (setupService.isSetupCompleted()) return
log.info { "Looks like this is the first time you're starting Gameyfin." }
if ("dev" in env.activeProfiles) {
log.info { "We will now set up some data for local development..." }
setupUsers()
log.info { "Setup completed..." }
}
val protocol = if (env.getProperty("server.ssl.key-store") != null) "https" else "http"
log.info { "Visit $protocol://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" }
}
fun setupUsers() {
log.info { "Setting up users..." }
val superadmin = User(
username = "admin",
password = "admin",
email = "admin@gameyfin.org",
emailConfirmed = true,
enabled = true,
roles = setOf(Role.SUPERADMIN)
)
registerUserIfNotFound(superadmin)
val user = User(
username = "user",
password = "user",
email = "user@gameyfin.org",
emailConfirmed = true,
enabled = true,
roles = setOf(Role.USER)
)
registerUserIfNotFound(user)
log.info { "User setup completed." }
}
fun registerUserIfNotFound(user: User) {
if (userService.existsByUsername(user.username)) return
userService.registerOrUpdateUser(user)
}
}
@@ -0,0 +1,26 @@
package de.grimsi.gameyfin.core
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
class Utils {
companion object {
fun maskEmail(email: String): String {
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex()
return email.replace(regex, "*")
}
fun getBaseUrl(): String {
val request = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).request
val scheme = request.scheme
val serverName = request.serverName
val serverPort = request.serverPort
return if (serverPort == 80 || serverPort == 443) {
"$scheme://$serverName"
} else {
"$scheme://$serverName:$serverPort"
}
}
}
}
@@ -0,0 +1,38 @@
package de.grimsi.gameyfin.core.annotations
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
@Component
class DynamicAccessInterceptor(
private val configService: ConfigService
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
val handlerMethod = (handler as? HandlerMethod) ?: return true
val method = handlerMethod.method
// 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.get(ConfigProperties.Libraries.AllowPublicAccess) == true) {
return true
}
// Deny access if user is not logged in and public access is disabled
response.status = HttpServletResponse.SC_UNAUTHORIZED
return false
}
return true
}
}
@@ -0,0 +1,14 @@
package de.grimsi.gameyfin.core.annotations
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.FUNCTION
/**
* This annotation is used on endpoint methods which can be switched between publicly accessible and
* only accessible for registered users.
* One example would be the main library view.
*/
@Target(FUNCTION)
@Retention(RUNTIME)
annotation class DynamicPublicAccess
@@ -0,0 +1,15 @@
package de.grimsi.gameyfin.core.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<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
@@ -0,0 +1,11 @@
package de.grimsi.gameyfin.core.annotations
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
class NullOrNotBlankValidator : ConstraintValidator<NullOrNotBlank, String?> {
override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
return value == null || value.isNotBlank()
}
}
@@ -0,0 +1,19 @@
package de.grimsi.gameyfin.core.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("delay")
class DelayInterceptor : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
if (request.requestURI.startsWith("/connect")) Thread.sleep(2000)
return true
}
}
@@ -0,0 +1,8 @@
package de.grimsi.gameyfin.core.events
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
@Configuration
@EnableAsync
class AsyncConfig
@@ -0,0 +1,28 @@
package de.grimsi.gameyfin.core.events
import de.grimsi.gameyfin.shared.token.Token
import de.grimsi.gameyfin.shared.token.TokenType.*
import de.grimsi.gameyfin.users.entities.User
import org.springframework.context.ApplicationEvent
class UserInvitationEvent(source: Any, val token: Token<Invitation>, val baseUrl: String, val email: String) :
ApplicationEvent(source)
class UserRegistrationWaitingForApprovalEvent(source: Any, val newUser: User) : ApplicationEvent(source)
class AccountStatusChangedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
class EmailNeedsConfirmationEvent(source: Any, val token: Token<EmailConfirmation>, val baseUrl: String) :
ApplicationEvent(source)
class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: User, val baseUrl: String) :
ApplicationEvent(source)
class PasswordResetRequestEvent(source: Any, val token: Token<PasswordReset>, val baseUrl: String) :
ApplicationEvent(source)
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
class GameRequestEvent(source: Any) : ApplicationEvent(source)
class GameRequestApprovalEvent(source: Any) : ApplicationEvent(source)
@@ -0,0 +1,25 @@
package de.grimsi.gameyfin.core.security
import de.grimsi.gameyfin.users.UserService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.access.hierarchicalroles.RoleHierarchy
import org.springframework.security.access.hierarchicalroles.RoleHierarchyAuthoritiesMapper
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
class AuthenticationProviderConfig {
@Bean
fun hierarchicalUserAuthenticationProvider(
userService: UserService,
roleHierarchy: RoleHierarchy,
passwordEncoder: PasswordEncoder
): DaoAuthenticationProvider {
val provider = DaoAuthenticationProvider()
provider.setUserDetailsService(userService)
provider.setPasswordEncoder(passwordEncoder)
provider.setAuthoritiesMapper(RoleHierarchyAuthoritiesMapper(roleHierarchy))
return provider
}
}

Some files were not shown because too many files have changed in this diff Show More