mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
Optimized config management in FE and BE
Implemented standardized Avatar component Added optional "Save message" to config pages
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
|
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
|
||||||
import {Avatar, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@nextui-org/react";
|
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@nextui-org/react";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import {ConfigController} from "Frontend/generated/endpoints";
|
import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import Avatar from "Frontend/components/general/Avatar";
|
||||||
|
|
||||||
export default function ProfileMenu() {
|
export default function ProfileMenu() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -10,7 +11,7 @@ export default function ProfileMenu() {
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
if (auth.state.user?.managedBySso) {
|
if (auth.state.user?.managedBySso) {
|
||||||
window.location.href = await ConfigController.getLogoutUrl() || "/";
|
window.location.href = await ConfigEndpoint.getLogoutUrl() || "/";
|
||||||
} else {
|
} else {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
}
|
}
|
||||||
@@ -44,16 +45,17 @@ export default function ProfileMenu() {
|
|||||||
return (
|
return (
|
||||||
<Dropdown placement="bottom-end">
|
<Dropdown placement="bottom-end">
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Avatar showFallback
|
{/* div is necessary so dropdown menu will appear in the correct place */}
|
||||||
src={`/images/avatar?username=${auth.state.user?.username}`}
|
<div>
|
||||||
radius="full"
|
<Avatar radius="full"
|
||||||
as="button"
|
as="button"
|
||||||
className="transition-transform size-8"
|
className="transition-transform size-8"
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "gradient-primary",
|
base: "gradient-primary",
|
||||||
icon: "text-background/80"
|
icon: "text-background/80"
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
import Input from "Frontend/components/general/Input";
|
import Input from "Frontend/components/general/Input";
|
||||||
import {Avatar, Button, Input as NextUiInput, Tooltip} from "@nextui-org/react";
|
import {Button, Input as NextUiInput, Tooltip} from "@nextui-org/react";
|
||||||
import {Form, Formik} from "formik";
|
import {Form, Formik} from "formik";
|
||||||
import {Check, Info, Trash} from "@phosphor-icons/react";
|
import {Check, Info, Trash} from "@phosphor-icons/react";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
@@ -11,6 +11,7 @@ import {UserEndpoint} from "Frontend/generated/endpoints";
|
|||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
|
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
|
||||||
|
import Avatar from "Frontend/components/general/Avatar";
|
||||||
|
|
||||||
export default function ProfileManagement() {
|
export default function ProfileManagement() {
|
||||||
const [configSaved, setConfigSaved] = useState(false);
|
const [configSaved, setConfigSaved] = useState(false);
|
||||||
@@ -101,10 +102,7 @@ export default function ProfileManagement() {
|
|||||||
<div className="flex flex-row flex-1 justify-between gap-16">
|
<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-col basis-1/4 mt-8 gap-4">
|
||||||
<div className="flex flex-row justify-center">
|
<div className="flex flex-row justify-center">
|
||||||
<Avatar showFallback
|
<Avatar className="size-40 m-4 flex flex-row"/>
|
||||||
src={`/images/avatar?username=${auth.state.user?.username}`}
|
|
||||||
className="size-40 m-4 flex flex-row">
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}
|
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, {useEffect} from "react";
|
||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
@@ -7,7 +7,15 @@ import {Button} from "@nextui-org/react";
|
|||||||
import {MagicWand} from "@phosphor-icons/react";
|
import {MagicWand} from "@phosphor-icons/react";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
|
||||||
function SsoMangementLayout({getConfig, formik}: any) {
|
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() {
|
function isAutoPopulateDisabled() {
|
||||||
return !formik.values.sso.oidc.enabled || !formik.values.sso.oidc["issuer-url"];
|
return !formik.values.sso.oidc.enabled || !formik.values.sso.oidc["issuer-url"];
|
||||||
@@ -109,4 +117,4 @@ const validationSchema = Yup.object({
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SsoManagement = withConfigPage(SsoMangementLayout, "Single Sign-On", "sso", validationSchema);
|
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", "sso", validationSchema);
|
||||||
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
|
|||||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
import {ConfigController, UserEndpoint} from "Frontend/generated/endpoints";
|
import {ConfigEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
|
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
|
||||||
import {UserManagementCard} from "Frontend/components/general/UserManagementCard";
|
import {UserManagementCard} from "Frontend/components/general/UserManagementCard";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
@@ -17,7 +17,7 @@ function UserManagementLayout({getConfig, formik}: any) {
|
|||||||
(response) => setUsers(response as UserInfoDto[])
|
(response) => setUsers(response as UserInfoDto[])
|
||||||
);
|
);
|
||||||
|
|
||||||
ConfigController.getConfig("sso.oidc.auto-register-new-users").then(
|
ConfigEndpoint.get("sso.oidc.auto-register-new-users").then(
|
||||||
(response) => setAutoRegisterNewUsers(response === "true")
|
(response) => setAutoRegisterNewUsers(response === "true")
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
import {ConfigController} from "Frontend/generated/endpoints";
|
import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
||||||
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
||||||
import {Form, Formik} from "formik";
|
import {Form, Formik} from "formik";
|
||||||
import {Button, Skeleton} from "@nextui-org/react";
|
import {Button, Skeleton} from "@nextui-org/react";
|
||||||
import {Check} from "@phosphor-icons/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 = {
|
type NestedConfig = {
|
||||||
[field: string]: any;
|
[field: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigValuePair = {
|
|
||||||
key: string;
|
|
||||||
value: string | number | boolean | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, configPrefix: string, validationSchema?: any) {
|
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, configPrefix: string, validationSchema?: any) {
|
||||||
return function ConfigPage(props: any) {
|
return function ConfigPage(props: any) {
|
||||||
const isInitialized = useRef(false);
|
const isInitialized = useRef(false);
|
||||||
const [configSaved, setConfigSaved] = useState(false);
|
const [configSaved, setConfigSaved] = useState(false);
|
||||||
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
||||||
|
const [saveMessage, setSaveMessage] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ConfigController.getConfigs(configPrefix).then((response: any) => {
|
ConfigEndpoint.getAll(configPrefix).then((response: any) => {
|
||||||
setConfigDtos(response as ConfigEntryDto[]);
|
setConfigDtos(response as ConfigEntryDto[]);
|
||||||
isInitialized.current = true;
|
isInitialized.current = true;
|
||||||
});
|
});
|
||||||
@@ -35,15 +33,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
|
|
||||||
async function handleSubmit(values: NestedConfig) {
|
async function handleSubmit(values: NestedConfig) {
|
||||||
const configValues = toConfigValuePair(values);
|
const configValues = toConfigValuePair(values);
|
||||||
await Promise.all(configValues.map(async (c: ConfigValuePair) => {
|
await ConfigEndpoint.setAll(configValues);
|
||||||
if (c.value === null || c.value === undefined) {
|
|
||||||
await ConfigController.deleteConfig(c.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ConfigController.setConfig(c.key, c.value.toString());
|
|
||||||
}));
|
|
||||||
|
|
||||||
setConfigSaved(true);
|
setConfigSaved(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +80,8 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
return nestedConfig;
|
return nestedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePair[] {
|
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePairDto[] {
|
||||||
let result: ConfigValuePair[] = [];
|
let result: ConfigValuePairDto[] = [];
|
||||||
|
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
if (obj.hasOwnProperty(key)) {
|
if (obj.hasOwnProperty(key)) {
|
||||||
@@ -133,17 +123,24 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||||
<h1 className="text-2xl font-bold">{title}</h1>
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
|
|
||||||
<Button
|
<div className="flex flex-row items-center gap-4">
|
||||||
color="primary"
|
{saveMessage && <SmallInfoField icon={Info}
|
||||||
isLoading={formik.isSubmitting}
|
message={saveMessage}
|
||||||
disabled={formik.isSubmitting || configSaved}
|
className="text-warning"/>}
|
||||||
type="submit"
|
|
||||||
>
|
<Button
|
||||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
color="primary"
|
||||||
</Button>
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={formik.isSubmitting || configSaved}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WrappedComponent {...props} getConfig={getConfig} formik={formik}/>
|
<WrappedComponent {...props} getConfig={getConfig} formik={formik}
|
||||||
|
setSaveMessage={setSaveMessage}/>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
|
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -23,6 +22,7 @@ import {useAuth} from "Frontend/util/auth";
|
|||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
|
import Avatar from "Frontend/components/general/Avatar";
|
||||||
|
|
||||||
export function UserManagementCard({user}: { user: UserInfoDto }) {
|
export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||||
const {isOpen, onOpen, onOpenChange} = useDisclosure();
|
const {isOpen, onOpen, onOpenChange} = useDisclosure();
|
||||||
@@ -62,14 +62,13 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
|||||||
return (
|
return (
|
||||||
<Card className="flex flex-row justify-between p-2">
|
<Card className="flex flex-row justify-between p-2">
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4">
|
||||||
<Avatar showFallback
|
<Avatar username={user.username}
|
||||||
name={user.username?.charAt(0)}
|
name={user.username?.charAt(0)}
|
||||||
src={`/images/avatar?username=${user?.username}`}
|
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "gradient-primary size-20",
|
base: "gradient-primary size-20",
|
||||||
icon: "text-background/80",
|
icon: "text-background/80",
|
||||||
name: "text-background/80 text-5xl -mt-1",
|
name: "text-background/80 text-5xl",
|
||||||
}}></Avatar>
|
}}/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="font-semibold">{user.username}</p>
|
<p className="font-semibold">{user.username}</p>
|
||||||
<p className="text-sm">{user.email}</p>
|
<p className="text-sm">{user.email}</p>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package de.grimsi.gameyfin.config
|
|
||||||
|
|
||||||
import com.vaadin.hilla.Endpoint
|
|
||||||
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
|
||||||
import de.grimsi.gameyfin.meta.Roles
|
|
||||||
import jakarta.annotation.security.PermitAll
|
|
||||||
import jakarta.annotation.security.RolesAllowed
|
|
||||||
|
|
||||||
@Endpoint
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
|
||||||
class ConfigController(
|
|
||||||
private val appConfigService: ConfigService
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun getConfigs(prefix: String?): List<ConfigEntryDto> {
|
|
||||||
return appConfigService.getAllConfigValues(prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getConfig(key: String): String? {
|
|
||||||
return appConfigService.getConfigValue(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PermitAll
|
|
||||||
fun isSsoEnabled(): Boolean? {
|
|
||||||
return appConfigService.getConfigValue(ConfigProperties.SsoEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PermitAll
|
|
||||||
fun getLogoutUrl(): String? {
|
|
||||||
return appConfigService.getConfigValue(ConfigProperties.SsoLogoutUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setConfig(key: String, value: String) {
|
|
||||||
appConfigService.setConfigValue(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetConfig(key: String) {
|
|
||||||
appConfigService.resetConfigValue(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteConfig(key: String) {
|
|
||||||
appConfigService.deleteConfig(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.meta.Roles
|
||||||
|
import jakarta.annotation.security.PermitAll
|
||||||
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
|
@Endpoint
|
||||||
|
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.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.SsoEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PermitAll
|
||||||
|
fun getLogoutUrl(): String? {
|
||||||
|
return config.get(ConfigProperties.SsoLogoutUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.grimsi.gameyfin.config
|
package de.grimsi.gameyfin.config
|
||||||
|
|
||||||
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
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.entities.ConfigEntry
|
||||||
import de.grimsi.gameyfin.config.persistence.ConfigRepository
|
import de.grimsi.gameyfin.config.persistence.ConfigRepository
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
@@ -21,7 +22,7 @@ class ConfigService(
|
|||||||
* @param prefix: Optional prefix to filter the config values
|
* @param prefix: Optional prefix to filter the config values
|
||||||
* @return A map of all config values
|
* @return A map of all config values
|
||||||
*/
|
*/
|
||||||
fun getAllConfigValues(prefix: String?): List<ConfigEntryDto> {
|
fun getAll(prefix: String?): List<ConfigEntryDto> {
|
||||||
|
|
||||||
log.info { "Getting all config values for prefix '$prefix'" }
|
log.info { "Getting all config values for prefix '$prefix'" }
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ class ConfigService(
|
|||||||
* @param configProperty: The config property containing necessary type information
|
* @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
|
* @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> getConfigValue(configProperty: ConfigProperties<T>): T? {
|
fun <T : Serializable> get(configProperty: ConfigProperties<T>): T? {
|
||||||
|
|
||||||
log.info { "Getting config value '${configProperty.key}'" }
|
log.info { "Getting config value '${configProperty.key}'" }
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ class ConfigService(
|
|||||||
* @param key: The key of the config property
|
* @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
|
* @return The current value if set or the default value or null if no value is set and no default value exists
|
||||||
*/
|
*/
|
||||||
fun getConfigValue(key: String): String? {
|
fun get(key: String): String? {
|
||||||
|
|
||||||
log.info { "Getting config value '$key'" }
|
log.info { "Getting config value '$key'" }
|
||||||
|
|
||||||
@@ -86,6 +87,17 @@ class ConfigService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Set the value for a specified key in a type-safe way.
|
||||||
@@ -94,8 +106,8 @@ class ConfigService(
|
|||||||
* @param value: Value to set the config property to
|
* @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
|
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
|
||||||
*/
|
*/
|
||||||
fun <T : Serializable> setConfigValue(configProperty: ConfigProperties<T>, value: T) {
|
fun <T : Serializable> set(configProperty: ConfigProperties<T>, value: T) {
|
||||||
return setConfigValue(configProperty.key, value)
|
return set(configProperty.key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,8 +118,7 @@ class ConfigService(
|
|||||||
* @param value: Value to set the config property to
|
* @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
|
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
|
||||||
*/
|
*/
|
||||||
fun <T : Serializable> setConfigValue(key: String, value: T) {
|
fun <T : Serializable> set(key: String, value: T) {
|
||||||
|
|
||||||
log.info { "Set config value '$key' to '$value'" }
|
log.info { "Set config value '$key' to '$value'" }
|
||||||
|
|
||||||
val configKey = findConfigProperty(key)
|
val configKey = findConfigProperty(key)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package de.grimsi.gameyfin.config.dto
|
||||||
|
|
||||||
|
data class ConfigValuePairDto(
|
||||||
|
val key: String,
|
||||||
|
val value: String?
|
||||||
|
)
|
||||||
@@ -24,7 +24,7 @@ class DynamicAccessInterceptor(
|
|||||||
// Check if method is annotated with @DynamicPublicAccess
|
// Check if method is annotated with @DynamicPublicAccess
|
||||||
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
|
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
|
||||||
// Check if user is authenticated or public access is enabled
|
// Check if user is authenticated or public access is enabled
|
||||||
if (request.userPrincipal != null || configService.getConfigValue(ConfigProperties.LibraryAllowPublicAccess) == true) {
|
if (request.userPrincipal != null || configService.get(ConfigProperties.LibraryAllowPublicAccess) == true) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class SecurityConfig(
|
|||||||
|
|
||||||
super.configure(http)
|
super.configure(http)
|
||||||
|
|
||||||
if (config.getConfigValue(ConfigProperties.SsoEnabled) == true) {
|
if (config.get(ConfigProperties.SsoEnabled) == true) {
|
||||||
setOAuth2LoginPage(http, "/oauth2/authorization/$ssoProviderKey")
|
setOAuth2LoginPage(http, "/oauth2/authorization/$ssoProviderKey")
|
||||||
// Use custom success handler to handle user registration
|
// Use custom success handler to handle user registration
|
||||||
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
|
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
|
||||||
@@ -74,16 +74,16 @@ class SecurityConfig(
|
|||||||
@Conditional(SsoEnabledCondition::class)
|
@Conditional(SsoEnabledCondition::class)
|
||||||
fun clientRegistrationRepository(): ClientRegistrationRepository? {
|
fun clientRegistrationRepository(): ClientRegistrationRepository? {
|
||||||
val clientRegistration = ClientRegistration.withRegistrationId(ssoProviderKey)
|
val clientRegistration = ClientRegistration.withRegistrationId(ssoProviderKey)
|
||||||
.clientId(config.getConfigValue(ConfigProperties.SsoClientId))
|
.clientId(config.get(ConfigProperties.SsoClientId))
|
||||||
.clientSecret(config.getConfigValue(ConfigProperties.SsoClientSecret))
|
.clientSecret(config.get(ConfigProperties.SsoClientSecret))
|
||||||
.scope("openid", "profile", "email")
|
.scope("openid", "profile", "email")
|
||||||
.userNameAttributeName("preferred_username")
|
.userNameAttributeName("preferred_username")
|
||||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||||
.issuerUri(config.getConfigValue(ConfigProperties.SsoIssuerUrl))
|
.issuerUri(config.get(ConfigProperties.SsoIssuerUrl))
|
||||||
.authorizationUri(config.getConfigValue(ConfigProperties.SsoAuthorizeUrl))
|
.authorizationUri(config.get(ConfigProperties.SsoAuthorizeUrl))
|
||||||
.tokenUri(config.getConfigValue(ConfigProperties.SsoTokenUrl))
|
.tokenUri(config.get(ConfigProperties.SsoTokenUrl))
|
||||||
.userInfoUri(config.getConfigValue(ConfigProperties.SsoUserInfoUrl))
|
.userInfoUri(config.get(ConfigProperties.SsoUserInfoUrl))
|
||||||
.jwkSetUri(config.getConfigValue(ConfigProperties.SsoJwksUrl))
|
.jwkSetUri(config.get(ConfigProperties.SsoJwksUrl))
|
||||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
+10
-18
@@ -33,40 +33,32 @@ class SsoAuthenticationSuccessHandler(
|
|||||||
// If user is not registered via SSO, check if user is already registered by username or email
|
// If user is not registered via SSO, check if user is already registered by username or email
|
||||||
// This is meant to map existing users to SSO users
|
// This is meant to map existing users to SSO users
|
||||||
if (matchedUser == null) {
|
if (matchedUser == null) {
|
||||||
matchedUser = when (config.getConfigValue(ConfigProperties.SsoMatchExistingUsersBy)) {
|
matchedUser = when (config.get(ConfigProperties.SsoMatchExistingUsersBy)) {
|
||||||
MatchUsersBy.USERNAME -> {
|
MatchUsersBy.USERNAME -> userService.getByUsername(oidcUser.preferredUsername)
|
||||||
userService.getByUsername(oidcUser.preferredUsername)
|
MatchUsersBy.EMAIL -> userService.getByEmail(oidcUser.email)
|
||||||
}
|
else -> throw IllegalStateException("Unknown 'match users by' configuration")
|
||||||
|
|
||||||
MatchUsersBy.EMAIL -> {
|
|
||||||
userService.getByEmail(oidcUser.email)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
throw IllegalStateException("Unknown 'match users by' configuration")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// User could not be found in the database
|
// User could not be found in the database
|
||||||
if (matchedUser == null) {
|
if (matchedUser == null) {
|
||||||
|
|
||||||
// Check if new user registration is enabled
|
// Check if new user registration is enabled
|
||||||
if (config.getConfigValue(ConfigProperties.SsoAutoRegisterNewUsers) == false) {
|
if (config.get(ConfigProperties.SsoAutoRegisterNewUsers) == false) {
|
||||||
response.sendRedirect("/")
|
response.sendRedirect("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register new user
|
// Register as new user
|
||||||
matchedUser = User(oidcUser)
|
matchedUser = User(oidcUser)
|
||||||
}
|
} else {
|
||||||
// User was found in the database, but we still want to update the user's information
|
// Update user with new SSO data
|
||||||
else {
|
|
||||||
matchedUser.username = oidcUser.preferredUsername
|
matchedUser.username = oidcUser.preferredUsername
|
||||||
matchedUser.email = oidcUser.email
|
matchedUser.email = oidcUser.email
|
||||||
|
matchedUser.email_confirmed = true
|
||||||
matchedUser.oidcProviderId = oidcUser.subject
|
matchedUser.oidcProviderId = oidcUser.subject
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val grantedAuthorities = roleService.extractGrantedAuthorities(oidcUser.authorities)
|
val grantedAuthorities = roleService.extractGrantedAuthorities(oidcUser.authorities)
|
||||||
matchedUser.roles = roleService.authoritiesToRoles(grantedAuthorities)
|
matchedUser.roles = roleService.authoritiesToRoles(grantedAuthorities)
|
||||||
userService.registerOrUpdateUser(matchedUser)
|
userService.registerOrUpdateUser(matchedUser)
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class UserService(
|
|||||||
return UserInfoDto(
|
return UserInfoDto(
|
||||||
username = user.username,
|
username = user.username,
|
||||||
email = user.email,
|
email = user.email,
|
||||||
|
emailConfirmed = user.email_confirmed,
|
||||||
managedBySso = user.oidcProviderId != null,
|
managedBySso = user.oidcProviderId != null,
|
||||||
roles = user.roles.map { r -> r.rolename }
|
roles = user.roles.map { r -> r.rolename }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package de.grimsi.gameyfin.users.dto
|
|
||||||
|
|
||||||
data class UserDetailsDto(
|
|
||||||
val id: Long,
|
|
||||||
val username: String,
|
|
||||||
val email: String
|
|
||||||
)
|
|
||||||
@@ -4,5 +4,6 @@ data class UserInfoDto(
|
|||||||
val username: String,
|
val username: String,
|
||||||
val managedBySso: Boolean,
|
val managedBySso: Boolean,
|
||||||
val email: String,
|
val email: String,
|
||||||
|
val emailConfirmed: Boolean,
|
||||||
var roles: List<String>
|
var roles: List<String>
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user