mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +00:00
Preparation for plugins
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
+249
@@ -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" "$@"
|
||||
Vendored
+92
@@ -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
|
||||
@@ -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)
|
||||
};
|
||||
Generated
+26037
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+73
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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>> = []
|
||||
)
|
||||
+11
@@ -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)
|
||||
+25
@@ -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
Reference in New Issue
Block a user