Implement notification templates

Refactor notification providers to be more expandable in the future
Minor layout changes
This commit is contained in:
grimsi
2024-09-21 19:24:43 +02:00
parent 69a32fb4f4
commit a993b8a488
15 changed files with 276 additions and 72 deletions
+24
View File
@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Production build" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="-P hilla.productionMode=true" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="build" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
@@ -1,24 +1,38 @@
import React from "react";
import React, {useState} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {Button, Input, Select, SelectItem} from "@nextui-org/react";
import {NotificationEndpoint} from "Frontend/generated/endpoints";
import EmailCredentialsDto from "Frontend/generated/de/grimsi/gameyfin/notifications/dto/EmailCredentialsDto";
import {
Button,
Card,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
useDisclosure
} from "@nextui-org/react";
import {ConfigEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {Pencil} from "@phosphor-icons/react";
function NotificationManagementLayout({getConfig, formik}: any) {
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
async function testMailSettings() {
const credentials: EmailCredentialsDto = {
host: formik.values.notifications.email.host,
port: formik.values.notifications.email.port,
username: formik.values.notifications.email.username,
password: formik.values.notifications.email.password
const {isOpen, onOpen, onOpenChange} = useDisclosure();
const [selectedTemplate, setSelectedTemplate] = useState<ConfigEntryDto | null>(null);
async function verifyCredentials(provider: string) {
const credentials: Record<string, any> = {
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
}
const areCredentialsValid = await NotificationEndpoint.verifyEmailCredentials(credentials);
const areCredentialsValid = await NotificationEndpoint.verifyCredentials(provider, credentials);
if (areCredentialsValid) {
toast.success("Credentials are valid")
@@ -27,6 +41,19 @@ function NotificationManagementLayout({getConfig, formik}: any) {
}
}
async function openModal(template: ConfigEntryDto) {
let templateContent = await ConfigEndpoint.get(template.key);
setSelectedTemplate({
...template,
value: templateContent
});
onOpen();
}
async function saveTemplate(template: ConfigEntryDto) {
await ConfigEndpoint.set(template.key, template.value);
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
@@ -36,36 +63,83 @@ function NotificationManagementLayout({getConfig, formik}: any) {
<div className="flex flex-row gap-8">
<div className="flex flex-col flex-1">
<Section title="E-Mail"/>
<ConfigFormField configElement={getConfig("notifications.email.host")}
<ConfigFormField configElement={getConfig("notifications.providers.email.host")}
isDisabled={!formik.values.notifications.enabled}/>
<ConfigFormField configElement={getConfig("notifications.email.port")}
<ConfigFormField configElement={getConfig("notifications.providers.email.port")}
isDisabled={!formik.values.notifications.enabled}/>
<ConfigFormField configElement={getConfig("notifications.email.username")}
<ConfigFormField configElement={getConfig("notifications.providers.email.username")}
isDisabled={!formik.values.notifications.enabled}/>
<ConfigFormField configElement={getConfig("notifications.email.password")}
<ConfigFormField configElement={getConfig("notifications.providers.email.password")}
type="password"
isDisabled={!formik.values.notifications.enabled}/>
<Button onPress={testMailSettings}
<Button onPress={() => verifyCredentials("email")}
isDisabled={!(
formik.values.notifications.enabled &&
formik.values.notifications.email.host &&
formik.values.notifications.email.port &&
formik.values.notifications.email.username)}>Test</Button>
formik.values.notifications.providers.email.host &&
formik.values.notifications.providers.email.port &&
formik.values.notifications.providers.email.username)}>Test</Button>
</div>
<div className="flex flex-col flex-1">
<Section title="Push"/>
{/* TODO: Evaluate need and options if need is given */}
<Select className="mt-2 mb-10"
label="Push notification provider" defaultSelectedKeys={["pushbullet"]}
isDisabled>
<SelectItem key="pushbullet">Pushbullet</SelectItem>
</Select>
<Input className="mt-2 mb-10" label="Access Token" type="password" isDisabled/>
<Input className="mt-2 mb-10" label="Channel tag" type="text" isDisabled/>
<Section title="Message Templates"/>
<div className="flex flex-col gap-4">
{getConfigs("notifications.templates").map((template: ConfigEntryDto) =>
<Card className="flex flex-row items-center gap-2 p-4">
<Button isIconOnly
size="sm"
onPress={() => openModal(template)}
>
<Pencil/>
</Button>
<p className="text-lg">{template.description}</p>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader
className="flex flex-col gap-1">Edit {selectedTemplate?.description.toLowerCase()}</ModalHeader>
<ModalBody>
<Textarea
size="lg"
autoFocus
disableAutosize
value={selectedTemplate?.value}
onChange={(e) => {
if (selectedTemplate?.key) setSelectedTemplate({
...selectedTemplate,
value: e.target.value
})
}}
classNames={{
input: "resize-y min-h-[500px]"
}}
/>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate);
toast.success("Template saved")
onClose();
}
}}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
}
@@ -66,7 +66,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<Button
isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate}
className="h-14 mt-2"><MagicWand className="min-w-5"/> Auto-populate</Button>
className="h-14"><MagicWand className="min-w-5"/> Auto-populate</Button>
</div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
@@ -44,6 +44,10 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
return configDtos.find((configDto: ConfigEntryDto) => configDto.key === key);
}
function getConfigs(prefix: string) {
return configDtos.filter((configDto: ConfigEntryDto) => configDto.key?.startsWith(prefix));
}
function toNestedConfig(configArray: ConfigEntryDto[]): NestedConfig {
const nestedConfig: NestedConfig = {};
@@ -143,7 +147,10 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
</div>
</div>
<WrappedComponent {...props} getConfig={getConfig} formik={formik}
<WrappedComponent {...props}
getConfig={getConfig}
getConfigs={getConfigs}
formik={formik}
setSaveMessage={setSaveMessage}/>
</Form>
)}
@@ -7,7 +7,7 @@ const CheckboxInput = ({label, ...props}) => {
const [field] = useField(props);
return (
<div className="flex flex-row flex-1 items-center gap-2 my-2">
<div className="flex flex-row flex-1 items-center gap-2">
<Checkbox
{...field}
{...props}
@@ -9,7 +9,7 @@ const Input = ({label, ...props}) => {
const [field, meta] = useField(props);
return (
<div className="flex flex-col flex-1 items-start gap-2 my-2">
<div className="flex flex-col flex-1 items-start gap-2">
<NextUiInput
{...props}
{...field}
@@ -7,7 +7,7 @@ const SelectInput = ({label, values, ...props}) => {
const [field] = useField(props);
return (
<div className="flex flex-row flex-1 justify-center gap-2 my-2">
<div className="flex flex-row flex-1 justify-center gap-2">
<Select
{...field}
{...props}
@@ -137,29 +137,59 @@ sealed class ConfigProperties<T : Serializable>(
data object NotificationsEmailHost : ConfigProperties<String>(
String::class,
"notifications.email.host",
"notifications.providers.email.host",
"URL of the email server"
)
data object NotificationsEmailPort : ConfigProperties<Int>(
Int::class,
"notifications.email.port",
"notifications.providers.email.port",
"Port of the email server",
587
)
data object NotificationsEmailUsername : ConfigProperties<String>(
String::class,
"notifications.email.username",
"notifications.providers.email.username",
"Username for the email account"
)
data object NotificationsEmailPassword : ConfigProperties<String>(
String::class,
"notifications.email.password",
"notifications.providers.email.password",
"Password for the email account"
)
data object NotificationsTemplateNewUser : ConfigProperties<String>(
String::class,
"notifications.templates.new-user",
"Template for new user notifications"
)
data object NotificationsTemplateNewInvite : ConfigProperties<String>(
String::class,
"notifications.templates.new-invite",
"Template for new user notifications"
)
data object NotificationsTemplateNewPasswordRequest : ConfigProperties<String>(
String::class,
"notifications.templates.new-password-request",
"Template for new password request notifications"
)
data object NotificationsTemplateNewGame : ConfigProperties<String>(
String::class,
"notifications.templates.new-game",
"Template for new game notifications"
)
data object NotificationsTemplateNewGameRequest : ConfigProperties<String>(
String::class,
"notifications.templates.new-game-request",
"Template for new game request notifications"
)
/** Logs */
data object LogsFolder : ConfigProperties<String>(
String::class,
@@ -1,9 +1,6 @@
package de.grimsi.gameyfin.config.entities
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@Entity
@@ -15,6 +12,7 @@ class ConfigEntry(
val key: String,
@NotNull
@Lob
@Column(name = "`value`")
var value: String
)
@@ -2,32 +2,15 @@ package de.grimsi.gameyfin.notifications
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.notifications.dto.EmailCredentialsDto
import jakarta.annotation.security.RolesAllowed
import jakarta.mail.MessagingException
import jakarta.mail.Session
import java.util.*
@Endpoint
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
class NotificationEndpoint {
class NotificationEndpoint(
private val notificationService: NotificationService
) {
fun verifyEmailCredentials(credentials: EmailCredentialsDto): Boolean {
val properties = Properties()
properties["mail.smtp.auth"] = true
properties["mail.smtp.starttls.enable"] = true
properties["mail.smtp.host"] = credentials.host
properties["mail.smtp.port"] = credentials.port
val session = Session.getInstance(properties)
try {
val transport = session.getTransport("smtp")
transport.connect(credentials.host, credentials.port, credentials.username, credentials.password)
transport.close()
return true
} catch (_: MessagingException) {
return false
}
fun verifyCredentials(provider: String, credentials: Map<String, Any>): Boolean {
return notificationService.testCredentials(provider, credentials)
}
}
@@ -0,0 +1,22 @@
package de.grimsi.gameyfin.notifications
import de.grimsi.gameyfin.notifications.providers.AbstractNotificationProvider
import org.springframework.context.ApplicationContext
import org.springframework.stereotype.Service
import java.util.*
@Service
class NotificationService(
private val applicationContext: ApplicationContext
) {
private val providers: List<AbstractNotificationProvider>
get() = applicationContext.getBeansOfType(AbstractNotificationProvider::class.java).values.toList()
fun testCredentials(provider: String, credentials: Map<String, Any>): Boolean {
val notificationProvider = providers.find { it.providerKey == provider }
val credentialsProperties = Properties().apply { putAll(credentials) }
return notificationProvider?.testCredentials(credentialsProperties)
?: throw IllegalArgumentException("Provider $provider not found")
}
}
@@ -1,8 +0,0 @@
package de.grimsi.gameyfin.notifications.dto
data class EmailCredentialsDto(
val host: String,
val port: Int,
val username: String,
val password: String?
)
@@ -0,0 +1,13 @@
package de.grimsi.gameyfin.notifications.events
import org.springframework.context.ApplicationEvent
class UserInvitationEvent(source: Any) : ApplicationEvent(source)
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
class PasswordResetRequestEvent(source: Any) : ApplicationEvent(source)
class GameRequestEvent(source: Any) : ApplicationEvent(source)
class GameRequestApprovalEvent(source: Any) : ApplicationEvent(source)
@@ -0,0 +1,20 @@
package de.grimsi.gameyfin.notifications.providers
import de.grimsi.gameyfin.config.ConfigService
import java.util.*
abstract class AbstractNotificationProvider(
val providerKey: String,
private val config: ConfigService
) {
private val configKey = String.format("notifications.providers.%s.enabled", providerKey)
fun isEnabled(): Boolean {
return config.get(configKey).toBoolean()
}
abstract fun testCredentials(credentials: Properties): Boolean
abstract fun sendNotification(recipient: String, title: String, message: String)
}
@@ -0,0 +1,41 @@
package de.grimsi.gameyfin.notifications.providers
import de.grimsi.gameyfin.config.ConfigService
import jakarta.mail.MessagingException
import jakarta.mail.Session
import org.springframework.stereotype.Service
import java.util.*
@Service
class EmailNotificationProvider(
configService: ConfigService
) : AbstractNotificationProvider("email", configService) {
override fun testCredentials(credentials: Properties): Boolean {
try {
val sessionProperties = Properties()
sessionProperties["mail.smtp.auth"] = true
sessionProperties["mail.smtp.starttls.enable"] = true
sessionProperties["mail.smtp.host"] = credentials["host"] as String
sessionProperties["mail.smtp.port"] = credentials["port"] as Int
val session = Session.getInstance(sessionProperties)
val transport = session.getTransport("smtp")
transport.connect(
credentials["host"] as String,
credentials["port"] as Int,
credentials["username"] as String,
credentials["password"] as String
)
transport.close()
return true
} catch (_: MessagingException) {
return false
}
}
override fun sendNotification(recipient: String, title: String, message: String) {
TODO("Not yet implemented")
}
}