Optimized config management in FE and BE

Implemented standardized Avatar component
Added optional "Save message" to config pages
This commit is contained in:
grimsi
2024-09-18 11:40:03 +02:00
parent 6575e102f4
commit e4ac87f96e
17 changed files with 185 additions and 141 deletions
+15 -13
View File
@@ -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()
@@ -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>
) )