diff --git a/src/main/frontend/components/administration/NotificationManagement.tsx b/src/main/frontend/components/administration/MessageManagement.tsx similarity index 75% rename from src/main/frontend/components/administration/NotificationManagement.tsx rename to src/main/frontend/components/administration/MessageManagement.tsx index 693b9cd..43a7fd0 100644 --- a/src/main/frontend/components/administration/NotificationManagement.tsx +++ b/src/main/frontend/components/administration/MessageManagement.tsx @@ -3,14 +3,14 @@ import withConfigPage from "Frontend/components/administration/withConfigPage"; import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import Section from "Frontend/components/general/Section"; import {Button, Card, Tooltip, useDisclosure} from "@nextui-org/react"; -import {MessageTemplateEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints"; +import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints"; import {toast} from "sonner"; import {PaperPlaneRight, Pencil} from "@phosphor-icons/react"; -import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto"; -import SendTestNotificationModal from "Frontend/components/administration/notifications/SendTestNotificationModal"; -import EditTemplateModal from "Frontend/components/administration/notifications/EditTemplateModel"; +import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto"; +import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal"; +import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel"; -function NotificationManagementLayout({getConfig, getConfigs, formik}: any) { +function MessageManagementLayout({getConfig, getConfigs, formik}: any) { const editorModal = useDisclosure(); const testNotificationModal = useDisclosure(); @@ -25,13 +25,13 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) { async function verifyCredentials(provider: string) { const credentials: Record = { - host: formik.values.notifications.providers.email.host, - port: formik.values.notifications.providers.email.port, - username: formik.values.notifications.providers.email.username, - password: formik.values.notifications.providers.email.password + host: formik.values.messages.providers.email.host, + port: formik.values.messages.providers.email.port, + username: formik.values.messages.providers.email.username, + password: formik.values.messages.providers.email.password } - const areCredentialsValid = await NotificationEndpoint.verifyCredentials(provider, credentials); + const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials); if (areCredentialsValid) { toast.success("Credentials are valid") @@ -57,22 +57,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
- - - - - + + + + + isDisabled={!formik.values.messages.providers.email.enabled}/> + formik.values.messages.providers.email.enabled && + formik.values.messages.providers.email.host && + formik.values.messages.providers.email.port && + formik.values.messages.providers.email.username)}>Test
@@ -119,4 +119,4 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) { ); } -export const NotificationManagement = withConfigPage(NotificationManagementLayout, "Notifications", "notifications"); \ No newline at end of file +export const MessageManagement = withConfigPage(MessageManagementLayout, "Messages", "messages"); \ No newline at end of file diff --git a/src/main/frontend/components/administration/notifications/EditTemplateModel.tsx b/src/main/frontend/components/administration/messages/EditTemplateModel.tsx similarity index 84% rename from src/main/frontend/components/administration/notifications/EditTemplateModel.tsx rename to src/main/frontend/components/administration/messages/EditTemplateModel.tsx index 2319e5b..70de43a 100644 --- a/src/main/frontend/components/administration/notifications/EditTemplateModel.tsx +++ b/src/main/frontend/components/administration/messages/EditTemplateModel.tsx @@ -12,8 +12,8 @@ import { } from "@nextui-org/react"; import {toast} from "sonner"; import {MessageTemplateEndpoint} from "Frontend/generated/endpoints"; -import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto"; -import TemplateType from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/TemplateType"; +import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto"; +import TemplateType from "Frontend/generated/de/grimsi/gameyfin/messages/templates/TemplateType"; interface EditTemplateModalProps { isOpen: boolean; @@ -41,6 +41,12 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent); } + function templateContainsAllRequiredPlaceholders(): boolean { + if (!selectedTemplate || !selectedTemplate.availablePlaceholders) return false; + return selectedTemplate.availablePlaceholders + .every((p) => templateContent.includes(`{${p}}`)) + } + return ( @@ -98,15 +104,17 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat - diff --git a/src/main/frontend/components/administration/notifications/SendTestNotificationModal.tsx b/src/main/frontend/components/administration/messages/SendTestNotificationModal.tsx similarity index 93% rename from src/main/frontend/components/administration/notifications/SendTestNotificationModal.tsx rename to src/main/frontend/components/administration/messages/SendTestNotificationModal.tsx index ac9c837..e73fde4 100644 --- a/src/main/frontend/components/administration/notifications/SendTestNotificationModal.tsx +++ b/src/main/frontend/components/administration/messages/SendTestNotificationModal.tsx @@ -3,9 +3,9 @@ import {Form, Formik} from "formik"; import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react"; import {toast} from "sonner"; import Input from "Frontend/components/general/Input"; -import {NotificationEndpoint} from "Frontend/generated/endpoints"; +import {MessageEndpoint} from "Frontend/generated/endpoints"; import * as Yup from "yup"; -import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto"; +import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto"; interface SendTestNotificationModalProps { isOpen: boolean; @@ -35,7 +35,7 @@ export default function SendTestNotificationModal({ { - await NotificationEndpoint.sendTestNotification(selectedTemplate?.key, values); + await MessageEndpoint.sendTestNotification(selectedTemplate?.key, values); toast.success("Test notification to you has been sent"); onClose(); }} diff --git a/src/main/frontend/routes.tsx b/src/main/frontend/routes.tsx index e989334..f7b3cdc 100644 --- a/src/main/frontend/routes.tsx +++ b/src/main/frontend/routes.tsx @@ -12,7 +12,7 @@ import ProfileManagement from "Frontend/components/administration/ProfileManagem import {SsoManagement} from "Frontend/components/administration/SsoManagement"; import {AdministrationView} from "Frontend/views/AdministrationView"; import {ProfileView} from "Frontend/views/ProfileView"; -import {NotificationManagement} from "Frontend/components/administration/NotificationManagement"; +import {MessageManagement} from "Frontend/components/administration/MessageManagement"; import {LogManagement} from "Frontend/components/administration/LogManagement"; import PasswordResetView from "Frontend/views/PasswordResetView"; @@ -43,7 +43,7 @@ export const routes = protectRoutes([ {path: 'libraries', element: }, {path: 'users', element: }, {path: 'sso', element: }, - {path: 'notifications', element: }, + {path: 'messages', element: }, {path: 'logs', element: } ] } diff --git a/src/main/frontend/views/AdministrationView.tsx b/src/main/frontend/views/AdministrationView.tsx index 45c3336..af8f3fa 100644 --- a/src/main/frontend/views/AdministrationView.tsx +++ b/src/main/frontend/views/AdministrationView.tsx @@ -18,8 +18,8 @@ const menuItems: MenuItem[] = [ icon: }, { - title: "Notifications", - url: "notifications", + title: "Messages", + url: "messages", icon: }, { diff --git a/src/main/frontend/views/LoginView.tsx b/src/main/frontend/views/LoginView.tsx index 0b31a31..5f87f0c 100644 --- a/src/main/frontend/views/LoginView.tsx +++ b/src/main/frontend/views/LoginView.tsx @@ -17,7 +17,7 @@ import { } from "@nextui-org/react"; import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert"; import {useNavigate} from "react-router-dom"; -import {NotificationEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints"; +import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints"; import {toast} from "sonner"; export default function LoginView() { @@ -35,7 +35,7 @@ export default function LoginView() { const navigate = useNavigate(); useEffect(() => { - NotificationEndpoint.isEnabled().then(setCanResetPassword); + MessageEndpoint.isEnabled().then(setCanResetPassword); }, []); useEffect(() => { diff --git a/src/main/frontend/views/PasswordResetView.tsx b/src/main/frontend/views/PasswordResetView.tsx index e887cca..760e320 100644 --- a/src/main/frontend/views/PasswordResetView.tsx +++ b/src/main/frontend/views/PasswordResetView.tsx @@ -7,7 +7,7 @@ import {PasswordResetEndpoint} from "Frontend/generated/endpoints"; import React, {useEffect, useState} from "react"; import {Warning} from "@phosphor-icons/react"; import {toast} from "sonner"; -import PasswordResetResult from "Frontend/generated/de/grimsi/gameyfin/users/dto/PasswordResetResult"; +import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult"; export default function PasswordResetView() { const [searchParams, setSearchParams] = useSearchParams(); @@ -21,17 +21,17 @@ export default function PasswordResetView() { async function resetPassword(values: any) { let token = searchParams.get("token") as string; - let result = await PasswordResetEndpoint.resetPassword(token, values.password) as PasswordResetResult; + let result = await PasswordResetEndpoint.resetPassword(token, values.password) as TokenValidationResult; switch (result) { - case PasswordResetResult.SUCCESS: + case TokenValidationResult.VALID: toast.success("Password reset successfully"); navigate("/", {replace: true}); break; - case PasswordResetResult.EXPIRED_TOKEN: + case TokenValidationResult.EXPIRED: toast.error("Token is expired"); break; - case PasswordResetResult.INVALID_TOKEN: + case TokenValidationResult.INVALID: default: toast.error("Token is invalid"); break diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt index 161832f..06b20fb 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt @@ -141,39 +141,39 @@ sealed class ConfigProperties( } } - /** Notifications */ - sealed class Notifications { + /** Messages */ + sealed class Messages { sealed class Providers { sealed class Email { data object Enabled : ConfigProperties( Boolean::class, - "notifications.providers.email.enabled", + "messages.providers.email.enabled", "Enable E-Mail notifications", false ) data object Host : ConfigProperties( String::class, - "notifications.providers.email.host", + "messages.providers.email.host", "URL of the email server" ) data object Port : ConfigProperties( Int::class, - "notifications.providers.email.port", + "messages.providers.email.port", "Port of the email server", 587 ) data object Username : ConfigProperties( String::class, - "notifications.providers.email.username", + "messages.providers.email.username", "Username for the email account" ) data object Password : ConfigProperties( String::class, - "notifications.providers.email.password", + "messages.providers.email.password", "Password for the email account" ) } diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt index f04f9b9..f9bfca6 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt @@ -1,13 +1,14 @@ package de.grimsi.gameyfin.core.events -import de.grimsi.gameyfin.users.entities.PasswordResetToken +import de.grimsi.gameyfin.shared.token.Token +import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset import org.springframework.context.ApplicationEvent class UserInvitationEvent(source: Any) : ApplicationEvent(source) class UserRegistrationEvent(source: Any) : ApplicationEvent(source) -class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken, val baseUrl: String) : +class PasswordResetRequestEvent(source: Any, val token: Token, val baseUrl: String) : ApplicationEvent(source) class GameRequestEvent(source: Any) : ApplicationEvent(source) diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageEndpoint.kt similarity index 59% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationEndpoint.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/MessageEndpoint.kt index 9364c51..c3569cf 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageEndpoint.kt @@ -1,4 +1,4 @@ -package de.grimsi.gameyfin.notifications +package de.grimsi.gameyfin.messages import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint @@ -7,20 +7,20 @@ import jakarta.annotation.security.RolesAllowed @Endpoint @RolesAllowed(Roles.Names.ADMIN) -class NotificationEndpoint( - private val notificationService: NotificationService +class MessageEndpoint( + private val messageService: MessageService ) { @AnonymousAllowed fun isEnabled(): Boolean { - return notificationService.enabled + return messageService.enabled } fun verifyCredentials(provider: String, credentials: Map): Boolean { - return notificationService.testCredentials(provider, credentials) + return messageService.testCredentials(provider, credentials) } fun sendTestNotification(templateKey: String, placeholders: Map): Boolean { - return notificationService.sendTestNotification(templateKey, placeholders) + return messageService.sendTestNotification(templateKey, placeholders) } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt similarity index 87% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt index d93820a..f3c7b3f 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt @@ -1,9 +1,9 @@ -package de.grimsi.gameyfin.notifications +package de.grimsi.gameyfin.messages import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent -import de.grimsi.gameyfin.notifications.providers.AbstractNotificationProvider -import de.grimsi.gameyfin.notifications.templates.MessageTemplateService -import de.grimsi.gameyfin.notifications.templates.MessageTemplates +import de.grimsi.gameyfin.messages.providers.AbstractMessageProvider +import de.grimsi.gameyfin.messages.templates.MessageTemplateService +import de.grimsi.gameyfin.messages.templates.MessageTemplates import de.grimsi.gameyfin.users.UserService import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging @@ -18,7 +18,7 @@ import java.util.* @EnableAsync @Service -class NotificationService( +class MessageService( private val applicationContext: ApplicationContext, private val templateService: MessageTemplateService, private val userService: UserService @@ -29,8 +29,8 @@ class NotificationService( val enabled: Boolean get() = providers.any { it.enabled } - private val providers: List - get() = applicationContext.getBeansOfType(AbstractNotificationProvider::class.java).values.toList() + private val providers: List + get() = applicationContext.getBeansOfType(AbstractMessageProvider::class.java).values.toList() fun testCredentials(provider: String, credentials: Map): Boolean { val notificationProvider = providers.find { it.providerKey == provider } @@ -87,7 +87,7 @@ class NotificationService( log.info { "Sending password reset request notification" } val token = event.token - val resetLink = event.baseUrl + "/reset-password?token=${token.token}" + val resetLink = event.baseUrl + "/reset-password?token=${token.secret}" sendNotification( token.user.email, "[Gameyfin] Password Reset Request", diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/AbstractNotificationProvider.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/providers/AbstractMessageProvider.kt similarity index 71% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/providers/AbstractNotificationProvider.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/providers/AbstractMessageProvider.kt index e6742d3..a92016b 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/AbstractNotificationProvider.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/providers/AbstractMessageProvider.kt @@ -1,16 +1,16 @@ -package de.grimsi.gameyfin.notifications.providers +package de.grimsi.gameyfin.messages.providers import de.grimsi.gameyfin.config.ConfigService -import de.grimsi.gameyfin.notifications.templates.TemplateType +import de.grimsi.gameyfin.messages.templates.TemplateType import java.util.* -abstract class AbstractNotificationProvider( +abstract class AbstractMessageProvider( val providerKey: String, val supportedTemplateType: TemplateType, protected val config: ConfigService ) { protected companion object { - const val BASE_KEY = "notifications.providers" + const val BASE_KEY = "messages.providers" } private val configKey = String.format("%s.%s.enabled", BASE_KEY, providerKey) diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/EmailNotificationProvider.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/providers/EmailMessageProvider.kt similarity index 84% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/providers/EmailNotificationProvider.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/providers/EmailMessageProvider.kt index be9a57a..b7912d7 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/EmailNotificationProvider.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/providers/EmailMessageProvider.kt @@ -1,8 +1,8 @@ -package de.grimsi.gameyfin.notifications.providers +package de.grimsi.gameyfin.messages.providers import de.grimsi.gameyfin.config.ConfigProperties import de.grimsi.gameyfin.config.ConfigService -import de.grimsi.gameyfin.notifications.templates.TemplateType +import de.grimsi.gameyfin.messages.templates.TemplateType import jakarta.mail.Message import jakarta.mail.MessagingException import jakarta.mail.Session @@ -12,17 +12,17 @@ import org.springframework.stereotype.Service import java.util.* @Service -class EmailNotificationProvider( +class EmailMessageProvider( config: ConfigService -) : AbstractNotificationProvider("email", TemplateType.MJML, config) { +) : AbstractMessageProvider("email", TemplateType.MJML, config) { private val storedCredentials: Properties get() { val properties = Properties() - properties["host"] = config.get(ConfigProperties.Notifications.Providers.Email.Host) - properties["port"] = config.get(ConfigProperties.Notifications.Providers.Email.Port) - properties["username"] = config.get(ConfigProperties.Notifications.Providers.Email.Username) - properties["password"] = config.get(ConfigProperties.Notifications.Providers.Email.Password) + properties["host"] = config.get(ConfigProperties.Messages.Providers.Email.Host) + properties["port"] = config.get(ConfigProperties.Messages.Providers.Email.Port) + properties["username"] = config.get(ConfigProperties.Messages.Providers.Email.Username) + properties["password"] = config.get(ConfigProperties.Messages.Providers.Email.Password) return properties } diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateDto.kt similarity index 74% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateDto.kt index 4e879c0..697cdaa 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateDto.kt @@ -1,4 +1,4 @@ -package de.grimsi.gameyfin.notifications.templates +package de.grimsi.gameyfin.messages.templates data class MessageTemplateDto( val key: String, diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateEndpoint.kt similarity index 94% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateEndpoint.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateEndpoint.kt index 7f367bf..791c828 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateEndpoint.kt @@ -1,4 +1,4 @@ -package de.grimsi.gameyfin.notifications.templates +package de.grimsi.gameyfin.messages.templates import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.core.Roles diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateService.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateService.kt similarity index 96% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateService.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateService.kt index f20c4d3..bdaed2b 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateService.kt @@ -1,4 +1,4 @@ -package de.grimsi.gameyfin.notifications.templates +package de.grimsi.gameyfin.messages.templates import ch.digitalfondue.mjml4j.Mjml4j import io.github.oshai.kotlinlogging.KotlinLogging @@ -14,7 +14,7 @@ class MessageTemplateService { companion object { private const val TEMPLATE_PATH = "templates" - private const val DEFAULT_TEMPLATE_PATH = "templates/notifications" + private const val DEFAULT_TEMPLATE_PATH = "templates/messages" } private val log = KotlinLogging.logger {} diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplates.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplates.kt similarity index 74% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplates.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplates.kt index 81483fd..2e0e5a3 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplates.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplates.kt @@ -1,4 +1,4 @@ -package de.grimsi.gameyfin.notifications.templates +package de.grimsi.gameyfin.messages.templates sealed class MessageTemplates( val key: String, @@ -20,6 +20,13 @@ sealed class MessageTemplates( listOf("username") ) + data object EmailConfirmation : MessageTemplates( + "email-confirmation", + "Email Confirmation", + "Template for the email confirmation message", + listOf("username", "confirmationLink") + ) + data object PasswordResetRequest : MessageTemplates( "password-reset-request", "Password Reset Request", diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MjmlTemplate.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MjmlTemplate.kt similarity index 99% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MjmlTemplate.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/templates/MjmlTemplate.kt index a070211..4677276 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MjmlTemplate.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MjmlTemplate.kt @@ -1,4 +1,4 @@ -package de.grimsi.gameyfin.notifications.templates +package de.grimsi.gameyfin.messages.templates abstract class MjmlTemplate { companion object { diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/TemplateType.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/TemplateType.kt similarity index 61% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/templates/TemplateType.kt rename to src/main/kotlin/de/grimsi/gameyfin/messages/templates/TemplateType.kt index 8bd25c0..c967338 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/TemplateType.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/TemplateType.kt @@ -1,4 +1,4 @@ -package de.grimsi.gameyfin.notifications.templates +package de.grimsi.gameyfin.messages.templates enum class TemplateType(val extension: String) { MJML("mjml"), TEXT("txt") diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/Token.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/Token.kt new file mode 100644 index 0000000..85201e9 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/Token.kt @@ -0,0 +1,33 @@ +package de.grimsi.gameyfin.shared.token + +import de.grimsi.gameyfin.core.security.EncryptionConverter +import de.grimsi.gameyfin.users.entities.User +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.Id +import jakarta.persistence.OneToOne +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.Type +import java.time.Instant +import java.util.UUID +import kotlin.time.toJavaDuration + +@Entity +class Token( + @Id + @Convert(converter = EncryptionConverter::class) + val secret: String = UUID.randomUUID().toString(), + + @Type(TokenTypeUserType::class) + val type: T, + + @OneToOne(targetEntity = User::class, fetch = FetchType.EAGER) + val user: User, + + @CreationTimestamp + val createdOn: Instant? = null +) { + val expired: Boolean + get() = createdOn?.plus(type.expiration.toJavaDuration())!!.isBefore(Instant.now()) +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenRepository.kt new file mode 100644 index 0000000..8629f39 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenRepository.kt @@ -0,0 +1,9 @@ +package de.grimsi.gameyfin.shared.token + +import de.grimsi.gameyfin.users.entities.User +import org.springframework.data.jpa.repository.JpaRepository + +interface TokenRepository : JpaRepository, String> { + fun findBySecret(secret: String): Token<*>? + fun findByUserAndType(user: User, type: T): Token? +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenService.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenService.kt new file mode 100644 index 0000000..95e128f --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenService.kt @@ -0,0 +1,47 @@ +package de.grimsi.gameyfin.shared.token + +import de.grimsi.gameyfin.users.entities.User +import io.github.oshai.kotlinlogging.KotlinLogging + +abstract class TokenService( + private val type: T, + private val tokenRepository: TokenRepository +) { + + private val log = KotlinLogging.logger {} + + open fun generate(user: User): Token { + val token = Token( + user = user, + type = type + ) + + tokenRepository.findByUserAndType(user, type)?.let { + log.warn { "Deleting existing '${it.type}' token for user '${user.username}'" } + delete(it) + } + + return tokenRepository.save(token) + } + + fun get(secret: String, type: T): Token? { + val token = tokenRepository.findBySecret(secret) ?: return null + + return if (token.type == type) { + @Suppress("UNCHECKED_CAST") + token as Token + } else { + log.error { "Token '$token' is not of type '$type'" } + null + } + } + + fun delete(token: Token) { + tokenRepository.delete(token) + } + + fun validate(secret: String): TokenValidationResult { + val token = tokenRepository.findBySecret(secret) ?: return TokenValidationResult.INVALID + return if (token.expired) TokenValidationResult.EXPIRED else TokenValidationResult.VALID + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenType.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenType.kt new file mode 100644 index 0000000..fe3ada9 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenType.kt @@ -0,0 +1,20 @@ +package de.grimsi.gameyfin.shared.token + +import java.io.Serializable +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +sealed class TokenType( + val key: String, + val expiration: Duration +) : Serializable { + data object PasswordReset : TokenType("password-reset", 15.minutes) + data object EmailVerification : TokenType("email-verification", Duration.INFINITE) + data object Invitation : TokenType("invitation", Duration.INFINITE) + + fun readResolve(): Any = when (this) { + PasswordReset -> PasswordReset + EmailVerification -> EmailVerification + Invitation -> Invitation + } +} diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenTypeUserType.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenTypeUserType.kt new file mode 100644 index 0000000..100e52f --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenTypeUserType.kt @@ -0,0 +1,55 @@ +package de.grimsi.gameyfin.shared.token + +import org.hibernate.engine.spi.SharedSessionContractImplementor +import org.hibernate.usertype.UserType +import java.io.Serializable +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.Types + +class TokenTypeUserType : UserType { + + override fun getSqlType(): Int = Types.VARCHAR + + override fun returnedClass(): Class = TokenType::class.java + + override fun equals(x: TokenType, y: TokenType): Boolean = x.key == y.key + + override fun hashCode(x: TokenType): Int = x.key.hashCode() + + override fun nullSafeGet( + rs: ResultSet, + position: Int, + session: SharedSessionContractImplementor, + owner: Any? + ): TokenType? { + val key = rs.getString(position) ?: return null + return when (key) { + TokenType.PasswordReset.key -> TokenType.PasswordReset + TokenType.EmailVerification.key -> TokenType.EmailVerification + TokenType.Invitation.key -> TokenType.Invitation + else -> throw IllegalArgumentException("Unknown TokenType key: $key") + } + } + + override fun nullSafeSet( + st: PreparedStatement, + value: TokenType?, + index: Int, + session: SharedSessionContractImplementor + ) { + if (value == null) { + st.setNull(index, Types.VARCHAR) + } else { + st.setString(index, value.key) + } + } + + override fun deepCopy(value: TokenType): TokenType = value + + override fun isMutable(): Boolean = false + + override fun disassemble(value: TokenType): Serializable = value.key + + override fun assemble(cached: Serializable, owner: Any?): TokenType = cached as TokenType +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenValidationResult.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenValidationResult.kt new file mode 100644 index 0000000..be8f7b7 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenValidationResult.kt @@ -0,0 +1,5 @@ +package de.grimsi.gameyfin.shared.token + +enum class TokenValidationResult() { + VALID, INVALID, EXPIRED +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt index f86d2be..902ba93 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt @@ -3,13 +3,14 @@ package de.grimsi.gameyfin.users import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.core.Roles -import de.grimsi.gameyfin.users.dto.PasswordResetResult +import de.grimsi.gameyfin.shared.token.TokenValidationResult import jakarta.annotation.security.RolesAllowed @Endpoint @AnonymousAllowed class PasswordResetEndpoint( - private val passwordResetService: PasswordResetService + private val passwordResetService: PasswordResetService, + private val userService: UserService ) { fun requestPasswordReset(email: String) { @@ -20,10 +21,10 @@ class PasswordResetEndpoint( @RolesAllowed(Roles.Names.ADMIN) fun createPasswordResetTokenForUser(username: String): String { - return passwordResetService.createPasswordResetToken(username) + return passwordResetService.generate(username).secret } - fun resetPassword(token: String, newPassword: String): PasswordResetResult { - return passwordResetService.resetPassword(token, newPassword) + fun resetPassword(secret: String, newPassword: String): TokenValidationResult { + return passwordResetService.resetPassword(secret, newPassword) } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt index 01d5e6a..b981ae9 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt @@ -2,43 +2,63 @@ package de.grimsi.gameyfin.users import de.grimsi.gameyfin.core.Utils import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent -import de.grimsi.gameyfin.notifications.NotificationService -import de.grimsi.gameyfin.users.dto.PasswordResetResult -import de.grimsi.gameyfin.users.entities.PasswordResetToken +import de.grimsi.gameyfin.messages.MessageService +import de.grimsi.gameyfin.shared.token.Token +import de.grimsi.gameyfin.shared.token.TokenRepository +import de.grimsi.gameyfin.shared.token.TokenService +import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset +import de.grimsi.gameyfin.shared.token.TokenValidationResult import de.grimsi.gameyfin.users.entities.User -import de.grimsi.gameyfin.users.persistence.PasswordResetTokenRepository import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import java.security.SecureRandom -import java.time.Instant -import java.util.* -import kotlin.time.Duration.Companion.hours -import kotlin.time.toJavaDuration @Service class PasswordResetService( + tokenRepository: TokenRepository, private val userService: UserService, + private val messageService: MessageService, private val sessionService: SessionService, - private val notificationService: NotificationService, - private val eventPublisher: ApplicationEventPublisher, - private val passwordResetTokenRepository: PasswordResetTokenRepository -) { - - private companion object { - val TOKEN_EXPIRATION = 24.hours - } + private val eventPublisher: ApplicationEventPublisher +) : TokenService(PasswordReset, tokenRepository) { private val log = KotlinLogging.logger {} private val secureRandom = SecureRandom() - private val PasswordResetToken.isExpired: Boolean - get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now()) - private val baseUrl: String get() = Utils.getBaseUrl() + override fun generate(user: User): Token { + if (user.oidcProviderId != null) { + throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally") + } + + return super.generate(user) + } + + /** + * Admins should be able to create password reset tokens for users when the following conditions are met: + * - E-Mail notifications are not enabled + * - The user has no confirmed email address + * - The user is not managed externally + */ + fun generate(username: String): Token { + if (messageService.enabled) { + throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled") + } + + val user = userService.getByUsername(username) + ?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist") + + if (user.emailConfirmed == true) { + throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled") + } + + return generate(user) + } + /** * Users can request a password reset when the following conditions are met: * - The user has confirmed their email address @@ -67,66 +87,26 @@ class PasswordResetService( return } - val token = createPasswordResetToken(user) + val token = generate(user) eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl)) // Simulate a delay to prevent timing attacks Thread.sleep(secureRandom.nextLong(1024)) } - fun createPasswordResetToken(user: User): PasswordResetToken { - if (user.oidcProviderId != null) { - throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally") - } + fun resetPassword(token: String, newPassword: String): TokenValidationResult { + val passwordResetToken = get(token, PasswordReset) + ?: return TokenValidationResult.INVALID - val token = PasswordResetToken( - user = user, - token = UUID.randomUUID().toString() - ) - - passwordResetTokenRepository.findByUser(user)?.let { - passwordResetTokenRepository.delete(it) - } - - return passwordResetTokenRepository.save(token) - } - - /** - * Admins should be able to create password reset tokens for users when the following conditions are met: - * - E-Mail notifications are not enabled - * - The user has no confirmed email address - * - The user is not managed externally - */ - fun createPasswordResetToken(username: String): String { - if (notificationService.enabled) { - throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled") - } - - val user = userService.getByUsername(username) - ?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist") - - if (user.emailConfirmed == true) { - throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled") - } - - return createPasswordResetToken(user).token - } - - - fun resetPassword(token: String, newPassword: String): PasswordResetResult { - val passwordResetToken = - passwordResetTokenRepository.findByToken(token) - ?: return PasswordResetResult.INVALID_TOKEN - - if (passwordResetToken.isExpired) { - return PasswordResetResult.EXPIRED_TOKEN + if (passwordResetToken.expired) { + return TokenValidationResult.EXPIRED } val user = passwordResetToken.user userService.updatePassword(user, newPassword) - passwordResetTokenRepository.delete(passwordResetToken) + delete(passwordResetToken) sessionService.logoutAllSessions(user) - return PasswordResetResult.SUCCESS + return TokenValidationResult.VALID } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index 33bf822..041130b 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -8,6 +8,7 @@ import de.grimsi.gameyfin.users.entities.Role import de.grimsi.gameyfin.users.entities.User import de.grimsi.gameyfin.users.persistence.AvatarContentStore import de.grimsi.gameyfin.users.persistence.UserRepository +import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.transaction.Transactional import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority @@ -26,12 +27,14 @@ import java.io.InputStream @Transactional class UserService( private val userRepository: UserRepository, + private val avatarStore: AvatarContentStore, private val passwordEncoder: PasswordEncoder, private val roleService: RoleService, - private val sessionService: SessionService, - private val avatarStore: AvatarContentStore + private val sessionService: SessionService ) : UserDetailsService { + private val log = KotlinLogging.logger {} + override fun loadUserByUsername(username: String): UserDetails { val user = userByUsername(username) @@ -125,15 +128,18 @@ class UserService( val user = userByUsername(username) updates.username?.let { user.username = it } - updates.password?.let { user.password = passwordEncoder.encode(it) } - updates.email?.let { user.email = it } - userRepository.save(user) - - // If user changes password, all sessions should be invalidated - if (updates.password != null) { + updates.password?.let { + user.password = passwordEncoder.encode(it) sessionService.logoutAllSessions() } + + updates.email?.let { + user.email = it + user.emailConfirmed = false + } + + userRepository.save(user) } fun updatePassword(user: User, newPassword: String) { diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/PasswordResetResult.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/PasswordResetResult.kt deleted file mode 100644 index c13096a..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/users/dto/PasswordResetResult.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.grimsi.gameyfin.users.dto - -enum class PasswordResetResult() { - SUCCESS, INVALID_TOKEN, EXPIRED_TOKEN -} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/entities/PasswordResetToken.kt b/src/main/kotlin/de/grimsi/gameyfin/users/entities/PasswordResetToken.kt deleted file mode 100644 index 1180a37..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/users/entities/PasswordResetToken.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.grimsi.gameyfin.users.entities - -import de.grimsi.gameyfin.core.security.EncryptionConverter -import jakarta.persistence.* -import org.hibernate.annotations.CreationTimestamp -import java.time.Instant - -@Entity -class PasswordResetToken( - @Id - @Convert(converter = EncryptionConverter::class) - val token: String, - - @OneToOne(targetEntity = User::class, fetch = FetchType.EAGER) - val user: User, - - @CreationTimestamp - val createdOn: Instant? = null -) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/AvatarContentStore.kt b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/AvatarContentStore.kt index 0d45092..f8c1e3c 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/AvatarContentStore.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/AvatarContentStore.kt @@ -2,5 +2,7 @@ package de.grimsi.gameyfin.users.persistence import de.grimsi.gameyfin.users.entities.Avatar import org.springframework.content.commons.store.ContentStore +import org.springframework.stereotype.Repository +@Repository interface AvatarContentStore : ContentStore \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/PasswordResetTokenRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/PasswordResetTokenRepository.kt deleted file mode 100644 index 6d7fbb7..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/PasswordResetTokenRepository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.grimsi.gameyfin.users.persistence - -import de.grimsi.gameyfin.users.entities.PasswordResetToken -import de.grimsi.gameyfin.users.entities.User -import org.springframework.data.jpa.repository.JpaRepository - -interface PasswordResetTokenRepository : JpaRepository { - fun findByToken(token: String): PasswordResetToken? - fun findByUser(user: User): PasswordResetToken? -} \ No newline at end of file diff --git a/src/main/resources/templates/messages/email-confirmation.mjml b/src/main/resources/templates/messages/email-confirmation.mjml new file mode 100644 index 0000000..01aabb8 --- /dev/null +++ b/src/main/resources/templates/messages/email-confirmation.mjml @@ -0,0 +1,25 @@ + + + Please confirm your email address + + + + + + + + + + + + Hey {username}, +
+
+
+ please confirm your email address using the following link: {confirmationLink} + + +
+
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/notifications/password-reset-request.mjml b/src/main/resources/templates/messages/password-reset-request.mjml similarity index 100% rename from src/main/resources/templates/notifications/password-reset-request.mjml rename to src/main/resources/templates/messages/password-reset-request.mjml diff --git a/src/main/resources/templates/notifications/user-invitation.mjml b/src/main/resources/templates/messages/user-invitation.mjml similarity index 100% rename from src/main/resources/templates/notifications/user-invitation.mjml rename to src/main/resources/templates/messages/user-invitation.mjml diff --git a/src/main/resources/templates/notifications/welcome.mjml b/src/main/resources/templates/messages/welcome.mjml similarity index 100% rename from src/main/resources/templates/notifications/welcome.mjml rename to src/main/resources/templates/messages/welcome.mjml