mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 08:15:37 +00:00
Implement account status management
Add generic messages for account status change Add message for account removal Improve UX of role assignment Fix a few bugs
This commit is contained in:
@@ -32,12 +32,12 @@ export default function AssignRolesModal({
|
||||
user
|
||||
}: AssignRolesModalProps) {
|
||||
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
|
||||
const [selectedRoles, setSelectedRoles] = useState<Selection>();
|
||||
const [selectedRole, setSelectedRole] = useState<Selection>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRoles(rolesToSelection(user.roles!));
|
||||
UserEndpoint.getAvailableRoles().then((availableRoles) => {
|
||||
setSelectedRole(rolesToSelection(user.roles!));
|
||||
UserEndpoint.getRolesBelow().then((availableRoles) => {
|
||||
setAvailableRoles(availableRoles!.map((role) => ({id: role!.toString()})));
|
||||
});
|
||||
}, []);
|
||||
@@ -47,15 +47,18 @@ export default function AssignRolesModal({
|
||||
}
|
||||
|
||||
async function assignRoles() {
|
||||
if (!selectedRoles) return;
|
||||
if (!selectedRole) return;
|
||||
|
||||
let selectedRolesArray = Array.from(selectedRoles).map((role) => role.toString());
|
||||
let selectedRolesArray = Array.from(selectedRole).map((role) => role.toString());
|
||||
let result = await UserEndpoint.assignRoles(user.username, selectedRolesArray);
|
||||
if (!result) return;
|
||||
switch (result) {
|
||||
case RoleAssignmentResult.SUCCESS:
|
||||
window.location.reload();
|
||||
break;
|
||||
case RoleAssignmentResult.NO_ROLES_PROVIDED:
|
||||
setError("Select at least one role");
|
||||
break;
|
||||
case RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH:
|
||||
setError("Power level of user too high");
|
||||
break;
|
||||
@@ -78,9 +81,10 @@ export default function AssignRolesModal({
|
||||
<ModalBody className="flex flex-col gap-2">
|
||||
<Select
|
||||
items={availableRoles}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedRoles}
|
||||
onSelectionChange={setSelectedRoles}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection={true}
|
||||
selectedKeys={selectedRole}
|
||||
onSelectionChange={setSelectedRole}
|
||||
placeholder="Select roles"
|
||||
renderValue={(items: SelectedItems<Role>) => {
|
||||
return (
|
||||
@@ -106,7 +110,7 @@ export default function AssignRolesModal({
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRoles}>
|
||||
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRole}>
|
||||
Assign roles
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosu
|
||||
import {DotsThreeVertical} from "@phosphor-icons/react";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {useEffect, useState} from "react";
|
||||
import {MessageEndpoint, PasswordResetEndpoint, RegistrationEndpoint} from "Frontend/generated/endpoints";
|
||||
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
||||
import Avatar from "Frontend/components/general/Avatar";
|
||||
import ConfirmUserDeletionModal from "Frontend/components/general/ConfirmUserDeletionModal";
|
||||
@@ -28,30 +28,19 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
let keysToBeDisabled: string[] = [];
|
||||
MessageEndpoint.isEnabled().then((isEnabled) => {
|
||||
if (isEnabled) keysToBeDisabled.push("resetPassword");
|
||||
if (!canUserBeDeleted()) keysToBeDisabled.push("delete")
|
||||
if (!user.hasAvatar) keysToBeDisabled.push("removeAvatar");
|
||||
setDisabledKeys(keysToBeDisabled);
|
||||
});
|
||||
UserEndpoint.canCurrentUserManage(user.username).then((canManage) => {
|
||||
if (!canManage) keysToBeDisabled.push("assignRole", "disableUser", "delete");
|
||||
setDisabledKeys(keysToBeDisabled);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDropdownItems(getDropdownItems());
|
||||
}, [userEnabled]);
|
||||
|
||||
function canUserBeDeleted(): Boolean {
|
||||
// User should not be able to delete himself through this menu (can be done via "My profile")
|
||||
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;
|
||||
|
||||
// Superadmins can delete anyone excluding themselves (and other superadmins if there are any)
|
||||
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);
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
let token = await PasswordResetEndpoint.createPasswordResetTokenForUser(user.username);
|
||||
if (token === undefined) return;
|
||||
@@ -62,37 +51,53 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
function getDropdownItems() {
|
||||
let items = [];
|
||||
|
||||
if (!user.enabled) {
|
||||
if (!user.managedBySso) {
|
||||
if (!userEnabled) {
|
||||
items.push(
|
||||
{
|
||||
key: "enableUser",
|
||||
onPress: () => {
|
||||
UserEndpoint.setUserEnabled(user.username, true).then(() => {
|
||||
setUserEnabled(true);
|
||||
})
|
||||
},
|
||||
label: "Enable user"
|
||||
}
|
||||
);
|
||||
} else {
|
||||
items.push(
|
||||
{
|
||||
key: "disableUser",
|
||||
onPress: () => {
|
||||
UserEndpoint.setUserEnabled(user.username, false).then(() => {
|
||||
setUserEnabled(false);
|
||||
})
|
||||
},
|
||||
label: "Disable user"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: "enableUser",
|
||||
onPress: () => {
|
||||
RegistrationEndpoint.confirmRegistration(user.username).then(() => {
|
||||
setUserEnabled(true);
|
||||
})
|
||||
},
|
||||
label: "Enable user"
|
||||
key: "removeAvatar",
|
||||
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
|
||||
label: "Remove avatar"
|
||||
},
|
||||
{
|
||||
key: "assignRole",
|
||||
onPress: roleAssignmentModal.onOpen,
|
||||
label: "Assign role"
|
||||
},
|
||||
{
|
||||
key: "resetPassword",
|
||||
onPress: resetPassword,
|
||||
label: "Reset password"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: "removeAvatar",
|
||||
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
|
||||
label: "Remove avatar"
|
||||
},
|
||||
{
|
||||
key: "assignRoles",
|
||||
onPress: roleAssignmentModal.onOpen,
|
||||
label: "Assign roles"
|
||||
},
|
||||
{
|
||||
key: "resetPassword",
|
||||
onPress: resetPassword,
|
||||
label: "Reset password"
|
||||
},
|
||||
{
|
||||
items.push({
|
||||
key: "delete",
|
||||
onPress: userDeletionConfirmationModal.onOpen,
|
||||
label: "Delete user"
|
||||
@@ -104,7 +109,8 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"}`}>
|
||||
<Card
|
||||
className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"} ${user.managedBySso ? "text-foreground/50" : ""}`}>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Avatar username={user.username}
|
||||
name={user.username?.charAt(0)}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {RegistrationEndpoint} from "Frontend/generated/endpoints";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Warning} from "@phosphor-icons/react";
|
||||
import {toast} from "sonner";
|
||||
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
|
||||
import UserInvitationAcceptanceResult
|
||||
from "Frontend/generated/de/grimsi/gameyfin/users/enums/UserInvitationAcceptanceResult";
|
||||
|
||||
export default function InvitationRegistrationView() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -23,7 +24,7 @@ export default function InvitationRegistrationView() {
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
async function register(values: any) {
|
||||
async function register(values: any, formik: any) {
|
||||
let result = await RegistrationEndpoint.acceptInvitation(token, {
|
||||
email: email,
|
||||
username: values.username,
|
||||
@@ -31,14 +32,17 @@ export default function InvitationRegistrationView() {
|
||||
});
|
||||
|
||||
switch (result) {
|
||||
case TokenValidationResult.VALID:
|
||||
case UserInvitationAcceptanceResult.SUCCESS:
|
||||
toast.success("Registration successful");
|
||||
navigate("/", {replace: true});
|
||||
break;
|
||||
case TokenValidationResult.EXPIRED:
|
||||
case UserInvitationAcceptanceResult.USERNAME_TAKEN:
|
||||
formik.setFieldError("username", "Username is already taken");
|
||||
break;
|
||||
case UserInvitationAcceptanceResult.TOKEN_EXPIRED:
|
||||
toast.error("Token is expired");
|
||||
break;
|
||||
case TokenValidationResult.INVALID:
|
||||
case UserInvitationAcceptanceResult.TOKEN_INVALID:
|
||||
default:
|
||||
toast.error("Token is invalid");
|
||||
break
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package de.grimsi.gameyfin.core.events
|
||||
|
||||
import de.grimsi.gameyfin.shared.token.Token
|
||||
import de.grimsi.gameyfin.shared.token.TokenType.EmailConfirmation
|
||||
import de.grimsi.gameyfin.shared.token.TokenType.Invitation
|
||||
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
|
||||
import de.grimsi.gameyfin.shared.token.TokenType.*
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
@@ -12,7 +10,7 @@ class UserInvitationEvent(source: Any, val token: Token<Invitation>, val baseUrl
|
||||
|
||||
class UserRegistrationWaitingForApprovalEvent(source: Any, val newUser: User) : ApplicationEvent(source)
|
||||
|
||||
class UserRegistrationEvent(source: Any, val newUser: User, val baseUrl: String) : ApplicationEvent(source)
|
||||
class AccountStatusChangedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
|
||||
|
||||
class EmailNeedsConfirmationEvent(source: Any, val token: Token<EmailConfirmation>, val baseUrl: String) :
|
||||
ApplicationEvent(source)
|
||||
@@ -23,6 +21,8 @@ class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: U
|
||||
class PasswordResetRequestEvent(source: Any, val token: Token<PasswordReset>, val baseUrl: String) :
|
||||
ApplicationEvent(source)
|
||||
|
||||
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
|
||||
|
||||
class GameRequestEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
class GameRequestApprovalEvent(source: Any) : ApplicationEvent(source)
|
||||
@@ -26,7 +26,8 @@ class SsoEnabledCondition : Condition {
|
||||
statement.setString(1, ConfigProperties.SSO.OIDC.Enabled.key)
|
||||
val resultSet = statement.executeQuery()
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getBoolean("value")
|
||||
val encryptedValue = resultSet.getString("value")
|
||||
return EncryptionUtils.decrypt(encryptedValue).toBoolean()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -33,9 +33,9 @@ class MessageService(
|
||||
get() = applicationContext.getBeansOfType(AbstractMessageProvider::class.java).values.toList()
|
||||
|
||||
fun testCredentials(provider: String, credentials: Map<String, Any>): Boolean {
|
||||
val notificationProvider = providers.find { it.providerKey == provider }
|
||||
val messageProvider = providers.find { it.providerKey == provider }
|
||||
val credentialsProperties = Properties().apply { putAll(credentials) }
|
||||
return notificationProvider?.testCredentials(credentialsProperties)
|
||||
return messageProvider?.testCredentials(credentialsProperties)
|
||||
?: throw IllegalArgumentException("Provider '$provider' not found")
|
||||
}
|
||||
|
||||
@@ -52,13 +52,13 @@ class MessageService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a test notification.
|
||||
* Sends a test message.
|
||||
* Recipient is always the current user to prevent misuse.
|
||||
*/
|
||||
fun sendTestNotification(templateKey: String, placeholders: Map<String, String>): Boolean {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send test message" }
|
||||
log.error { "No message provider available, can't send test message" }
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class MessageService(
|
||||
val template = templateService.getMessageTemplate(templateKey)
|
||||
sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders)
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Failed to send test notification" }
|
||||
log.error(e) { "Failed to send test message" }
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -80,11 +80,11 @@ class MessageService(
|
||||
fun onPasswordResetRequest(event: PasswordResetRequestEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send password reset message" }
|
||||
log.error { "No message provider available, can't send password reset message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending password reset request notification" }
|
||||
log.info { "Sending password reset request message" }
|
||||
|
||||
val token = event.token
|
||||
val resetLink = event.baseUrl + "/reset-password?token=${token.secret}"
|
||||
@@ -101,11 +101,11 @@ class MessageService(
|
||||
fun onUserRegistrationWaitingForApproval(event: UserRegistrationWaitingForApprovalEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send 'waiting for approval' message" }
|
||||
log.error { "No message provider available, can't send 'waiting for approval' message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending waiting for approval notification" }
|
||||
log.info { "Sending waiting for approval message" }
|
||||
|
||||
val user = event.newUser
|
||||
sendNotification(
|
||||
@@ -117,23 +117,33 @@ class MessageService(
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener(UserRegistrationEvent::class)
|
||||
fun onUserRegistration(event: UserRegistrationEvent) {
|
||||
@EventListener(AccountStatusChangedEvent::class)
|
||||
fun onAccountStatusChanged(event: AccountStatusChangedEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send registration message" }
|
||||
log.error { "No message provider available, can't send registration message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending registration notification" }
|
||||
log.info { "Sending registration message" }
|
||||
|
||||
val user = event.newUser
|
||||
sendNotification(
|
||||
user.email,
|
||||
"[Gameyfin] Welcome",
|
||||
MessageTemplates.Welcome,
|
||||
mapOf("username" to user.username, "baseUrl" to event.baseUrl)
|
||||
)
|
||||
val user = event.user
|
||||
|
||||
if (event.user.enabled) {
|
||||
sendNotification(
|
||||
user.email,
|
||||
"[Gameyfin] Your account has been enabled",
|
||||
MessageTemplates.AccountEnabled,
|
||||
mapOf("username" to user.username, "baseUrl" to event.baseUrl)
|
||||
)
|
||||
} else {
|
||||
sendNotification(
|
||||
user.email,
|
||||
"[Gameyfin] Your account has been disabled",
|
||||
MessageTemplates.AccountDisabled,
|
||||
mapOf("username" to user.username, "baseUrl" to event.baseUrl)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Async
|
||||
@@ -141,11 +151,11 @@ class MessageService(
|
||||
fun onRegistrationAttemptWithExistingEmail(event: RegistrationAttemptWithExistingEmailEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send 'registration attempt with existing email' message" }
|
||||
log.error { "No message provider available, can't send 'registration attempt with existing email' message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending registration attempt with existing email notification" }
|
||||
log.info { "Sending registration attempt with existing email message" }
|
||||
|
||||
val user = event.existingUser
|
||||
sendNotification(
|
||||
@@ -161,11 +171,11 @@ class MessageService(
|
||||
fun onEmailNeedsConfirmation(event: EmailNeedsConfirmationEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send email confirmation message" }
|
||||
log.error { "No message provider available, can't send email confirmation message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending email confirmation notification" }
|
||||
log.info { "Sending email confirmation message" }
|
||||
|
||||
val user = event.token.creator
|
||||
val confirmationLink = event.baseUrl + "/confirm-email?token=${event.token.secret}"
|
||||
@@ -182,11 +192,11 @@ class MessageService(
|
||||
fun onUserInvitation(event: UserInvitationEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send invitation message" }
|
||||
log.error { "No message provider available, can't send invitation message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending invitation notification" }
|
||||
log.info { "Sending invitation message" }
|
||||
|
||||
val invitationLink = event.baseUrl + "/accept-invitation?token=${event.token.secret}"
|
||||
sendNotification(
|
||||
@@ -196,4 +206,23 @@ class MessageService(
|
||||
mapOf("invitationLink" to invitationLink)
|
||||
)
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener(AccountDeletedEvent::class)
|
||||
fun onAccountDeletion(event: AccountDeletedEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No message provider available, can't send account deletion message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending account deletion message" }
|
||||
|
||||
sendNotification(
|
||||
event.user.email,
|
||||
"[Gameyfin] Your account has been deleted",
|
||||
MessageTemplates.AccountDeleted,
|
||||
mapOf("username" to event.user.username, "baseUrl" to event.baseUrl)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,24 @@ sealed class MessageTemplates(
|
||||
listOf("username")
|
||||
)
|
||||
|
||||
data object Welcome : MessageTemplates(
|
||||
"welcome",
|
||||
"Welcome",
|
||||
"Template for the welcome message for new users",
|
||||
data object AccountEnabled : MessageTemplates(
|
||||
"account-enabled",
|
||||
"Account Enabled",
|
||||
"Template for the enabling of a users account",
|
||||
listOf("username", "baseUrl")
|
||||
)
|
||||
|
||||
data object AccountDisabled : MessageTemplates(
|
||||
"account-disabled",
|
||||
"Account Disabled",
|
||||
"Template for the disabling of a users account",
|
||||
listOf("username", "baseUrl")
|
||||
)
|
||||
|
||||
data object AccountDeleted : MessageTemplates(
|
||||
"account-deleted",
|
||||
"Account Deleted",
|
||||
"Template for the account deletion message",
|
||||
listOf("username", "baseUrl")
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ class UserEndpoint(
|
||||
userService.updateUser(username, updates)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun setUserEnabled(username: String, enabled: Boolean) {
|
||||
userService.setUserEnabled(username, enabled)
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
fun deleteUser() {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
@@ -65,6 +70,11 @@ class UserEndpoint(
|
||||
return roleService.getRolesBelowAuth(auth).map { it.roleName }
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun canCurrentUserManage(username: String): Boolean {
|
||||
return userService.canManage(username)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun assignRoles(username: String, roles: List<String>): RoleAssignmentResult {
|
||||
return userService.assignRoles(username, roles)
|
||||
|
||||
@@ -4,10 +4,7 @@ import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import de.grimsi.gameyfin.config.ConfigService
|
||||
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
|
||||
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
|
||||
import de.grimsi.gameyfin.core.events.UserRegistrationWaitingForApprovalEvent
|
||||
import de.grimsi.gameyfin.core.events.*
|
||||
import de.grimsi.gameyfin.users.dto.UserInfoDto
|
||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||
import de.grimsi.gameyfin.users.dto.UserUpdateDto
|
||||
@@ -164,7 +161,7 @@ class UserService(
|
||||
if (adminNeedsToApprove) {
|
||||
eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user))
|
||||
} else {
|
||||
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
|
||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
if (!user.emailConfirmed) {
|
||||
@@ -183,6 +180,10 @@ class UserService(
|
||||
roles = setOf(Role.USER)
|
||||
)
|
||||
|
||||
if (existsByUsername(user.username)) {
|
||||
throw IllegalStateException("User with username '${user.username}' already exists")
|
||||
}
|
||||
|
||||
return userRepository.save(user)
|
||||
}
|
||||
|
||||
@@ -211,27 +212,22 @@ class UserService(
|
||||
userRepository.save(user)
|
||||
}
|
||||
|
||||
fun confirmRegistration(username: String) {
|
||||
val user = userByUsername(username)
|
||||
user.enabled = true
|
||||
userRepository.save(user)
|
||||
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
fun assignRoles(username: String, roleNames: List<String>): RoleAssignmentResult {
|
||||
if (roleNames.isEmpty()) {
|
||||
return RoleAssignmentResult.NO_ROLES_PROVIDED
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!canManage(targetUser)) {
|
||||
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
|
||||
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
||||
|
||||
if (currentUserLevel <= newAssignedRolesLevel) {
|
||||
log.error { "User ${currentUser.name} tried to assign roles with higher or equal power level than their own" }
|
||||
@@ -243,9 +239,29 @@ class UserService(
|
||||
return RoleAssignmentResult.SUCCESS
|
||||
}
|
||||
|
||||
fun canManage(targetUsername: String): Boolean {
|
||||
val targetUser = userByUsername(targetUsername)
|
||||
return canManage(targetUser)
|
||||
}
|
||||
|
||||
fun canManage(targetUser: User): Boolean {
|
||||
val currentUser = SecurityContextHolder.getContext().authentication
|
||||
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
||||
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
|
||||
return currentUserLevel > targetUserLevel
|
||||
}
|
||||
|
||||
fun setUserEnabled(username: String, enabled: Boolean) {
|
||||
val user = userByUsername(username)
|
||||
user.enabled = enabled
|
||||
userRepository.save(user)
|
||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
fun deleteUser(username: String) {
|
||||
val user = userByUsername(username)
|
||||
userRepository.delete(user)
|
||||
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
fun toUserInfo(user: User): UserInfoDto {
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.users.enums
|
||||
|
||||
enum class RoleAssignmentResult {
|
||||
SUCCESS,
|
||||
NO_ROLES_PROVIDED,
|
||||
TARGET_POWER_LEVEL_TOO_HIGH,
|
||||
ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.grimsi.gameyfin.users.enums
|
||||
|
||||
enum class UserInvitationAcceptanceResult {
|
||||
SUCCESS,
|
||||
TOKEN_INVALID,
|
||||
TOKEN_EXPIRED,
|
||||
USERNAME_TAKEN
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package de.grimsi.gameyfin.users.registration
|
||||
|
||||
import de.grimsi.gameyfin.core.Utils
|
||||
import de.grimsi.gameyfin.core.events.AccountStatusChangedEvent
|
||||
import de.grimsi.gameyfin.core.events.UserInvitationEvent
|
||||
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
|
||||
import de.grimsi.gameyfin.shared.token.TokenDto
|
||||
import de.grimsi.gameyfin.shared.token.TokenRepository
|
||||
import de.grimsi.gameyfin.shared.token.TokenService
|
||||
import de.grimsi.gameyfin.shared.token.TokenType.Invitation
|
||||
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||
import de.grimsi.gameyfin.users.enums.UserInvitationAcceptanceResult
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
@@ -44,15 +44,19 @@ class InvitationService(
|
||||
return payload[EMAIL_KEY]
|
||||
}
|
||||
|
||||
fun acceptInvitation(secret: String, registration: UserRegistrationDto): TokenValidationResult {
|
||||
val invitationToken = super.get(secret, Invitation) ?: return TokenValidationResult.INVALID
|
||||
val email = invitationToken.payload[EMAIL_KEY] ?: return TokenValidationResult.INVALID
|
||||
if (invitationToken.expired) return TokenValidationResult.EXPIRED
|
||||
fun acceptInvitation(secret: String, registration: UserRegistrationDto): UserInvitationAcceptanceResult {
|
||||
val invitationToken = super.get(secret, Invitation) ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
|
||||
val email = invitationToken.payload[EMAIL_KEY] ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
|
||||
if (invitationToken.expired) return UserInvitationAcceptanceResult.TOKEN_EXPIRED
|
||||
|
||||
val user = userService.registerUserFromInvitation(registration, email)
|
||||
super.delete(invitationToken)
|
||||
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
|
||||
try {
|
||||
val user = userService.registerUserFromInvitation(registration, email)
|
||||
super.delete(invitationToken)
|
||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||
} catch (e: IllegalStateException) {
|
||||
return UserInvitationAcceptanceResult.USERNAME_TAKEN
|
||||
}
|
||||
|
||||
return TokenValidationResult.VALID
|
||||
return UserInvitationAcceptanceResult.SUCCESS
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
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
|
||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||
import de.grimsi.gameyfin.users.enums.UserInvitationAcceptanceResult
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@AnonymousAllowed
|
||||
@@ -29,7 +29,7 @@ class RegistrationEndpoint(
|
||||
return !userService.existsByUsername(username)
|
||||
}
|
||||
|
||||
fun acceptInvitation(token: String, registration: UserRegistrationDto): TokenValidationResult {
|
||||
fun acceptInvitation(token: String, registration: UserRegistrationDto): UserInvitationAcceptanceResult {
|
||||
return invitationService.acceptInvitation(token, registration)
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@ class RegistrationEndpoint(
|
||||
return invitationService.getAssociatedEmail(token)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun confirmRegistration(username: String) {
|
||||
userService.confirmRegistration(username)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun createInvitation(email: String): TokenDto {
|
||||
return invitationService.createInvitation(email)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-title>[Gameyfin] Welcome</mj-title>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Arial, sans-serif"/>
|
||||
<mj-text font-size="16px"/>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-image alt="Gameyfin Logo" title="Gameyfin Logo" width="128px" src="{logo}"/>
|
||||
<mj-image alt="Gameyfin Logo" title="Gameyfin Logo" height="2px" padding-bottom="20px"
|
||||
src="{gradient}"/>
|
||||
|
||||
<mj-text font-size="20px" font-family="helvetica">Hello there {username},
|
||||
<br/>
|
||||
<br/>
|
||||
</mj-text>
|
||||
<mj-text>your account has been permanently deleted.
|
||||
<br/>
|
||||
You will be missed!
|
||||
<br/>
|
||||
If you think this is a mistake, contact your <a href="{baseUrl}">Gameyfin</a> administrator.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -0,0 +1,28 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-title>[Gameyfin] Welcome</mj-title>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Arial, sans-serif"/>
|
||||
<mj-text font-size="16px"/>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-image alt="Gameyfin Logo" title="Gameyfin Logo" width="128px" src="{logo}"/>
|
||||
<mj-image alt="Gameyfin Logo" title="Gameyfin Logo" height="2px" padding-bottom="20px"
|
||||
src="{gradient}"/>
|
||||
|
||||
<mj-text font-size="20px" font-family="helvetica">Hello there {username},
|
||||
<br/>
|
||||
<br/>
|
||||
</mj-text>
|
||||
<mj-text>your account has been disabled.
|
||||
<br/>
|
||||
If you think this is a mistake, contact your <a href="{baseUrl}">Gameyfin</a> administrator.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<br/>
|
||||
<br/>
|
||||
</mj-text>
|
||||
<mj-text>your registration has been approved!
|
||||
<mj-text>your account has been enabled!
|
||||
<br/>
|
||||
You can now start browsing games in <a href="{baseUrl}">Gameyfin</a>.
|
||||
</mj-text>
|
||||
Reference in New Issue
Block a user