WIP: Notifications (via email, more to come?)

This commit is contained in:
grimsi
2024-09-18 23:35:19 +02:00
parent a8b12528a4
commit f5962e3cfd
11 changed files with 157 additions and 14 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080"> <configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>
+3
View File
@@ -60,6 +60,9 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.security:spring-security-oauth2-jose") implementation("org.springframework.security:spring-security-oauth2-jose")
// Notifications
implementation("org.springframework.boot:spring-boot-starter-mail")
// Development // Development
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
@@ -21,7 +21,8 @@ export default function ConfigFormField({configElement, ...props}: any) {
); );
case "String": case "String":
return ( return (
<Input label={configElement.description} name={configElement.key} type="text" {...props}/> <Input label={configElement.description} name={configElement.key}
type={props.type && "text"} {...props}/>
); );
case "Float": case "Float":
return ( return (
@@ -0,0 +1,75 @@
import React 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 {toast} from "sonner";
function NotificationManagementLayout({getConfig, 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 areCredentialsValid = await NotificationEndpoint.verifyEmailCredentials(credentials);
if (areCredentialsValid) {
toast.success("Credentials are valid")
} else {
toast.error("Credentials are invalid")
}
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<ConfigFormField configElement={getConfig("notifications.enabled")}/>
<div className="flex flex-row gap-8">
<div className="flex flex-col flex-1">
<Section title="E-Mail"/>
<ConfigFormField configElement={getConfig("notifications.email.host")}
isDisabled={!formik.values.notifications.enabled}/>
<ConfigFormField configElement={getConfig("notifications.email.port")}
isDisabled={!formik.values.notifications.enabled}/>
<ConfigFormField configElement={getConfig("notifications.email.username")}
isDisabled={!formik.values.notifications.enabled}/>
<ConfigFormField configElement={getConfig("notifications.email.password")}
type="password"
isDisabled={!formik.values.notifications.enabled}/>
<Button onPress={testMailSettings}
isDisabled={!(
formik.values.notifications.enabled &&
formik.values.notifications.email.host &&
formik.values.notifications.email.port &&
formik.values.notifications.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/>
</div>
</div>
</div>
</div>
</div>
);
}
const validationSchema = Yup.object({});
export const NotificationManagement = withConfigPage(NotificationManagementLayout, "Notifications", "notifications", validationSchema);
@@ -58,6 +58,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<ConfigFormField configElement={getConfig("sso.oidc.client-id")} <ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")} <ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
type="password"
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<ConfigFormField configElement={getConfig("sso.oidc.issuer-url")} <ConfigFormField configElement={getConfig("sso.oidc.issuer-url")}
@@ -65,7 +66,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<Button <Button
isDisabled={isAutoPopulateDisabled()} isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate} onPress={autoPopulate}
className="h-14 mt-2"><MagicWand/> Auto-populate</Button> className="h-14 mt-2"><MagicWand className="min-w-5"/> Auto-populate</Button>
</div> </div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")} <ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
@@ -9,7 +9,7 @@ const Input = ({label, ...props}) => {
const [field, meta] = useField(props); const [field, meta] = useField(props);
return ( return (
<div className="flex flex-col flex-grow items-start gap-2 my-2"> <div className="flex flex-col w-full items-start gap-2 my-2">
<NextUiInput <NextUiInput
{...props} {...props}
{...field} {...field}
@@ -17,7 +17,7 @@ const Input = ({label, ...props}) => {
label={label} label={label}
isInvalid={meta.touched && !!meta.error} isInvalid={meta.touched && !!meta.error}
/> />
<div className="min-h-6 w-full text-danger"> <div className="min-h-6 text-danger">
{meta.touched && meta.error && ( {meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/> <SmallInfoField icon={XCircle} message={meta.error}/>
)} )}
+2
View File
@@ -12,6 +12,7 @@ import ProfileManagement from "Frontend/components/administration/ProfileManagem
import {SsoManagement} from "Frontend/components/administration/SsoManagement"; import {SsoManagement} from "Frontend/components/administration/SsoManagement";
import {AdministrationView} from "Frontend/views/AdministrationView"; import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView"; import {ProfileView} from "Frontend/views/ProfileView";
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
export const routes = protectRoutes([ export const routes = protectRoutes([
{ {
@@ -40,6 +41,7 @@ export const routes = protectRoutes([
{path: 'libraries', element: <LibraryManagement/>}, {path: 'libraries', element: <LibraryManagement/>},
{path: 'users', element: <UserManagement/>}, {path: 'users', element: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>}, {path: 'sso', element: <SsoManagement/>},
{path: 'notifications', element: <NotificationManagement/>}
] ]
} }
] ]
@@ -127,17 +127,37 @@ sealed class ConfigProperties<T : Serializable>(
) )
/** Notifications */ /** Notifications */
data object NotificationsEmailHost : data object NotificationsEnabled : ConfigProperties<Boolean>(
ConfigProperties<String>(String::class, "notifications.email.host", "URL of the email server") Boolean::class,
"notifications.enabled",
"Enable notifications",
false
)
data object NotificationsEmailPort : data object NotificationsEmailHost : ConfigProperties<String>(
ConfigProperties<String>(String::class, "notifications.email.port", "Port of the email server") String::class,
"notifications.email.host",
"URL of the email server"
)
data object NotificationsEmailUsername : data object NotificationsEmailPort : ConfigProperties<Int>(
ConfigProperties<String>(String::class, "notifications.email.username", "Username for the email account") Int::class,
"notifications.email.port",
"Port of the email server",
587
)
data object NotificationsEmailPassword : data object NotificationsEmailUsername : ConfigProperties<String>(
ConfigProperties<String>(String::class, "notifications.email.password", "Password for the email account") String::class,
"notifications.email.username",
"Username for the email account"
)
data object NotificationsEmailPassword : ConfigProperties<String>(
String::class,
"notifications.email.password",
"Password for the email account"
)
} }
enum class MatchUsersBy { enum class MatchUsersBy {
@@ -119,7 +119,7 @@ class ConfigService(
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property * @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/ */
fun <T : Serializable> set(key: String, value: T) { fun <T : Serializable> set(key: String, value: T) {
log.info { "Set config value '$key' to '$value'" } log.info { "Set config value '$key'" }
val configKey = findConfigProperty(key) val configKey = findConfigProperty(key)
@@ -0,0 +1,33 @@
package de.grimsi.gameyfin.notifications
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.meta.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 {
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, null)
try {
val transport = session.getTransport("smtp")
transport.connect(credentials.host, credentials.port, credentials.username, credentials.password)
transport.close()
return true
} catch (ex: MessagingException) {
return false
}
}
}
@@ -0,0 +1,8 @@
package de.grimsi.gameyfin.notifications.dto
data class EmailCredentialsDto(
val host: String,
val port: Int,
val username: String,
val password: String?
)