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