mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 08:15:37 +00:00
Moved roles from entities to enum
Added power level to roles Implemented role assignment in frontend
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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 [selectedRoles, setSelectedRoles] = useState<Selection>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRoles(rolesToSelection(user.roles!));
|
||||
UserEndpoint.getAvailableRoles().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 (!selectedRoles) return;
|
||||
|
||||
let selectedRolesArray = Array.from(selectedRoles).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.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="multiple"
|
||||
selectedKeys={selectedRoles}
|
||||
onSelectionChange={setSelectedRoles}
|
||||
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={!selectedRoles}>
|
||||
Assign roles
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Card, Chip, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react";
|
||||
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
|
||||
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";
|
||||
@@ -10,10 +9,14 @@ import ConfirmUserDeletionModal from "Frontend/components/general/ConfirmUserDel
|
||||
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[]>([]);
|
||||
@@ -40,13 +43,13 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
if (auth.state.user?.username === user.username) return false;
|
||||
|
||||
// User should not be able to delete the SUPERADMIN
|
||||
if (user.roles?.includes("ROLE_SUPERADMIN")) return false;
|
||||
if (user.roles?.includes(Role.SUPERADMIN)) return false;
|
||||
|
||||
// Superadmins can delete anyone excluding themselves (and other superadmins if there are any)
|
||||
if (auth.state.user?.roles?.includes("ROLE_SUPERADMIN")) return true;
|
||||
if (auth.state.user?.roles?.includes(Role.SUPERADMIN)) return true;
|
||||
|
||||
// Admins should be only allowed to delete other users, not other admins
|
||||
return !user.roles?.includes("ROLE_ADMIN");
|
||||
return !user.roles?.includes(Role.ADMIN);
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
@@ -79,6 +82,11 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
|
||||
label: "Remove avatar"
|
||||
},
|
||||
{
|
||||
key: "assignRoles",
|
||||
onPress: roleAssignmentModal.onOpen,
|
||||
label: "Assign roles"
|
||||
},
|
||||
{
|
||||
key: "resetPassword",
|
||||
onPress: resetPassword,
|
||||
@@ -108,9 +116,9 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
<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) =>
|
||||
<Chip key={role} size="sm" radius="sm"
|
||||
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)}
|
||||
{user.roles?.map((role) => (
|
||||
<RoleChip role={role as Role}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,6 +146,8 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
|
||||
onOpenChange={passwordResetTokenModal.onOpenChange}
|
||||
token={passwordResetToken as TokenDto}/>
|
||||
<AssignRolesModal isOpen={roleAssignmentModal.isOpen} onOpenChange={roleAssignmentModal.onOpenChange}
|
||||
user={user}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,12 @@ 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.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
class ConfigEndpoint(
|
||||
private val config: ConfigService
|
||||
) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package de.grimsi.gameyfin.core
|
||||
|
||||
import de.grimsi.gameyfin.users.RoleService.Companion.INTERNAL_ROLE_PREFIX
|
||||
|
||||
enum class Roles(val roleName: String) {
|
||||
SUPERADMIN(Names.SUPERADMIN),
|
||||
ADMIN(Names.ADMIN),
|
||||
USER(Names.USER);
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package de.grimsi.gameyfin.core
|
||||
|
||||
import de.grimsi.gameyfin.setup.SetupService
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import de.grimsi.gameyfin.users.entities.Role
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import de.grimsi.gameyfin.users.persistence.RoleRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
@@ -17,7 +15,6 @@ import java.net.InetAddress
|
||||
@Service
|
||||
@Transactional
|
||||
class SetupDataLoader(
|
||||
private val roleRepository: RoleRepository,
|
||||
private val userService: UserService,
|
||||
private val setupService: SetupService,
|
||||
private val env: Environment
|
||||
@@ -29,39 +26,16 @@ class SetupDataLoader(
|
||||
if (setupService.isSetupCompleted()) return
|
||||
|
||||
log.info { "Looks like this is the first time you're starting Gameyfin." }
|
||||
log.info { "We will now set up some data..." }
|
||||
|
||||
setupRoles()
|
||||
|
||||
if ("dev" in env.activeProfiles) {
|
||||
log.info { "We will now set up some data for local development..." }
|
||||
setupUsers()
|
||||
log.info { "Setup completed..." }
|
||||
}
|
||||
|
||||
log.info { "Setup completed..." }
|
||||
log.info { "Visit http://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" }
|
||||
}
|
||||
val protocol = if (env.getProperty("server.ssl.key-store") != null) "https" else "http"
|
||||
|
||||
fun setupRoles() {
|
||||
|
||||
log.info { "Setting up roles..." }
|
||||
|
||||
createRoleIfNotFound(Roles.SUPERADMIN.roleName)
|
||||
createRoleIfNotFound(Roles.ADMIN.roleName)
|
||||
createRoleIfNotFound(Roles.USER.roleName)
|
||||
|
||||
log.info { "Role setup completed." }
|
||||
}
|
||||
|
||||
fun createRoleIfNotFound(name: String): Role {
|
||||
log.info { "Creating role $name" }
|
||||
|
||||
var role: Role? = roleRepository.findByRolename(name)
|
||||
|
||||
if (role == null) {
|
||||
role = Role(name)
|
||||
roleRepository.save(role)
|
||||
}
|
||||
return role
|
||||
log.info { "Visit $protocol://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" }
|
||||
}
|
||||
|
||||
fun setupUsers() {
|
||||
@@ -72,27 +46,29 @@ class SetupDataLoader(
|
||||
password = "admin",
|
||||
email = "admin@gameyfin.org",
|
||||
emailConfirmed = true,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
roles = setOf(Role.SUPERADMIN)
|
||||
)
|
||||
|
||||
registerUserIfNotFound(superadmin, Roles.SUPERADMIN)
|
||||
registerUserIfNotFound(superadmin)
|
||||
|
||||
val user = User(
|
||||
username = "user",
|
||||
password = "user",
|
||||
email = "user@gameyfin.org",
|
||||
emailConfirmed = true,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
roles = setOf(Role.USER)
|
||||
)
|
||||
|
||||
registerUserIfNotFound(user, Roles.USER)
|
||||
registerUserIfNotFound(user)
|
||||
|
||||
log.info { "User setup completed." }
|
||||
}
|
||||
|
||||
fun registerUserIfNotFound(user: User, role: Roles) {
|
||||
fun registerUserIfNotFound(user: User) {
|
||||
if (userService.existsByUsername(user.username)) return
|
||||
|
||||
userService.registerOrUpdateUser(user, role)
|
||||
userService.registerOrUpdateUser(user)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.grimsi.gameyfin.libraries
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.libraries.entities.Library
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@@ -9,7 +9,7 @@ import jakarta.annotation.security.RolesAllowed
|
||||
class LibraryEndpoint(
|
||||
private val libraryService: LibraryService
|
||||
) {
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun getAllLibraries(): Collection<Library> {
|
||||
return libraryService.getAllLibraries()
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package de.grimsi.gameyfin.logs
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import reactor.core.publisher.Flux
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
class LogEndpoint(
|
||||
private val logService: LogService
|
||||
) {
|
||||
|
||||
@@ -2,11 +2,11 @@ package de.grimsi.gameyfin.messages
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
class MessageEndpoint(
|
||||
private val messageService: MessageService
|
||||
) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package de.grimsi.gameyfin.messages.templates
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
@Endpoint
|
||||
class MessageTemplateEndpoint(
|
||||
private val messageTemplateService: MessageTemplateService
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.grimsi.gameyfin.setup
|
||||
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.users.RoleService
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import de.grimsi.gameyfin.users.dto.UserInfoDto
|
||||
@@ -20,7 +20,7 @@ class SetupService(
|
||||
* 1. At least one user with "Super Admin" role
|
||||
*/
|
||||
fun isSetupCompleted(): Boolean {
|
||||
return roleService.getUserCountForRole(Roles.SUPERADMIN) > 0
|
||||
return roleService.getUserCountForRole(Role.SUPERADMIN) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,11 +31,11 @@ class SetupService(
|
||||
username = registration.username,
|
||||
password = registration.password,
|
||||
email = registration.email,
|
||||
roles = setOf(roleService.toRole(Roles.SUPERADMIN)),
|
||||
enabled = true
|
||||
enabled = true,
|
||||
roles = setOf(Role.SUPERADMIN)
|
||||
)
|
||||
|
||||
val user = userService.registerOrUpdateUser(superAdmin, Roles.SUPERADMIN)
|
||||
val user = userService.registerOrUpdateUser(superAdmin)
|
||||
return userService.toUserInfo(user)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.grimsi.gameyfin.system
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@@ -9,7 +9,7 @@ class SystemEndpoint(
|
||||
private val systemService: SystemService
|
||||
) {
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun restart() {
|
||||
systemService.restart()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.users.entities.Role
|
||||
import de.grimsi.gameyfin.users.persistence.RoleRepository
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import de.grimsi.gameyfin.users.persistence.UserRepository
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority
|
||||
@@ -12,7 +13,7 @@ import org.springframework.stereotype.Service
|
||||
@Service
|
||||
@Transactional
|
||||
class RoleService(
|
||||
private val roleRepository: RoleRepository
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -20,26 +21,37 @@ class RoleService(
|
||||
const val INTERNAL_ROLE_PREFIX = "ROLE_"
|
||||
}
|
||||
|
||||
fun getAllRoles(): List<Role> {
|
||||
return Role.entries
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the number of registered users with a given role
|
||||
* @return 0 if a role does not exist
|
||||
*/
|
||||
fun getUserCountForRole(role: Roles): Int {
|
||||
val r = roleRepository.findByRolename(role.roleName) ?: return 0
|
||||
return r.users.size
|
||||
fun getUserCountForRole(role: Role): Int {
|
||||
return userRepository.countUserByRolesContains(role)
|
||||
}
|
||||
|
||||
fun toRoles(roles: Collection<Roles>): Set<Role> {
|
||||
return roles.mapNotNull { r -> roleRepository.findByRolename(r.roleName) }.toSet()
|
||||
fun getHighestRole(roles: Collection<Role>): Role {
|
||||
return roles.maxByOrNull { it.powerLevel } ?: Role.USER
|
||||
}
|
||||
|
||||
fun toRole(role: Roles): Role {
|
||||
return roleRepository.findByRolename(role.roleName)
|
||||
?: throw RuntimeException("Role ${role.roleName} does not exist")
|
||||
fun getHighestRoleFromAuthorities(authorities: Collection<GrantedAuthority>): Role {
|
||||
return getHighestRole(authoritiesToRoles(authorities))
|
||||
}
|
||||
|
||||
fun getRolesBelowUser(user: User): List<Role> {
|
||||
val highestUserRole = getHighestRole(user.roles)
|
||||
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
|
||||
}
|
||||
|
||||
fun getRolesBelowAuth(auth: Authentication): List<Role> {
|
||||
val highestUserRole = getHighestRole(auth.authorities.mapNotNull { Role.safeValueOf(it.authority) })
|
||||
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
|
||||
}
|
||||
|
||||
fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): Set<Role> {
|
||||
return authorities.mapNotNull { a -> roleRepository.findByRolename(a.authority) }.toSet()
|
||||
return authorities.mapNotNull { Role.safeValueOf(it.authority) }.toMutableSet()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,18 +71,16 @@ class RoleService(
|
||||
val roles = userInfo.getClaim<List<String>>("roles")
|
||||
roles.asSequence().mapNotNull {
|
||||
if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority(
|
||||
it.replace(
|
||||
SSO_ROLE_PREFIX,
|
||||
INTERNAL_ROLE_PREFIX
|
||||
)
|
||||
it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX)
|
||||
)
|
||||
else null
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
// Add USER role if no roles are present
|
||||
if (mappedAuthorities.isEmpty()) {
|
||||
mappedAuthorities.plus(SimpleGrantedAuthority(Roles.Names.USER))
|
||||
mappedAuthorities.plus(SimpleGrantedAuthority(Role.Names.USER))
|
||||
}
|
||||
|
||||
return mappedAuthorities
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.users.dto.UserInfoDto
|
||||
import de.grimsi.gameyfin.users.dto.UserUpdateDto
|
||||
import de.grimsi.gameyfin.users.enums.RoleAssignmentResult
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import org.springframework.security.core.Authentication
|
||||
@@ -12,7 +13,8 @@ import org.springframework.security.core.context.SecurityContextHolder
|
||||
|
||||
@Endpoint
|
||||
class UserEndpoint(
|
||||
private val userService: UserService
|
||||
private val userService: UserService,
|
||||
private val roleService: RoleService
|
||||
) {
|
||||
@PermitAll
|
||||
fun existsByMail(email: String): Boolean {
|
||||
@@ -25,18 +27,18 @@ class UserEndpoint(
|
||||
return userService.getUserInfo(auth)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun getAllUsers(): List<UserInfoDto> {
|
||||
return userService.getAllUsers()
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
fun updateUser(updates: UserUpdateDto) {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
userService.updateUser(auth.name, updates)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun getAllUsers(): List<UserInfoDto> {
|
||||
return userService.getAllUsers()
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun updateUserByName(username: String, updates: UserUpdateDto) {
|
||||
userService.updateUser(username, updates)
|
||||
}
|
||||
@@ -47,8 +49,24 @@ class UserEndpoint(
|
||||
userService.deleteUser(auth.name)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun deleteUserByName(username: String) {
|
||||
userService.deleteUser(username)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun getAvailableRoles(): List<String> {
|
||||
return roleService.getAllRoles().map { it.roleName }
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun getRolesBelow(): List<String> {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
return roleService.getRolesBelowAuth(auth).map { it.roleName }
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun assignRoles(username: String, roles: List<String>): RoleAssignmentResult {
|
||||
return userService.assignRoles(username, roles)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users
|
||||
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import de.grimsi.gameyfin.config.ConfigService
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.core.Utils
|
||||
import de.grimsi.gameyfin.core.events.EmailNeedsConfirmationEvent
|
||||
import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent
|
||||
@@ -13,8 +13,8 @@ import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||
import de.grimsi.gameyfin.users.dto.UserUpdateDto
|
||||
import de.grimsi.gameyfin.users.emailconfirmation.EmailConfirmationService
|
||||
import de.grimsi.gameyfin.users.entities.Avatar
|
||||
import de.grimsi.gameyfin.users.entities.Role
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import de.grimsi.gameyfin.users.enums.RoleAssignmentResult
|
||||
import de.grimsi.gameyfin.users.persistence.AvatarContentStore
|
||||
import de.grimsi.gameyfin.users.persistence.UserRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
@@ -23,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||
@@ -88,7 +89,9 @@ class UserService(
|
||||
if (principal is OidcUser) {
|
||||
val oidcUser = User(principal)
|
||||
val userInfoDto = toUserInfo(oidcUser)
|
||||
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities).map { it.authority }
|
||||
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities)
|
||||
.mapNotNull { Role.safeValueOf(it.authority) }
|
||||
.toSet()
|
||||
return userInfoDto
|
||||
}
|
||||
|
||||
@@ -128,16 +131,7 @@ class UserService(
|
||||
}
|
||||
|
||||
fun registerOrUpdateUser(user: User): User {
|
||||
return userRepository.save(user)
|
||||
}
|
||||
|
||||
fun registerOrUpdateUser(user: User, role: Roles): User {
|
||||
return registerOrUpdateUser(user, listOf(role))
|
||||
}
|
||||
|
||||
fun registerOrUpdateUser(user: User, roles: List<Roles>): User {
|
||||
user.password?.let { user.password = passwordEncoder.encode(it) }
|
||||
user.roles = roleService.toRoles(roles)
|
||||
user.password = passwordEncoder.encode(user.password)
|
||||
return userRepository.save(user)
|
||||
}
|
||||
|
||||
@@ -162,7 +156,7 @@ class UserService(
|
||||
password = passwordEncoder.encode(registration.password),
|
||||
email = registration.email,
|
||||
enabled = !adminNeedsToApprove,
|
||||
roles = roleService.toRoles(listOf(Roles.USER))
|
||||
roles = setOf(Role.USER)
|
||||
)
|
||||
|
||||
user = userRepository.save(user)
|
||||
@@ -186,7 +180,7 @@ class UserService(
|
||||
email = email,
|
||||
emailConfirmed = true,
|
||||
enabled = true,
|
||||
roles = roleService.toRoles(listOf(Roles.USER))
|
||||
roles = setOf(Role.USER)
|
||||
)
|
||||
|
||||
return userRepository.save(user)
|
||||
@@ -224,6 +218,31 @@ class UserService(
|
||||
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
fun assignRoles(username: String, roleNames: List<String>): RoleAssignmentResult {
|
||||
val currentUser = SecurityContextHolder.getContext().authentication
|
||||
val targetUser = userByUsername(username)
|
||||
|
||||
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
||||
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
|
||||
|
||||
if (currentUserLevel <= targetUserLevel) {
|
||||
log.error { "User ${currentUser.name} tried to assign roles to user with higher or equal power level to their own" }
|
||||
return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH
|
||||
}
|
||||
|
||||
val newAssignedRoles = roleNames.mapNotNull { r -> Role.safeValueOf(r) }
|
||||
val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel
|
||||
|
||||
if (currentUserLevel <= newAssignedRolesLevel) {
|
||||
log.error { "User ${currentUser.name} tried to assign roles with higher or equal power level than their own" }
|
||||
return RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH
|
||||
}
|
||||
|
||||
targetUser.roles = newAssignedRoles.toMutableSet()
|
||||
userRepository.save(targetUser)
|
||||
return RoleAssignmentResult.SUCCESS
|
||||
}
|
||||
|
||||
fun deleteUser(username: String) {
|
||||
val user = userByUsername(username)
|
||||
userRepository.delete(user)
|
||||
@@ -237,12 +256,12 @@ class UserService(
|
||||
isEnabled = user.enabled,
|
||||
hasAvatar = user.avatar != null,
|
||||
managedBySso = user.oidcProviderId != null,
|
||||
roles = user.roles.map { r -> r.rolename }
|
||||
roles = user.roles
|
||||
)
|
||||
}
|
||||
|
||||
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
|
||||
return roles.map { r -> SimpleGrantedAuthority(r.rolename) }
|
||||
return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
|
||||
}
|
||||
|
||||
private fun userByUsername(username: String): User {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.grimsi.gameyfin.users.avatar
|
||||
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
@@ -35,7 +35,7 @@ class AvatarController(
|
||||
userService.deleteAvatar(auth.name)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
@PostMapping("/avatar/deleteByName")
|
||||
fun deleteAvatarByName(@RequestParam("name") name: String) {
|
||||
userService.deleteAvatar(name)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.grimsi.gameyfin.users.dto
|
||||
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
|
||||
data class UserInfoDto(
|
||||
val username: String,
|
||||
val managedBySso: Boolean,
|
||||
@@ -7,5 +9,5 @@ data class UserInfoDto(
|
||||
val emailConfirmed: Boolean,
|
||||
val isEnabled: Boolean,
|
||||
val hasAvatar: Boolean,
|
||||
var roles: List<String>
|
||||
var roles: Set<Role>
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
package de.grimsi.gameyfin.users.entities
|
||||
|
||||
import jakarta.persistence.*
|
||||
import jakarta.validation.constraints.NotNull
|
||||
|
||||
|
||||
@Entity
|
||||
class Role(
|
||||
@NotNull
|
||||
@Column(unique = true)
|
||||
var rolename: String,
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
var id: Long? = null,
|
||||
|
||||
@ManyToMany(mappedBy = "roles", fetch = FetchType.EAGER)
|
||||
var users: Collection<User> = emptyList()
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.grimsi.gameyfin.users.entities
|
||||
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.core.security.EncryptionConverter
|
||||
import jakarta.annotation.Nullable
|
||||
import jakarta.persistence.*
|
||||
@@ -35,12 +36,8 @@ class User(
|
||||
@Nullable
|
||||
var avatar: Avatar? = null,
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(
|
||||
name = "users_roles",
|
||||
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
|
||||
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
|
||||
)
|
||||
@ElementCollection(targetClass = Role::class, fetch = FetchType.EAGER)
|
||||
@Enumerated(EnumType.STRING)
|
||||
var roles: Set<Role> = emptySet()
|
||||
) {
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.grimsi.gameyfin.users.enums
|
||||
|
||||
enum class RoleAssignmentResult {
|
||||
SUCCESS,
|
||||
TARGET_POWER_LEVEL_TOO_HIGH,
|
||||
ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.passwordreset
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.shared.token.TokenDto
|
||||
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
@@ -21,7 +21,7 @@ class PasswordResetEndpoint(
|
||||
// No return value to prevent enumeration attacks
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun createPasswordResetTokenForUser(username: String): TokenDto {
|
||||
return passwordResetService.generate(username)
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package de.grimsi.gameyfin.users.persistence
|
||||
|
||||
import de.grimsi.gameyfin.users.entities.Role
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface RoleRepository : JpaRepository<Role, Long> {
|
||||
fun findByRolename(roleName: String): Role?
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.grimsi.gameyfin.users.persistence
|
||||
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
@@ -9,4 +10,5 @@ interface UserRepository : JpaRepository<User, Long> {
|
||||
fun findByUsername(userName: String): User?
|
||||
fun findByEmail(email: String): User?
|
||||
fun findByOidcProviderId(oidcProviderId: String): User?
|
||||
fun countUserByRolesContains(role: Role): Int
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.registration
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.shared.token.TokenDto
|
||||
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
@@ -37,12 +37,12 @@ class RegistrationEndpoint(
|
||||
return invitationService.getAssociatedEmail(token)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun confirmRegistration(username: String) {
|
||||
userService.confirmRegistration(username)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun createInvitation(email: String): TokenDto {
|
||||
return invitationService.createInvitation(email)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
|
||||
package de.grimsi.gameyfin.users.util
|
||||
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
|
||||
fun UserDetails.hasRole(role: Roles): Boolean {
|
||||
fun User.hasRole(role: Role): Boolean {
|
||||
return role.roleName in this.roles.map { r -> r.roleName }
|
||||
}
|
||||
|
||||
fun UserDetails.hasRole(role: Role): Boolean {
|
||||
return role.roleName in this.authorities.map { a -> a.authority }
|
||||
}
|
||||
|
||||
fun UserDetails.isAdmin(): Boolean {
|
||||
return hasRole(Roles.SUPERADMIN) || hasRole(Roles.ADMIN)
|
||||
return hasRole(Role.SUPERADMIN) || hasRole(Role.ADMIN)
|
||||
}
|
||||
Reference in New Issue
Block a user