From e4ac87f96efeb693a799c0b54385eefa63a1192e Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:40:03 +0200 Subject: [PATCH] Optimized config management in FE and BE Implemented standardized Avatar component Added optional "Save message" to config pages --- src/main/frontend/components/ProfileMenu.tsx | 28 +++++----- .../administration/ProfileManagement.tsx | 8 ++- .../administration/SsoManagement.tsx | 14 +++-- .../administration/UserManagement.tsx | 4 +- .../administration/withConfigPage.tsx | 53 +++++++++---------- .../frontend/components/general/Avatar.tsx | 27 ++++++++++ .../components/general/UserManagementCard.tsx | 9 ++-- .../gameyfin/config/ConfigController.kt | 44 --------------- .../grimsi/gameyfin/config/ConfigEndpoint.kt | 53 +++++++++++++++++++ .../grimsi/gameyfin/config/ConfigService.kt | 25 ++++++--- .../gameyfin/config/dto/ConfigValuePairDto.kt | 6 +++ .../annotations/DynamicAccessInterceptor.kt | 2 +- .../gameyfin/meta/security/SecurityConfig.kt | 16 +++--- .../SsoAuthenticationSuccessHandler.kt | 28 ++++------ .../de/grimsi/gameyfin/users/UserService.kt | 1 + .../gameyfin/users/dto/UserDetailsDto.kt | 7 --- .../grimsi/gameyfin/users/dto/UserInfoDto.kt | 1 + 17 files changed, 185 insertions(+), 141 deletions(-) create mode 100644 src/main/frontend/components/general/Avatar.tsx delete mode 100644 src/main/kotlin/de/grimsi/gameyfin/config/ConfigController.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt delete mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt diff --git a/src/main/frontend/components/ProfileMenu.tsx b/src/main/frontend/components/ProfileMenu.tsx index 559e8be..ba3d891 100644 --- a/src/main/frontend/components/ProfileMenu.tsx +++ b/src/main/frontend/components/ProfileMenu.tsx @@ -1,8 +1,9 @@ import {useAuth} from "Frontend/util/auth"; 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 {ConfigController} from "Frontend/generated/endpoints"; +import {ConfigEndpoint} from "Frontend/generated/endpoints"; +import Avatar from "Frontend/components/general/Avatar"; export default function ProfileMenu() { const auth = useAuth(); @@ -10,7 +11,7 @@ export default function ProfileMenu() { async function logout() { if (auth.state.user?.managedBySso) { - window.location.href = await ConfigController.getLogoutUrl() || "/"; + window.location.href = await ConfigEndpoint.getLogoutUrl() || "/"; } else { await auth.logout(); } @@ -44,16 +45,17 @@ export default function ProfileMenu() { return ( - + {/* div is necessary so dropdown menu will appear in the correct place */} +
+ +
{/* @ts-ignore */} diff --git a/src/main/frontend/components/administration/ProfileManagement.tsx b/src/main/frontend/components/administration/ProfileManagement.tsx index 08facb9..d7b9b7b 100644 --- a/src/main/frontend/components/administration/ProfileManagement.tsx +++ b/src/main/frontend/components/administration/ProfileManagement.tsx @@ -1,6 +1,6 @@ import Section from "Frontend/components/general/Section"; 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 {Check, Info, Trash} from "@phosphor-icons/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 {toast} from "sonner"; import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint"; +import Avatar from "Frontend/components/general/Avatar"; export default function ProfileManagement() { const [configSaved, setConfigSaved] = useState(false); @@ -101,10 +102,7 @@ export default function ProfileManagement() {
- - +
{ + 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"]; @@ -109,4 +117,4 @@ const validationSchema = Yup.object({ }) }); -export const SsoManagement = withConfigPage(SsoMangementLayout, "Single Sign-On", "sso", validationSchema); \ No newline at end of file +export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", "sso", validationSchema); \ No newline at end of file diff --git a/src/main/frontend/components/administration/UserManagement.tsx b/src/main/frontend/components/administration/UserManagement.tsx index 6230c3d..c666790 100644 --- a/src/main/frontend/components/administration/UserManagement.tsx +++ b/src/main/frontend/components/administration/UserManagement.tsx @@ -2,7 +2,7 @@ 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 {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 {UserManagementCard} from "Frontend/components/general/UserManagementCard"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; @@ -17,7 +17,7 @@ function UserManagementLayout({getConfig, formik}: any) { (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") ); }, []); diff --git a/src/main/frontend/components/administration/withConfigPage.tsx b/src/main/frontend/components/administration/withConfigPage.tsx index 209981b..7b219b8 100644 --- a/src/main/frontend/components/administration/withConfigPage.tsx +++ b/src/main/frontend/components/administration/withConfigPage.tsx @@ -1,27 +1,25 @@ 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 {Form, Formik} from "formik"; 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 = { [field: string]: any; } -type ConfigValuePair = { - key: string; - value: string | number | boolean | null | undefined; -} - export default function withConfigPage(WrappedComponent: React.ComponentType, title: String, configPrefix: string, validationSchema?: any) { return function ConfigPage(props: any) { const isInitialized = useRef(false); const [configSaved, setConfigSaved] = useState(false); const [configDtos, setConfigDtos] = useState([]); + const [saveMessage, setSaveMessage] = useState(); useEffect(() => { - ConfigController.getConfigs(configPrefix).then((response: any) => { + ConfigEndpoint.getAll(configPrefix).then((response: any) => { setConfigDtos(response as ConfigEntryDto[]); isInitialized.current = true; }); @@ -35,15 +33,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType { - if (c.value === null || c.value === undefined) { - await ConfigController.deleteConfig(c.key); - return; - } - - await ConfigController.setConfig(c.key, c.value.toString()); - })); - + await ConfigEndpoint.setAll(configValues); setConfigSaved(true); } @@ -90,8 +80,8 @@ export default function withConfigPage(WrappedComponent: React.ComponentType

{title}

- +
+ {saveMessage && } + + +
- + )} diff --git a/src/main/frontend/components/general/Avatar.tsx b/src/main/frontend/components/general/Avatar.tsx new file mode 100644 index 0000000..bcec448 --- /dev/null +++ b/src/main/frontend/components/general/Avatar.tsx @@ -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 ( + + ); +} + +export default Avatar; \ No newline at end of file diff --git a/src/main/frontend/components/general/UserManagementCard.tsx b/src/main/frontend/components/general/UserManagementCard.tsx index 120e8a4..0265c56 100644 --- a/src/main/frontend/components/general/UserManagementCard.tsx +++ b/src/main/frontend/components/general/UserManagementCard.tsx @@ -1,6 +1,5 @@ import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; import { - Avatar, Button, Card, Chip, @@ -23,6 +22,7 @@ import {useAuth} from "Frontend/util/auth"; import {useEffect, useState} from "react"; import {UserEndpoint} from "Frontend/generated/endpoints"; import {AvatarEndpoint} from "Frontend/endpoints/endpoints"; +import Avatar from "Frontend/components/general/Avatar"; export function UserManagementCard({user}: { user: UserInfoDto }) { const {isOpen, onOpen, onOpenChange} = useDisclosure(); @@ -62,14 +62,13 @@ export function UserManagementCard({user}: { user: UserInfoDto }) { return (
- + name: "text-background/80 text-5xl", + }}/>

{user.username}

{user.email}

diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigController.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigController.kt deleted file mode 100644 index d88494a..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigController.kt +++ /dev/null @@ -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 { - 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) - } -} diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt new file mode 100644 index 0000000..d3acb4c --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt @@ -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 { + 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) { + 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) + } +} diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt index dbe6513..28eb8d3 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt @@ -1,6 +1,7 @@ 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 @@ -21,7 +22,7 @@ class ConfigService( * @param prefix: Optional prefix to filter the config values * @return A map of all config values */ - fun getAllConfigValues(prefix: String?): List { + fun getAll(prefix: String?): List { log.info { "Getting all config values for prefix '$prefix'" } @@ -53,7 +54,7 @@ class ConfigService( * @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 getConfigValue(configProperty: ConfigProperties): T? { + fun get(configProperty: ConfigProperties): T? { log.info { "Getting config value '${configProperty.key}'" } @@ -72,7 +73,7 @@ class ConfigService( * @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 getConfigValue(key: String): String? { + fun get(key: String): String? { 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) { + 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. @@ -94,8 +106,8 @@ class ConfigService( * @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 setConfigValue(configProperty: ConfigProperties, value: T) { - return setConfigValue(configProperty.key, value) + fun set(configProperty: ConfigProperties, value: T) { + return set(configProperty.key, value) } /** @@ -106,8 +118,7 @@ class ConfigService( * @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 setConfigValue(key: String, value: T) { - + fun set(key: String, value: T) { log.info { "Set config value '$key' to '$value'" } val configKey = findConfigProperty(key) diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt b/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt new file mode 100644 index 0000000..5221659 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt @@ -0,0 +1,6 @@ +package de.grimsi.gameyfin.config.dto + +data class ConfigValuePairDto( + val key: String, + val value: String? +) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt index 8178984..712b8b1 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt @@ -24,7 +24,7 @@ class DynamicAccessInterceptor( // Check if method is annotated with @DynamicPublicAccess if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) { // Check if user is authenticated or public access is enabled - if (request.userPrincipal != null || configService.getConfigValue(ConfigProperties.LibraryAllowPublicAccess) == true) { + if (request.userPrincipal != null || configService.get(ConfigProperties.LibraryAllowPublicAccess) == true) { return true } diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/security/SecurityConfig.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/security/SecurityConfig.kt index 99cf108..140e697 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/meta/security/SecurityConfig.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/meta/security/SecurityConfig.kt @@ -49,7 +49,7 @@ class SecurityConfig( super.configure(http) - if (config.getConfigValue(ConfigProperties.SsoEnabled) == true) { + if (config.get(ConfigProperties.SsoEnabled) == true) { setOAuth2LoginPage(http, "/oauth2/authorization/$ssoProviderKey") // Use custom success handler to handle user registration http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) } @@ -74,16 +74,16 @@ class SecurityConfig( @Conditional(SsoEnabledCondition::class) fun clientRegistrationRepository(): ClientRegistrationRepository? { val clientRegistration = ClientRegistration.withRegistrationId(ssoProviderKey) - .clientId(config.getConfigValue(ConfigProperties.SsoClientId)) - .clientSecret(config.getConfigValue(ConfigProperties.SsoClientSecret)) + .clientId(config.get(ConfigProperties.SsoClientId)) + .clientSecret(config.get(ConfigProperties.SsoClientSecret)) .scope("openid", "profile", "email") .userNameAttributeName("preferred_username") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .issuerUri(config.getConfigValue(ConfigProperties.SsoIssuerUrl)) - .authorizationUri(config.getConfigValue(ConfigProperties.SsoAuthorizeUrl)) - .tokenUri(config.getConfigValue(ConfigProperties.SsoTokenUrl)) - .userInfoUri(config.getConfigValue(ConfigProperties.SsoUserInfoUrl)) - .jwkSetUri(config.getConfigValue(ConfigProperties.SsoJwksUrl)) + .issuerUri(config.get(ConfigProperties.SsoIssuerUrl)) + .authorizationUri(config.get(ConfigProperties.SsoAuthorizeUrl)) + .tokenUri(config.get(ConfigProperties.SsoTokenUrl)) + .userInfoUri(config.get(ConfigProperties.SsoUserInfoUrl)) + .jwkSetUri(config.get(ConfigProperties.SsoJwksUrl)) .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") .build() diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/security/SsoAuthenticationSuccessHandler.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/security/SsoAuthenticationSuccessHandler.kt index c9622f0..45163a1 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/meta/security/SsoAuthenticationSuccessHandler.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/meta/security/SsoAuthenticationSuccessHandler.kt @@ -33,40 +33,32 @@ class SsoAuthenticationSuccessHandler( // 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 if (matchedUser == null) { - matchedUser = when (config.getConfigValue(ConfigProperties.SsoMatchExistingUsersBy)) { - MatchUsersBy.USERNAME -> { - userService.getByUsername(oidcUser.preferredUsername) - } - - MatchUsersBy.EMAIL -> { - userService.getByEmail(oidcUser.email) - } - - else -> { - throw IllegalStateException("Unknown 'match users by' configuration") - } + matchedUser = when (config.get(ConfigProperties.SsoMatchExistingUsersBy)) { + MatchUsersBy.USERNAME -> userService.getByUsername(oidcUser.preferredUsername) + MatchUsersBy.EMAIL -> userService.getByEmail(oidcUser.email) + else -> throw IllegalStateException("Unknown 'match users by' configuration") } } // User could not be found in the database if (matchedUser == null) { - // Check if new user registration is enabled - if (config.getConfigValue(ConfigProperties.SsoAutoRegisterNewUsers) == false) { + if (config.get(ConfigProperties.SsoAutoRegisterNewUsers) == false) { response.sendRedirect("/") return } - // Register new user + // Register as new user matchedUser = User(oidcUser) - } - // User was found in the database, but we still want to update the user's information - else { + } else { + // Update user with new SSO data matchedUser.username = oidcUser.preferredUsername matchedUser.email = oidcUser.email + matchedUser.email_confirmed = true matchedUser.oidcProviderId = oidcUser.subject } + val grantedAuthorities = roleService.extractGrantedAuthorities(oidcUser.authorities) matchedUser.roles = roleService.authoritiesToRoles(grantedAuthorities) userService.registerOrUpdateUser(matchedUser) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index 9064ed9..ced9c5a 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -145,6 +145,7 @@ class UserService( return UserInfoDto( username = user.username, email = user.email, + emailConfirmed = user.email_confirmed, managedBySso = user.oidcProviderId != null, roles = user.roles.map { r -> r.rolename } ) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt deleted file mode 100644 index cc97f39..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserDetailsDto.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.grimsi.gameyfin.users.dto - -data class UserDetailsDto( - val id: Long, - val username: String, - val email: String -) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt index 2dd167e..50aa358 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt @@ -4,5 +4,6 @@ data class UserInfoDto( val username: String, val managedBySso: Boolean, val email: String, + val emailConfirmed: Boolean, var roles: List ) \ No newline at end of file