Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
.idea
.vscode
.VSCodeCounter
*.sh
mongo
timescaledb
node_modules/
docs/architecture
docs/reviews
docs/todo
docs/frontend
.idea
.vscode
.VSCodeCounter
*.sh
mongo
node_modules/
docs/architecture
docs/reviews
docs/todo
docs/frontend
docs/timescale
11 changes: 11 additions & 0 deletions client/src/Hooks/useNotificationForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ function buildDefaults(data: Notification | null): NotificationFormData {
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
authType: data.authType || "none",
username: data.username || "",
password: data.password || "",
accessToken: data.accessToken || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
Expand Down
143 changes: 135 additions & 8 deletions client/src/Pages/Notifications/create/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { useNotificationForm } from "@/Hooks/useNotificationForm";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
import { type Notification, NotificationChannels, AuthTypes } from "@/Types/Notification";
import { useTranslation } from "react-i18next";
import { NotificationChannels } from "@/Types/Notification";
import { dropStaleAuth } from "@/Utils/NotificationUtils";

const NotificationsCreatePage = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -46,6 +46,7 @@ const NotificationsCreatePage = () => {
}, [defaults, reset]);

const watchedType = watch("type");
const watchedAuthType = watch("authType");

useEffect(() => {
clearErrors();
Expand Down Expand Up @@ -77,9 +78,10 @@ const NotificationsCreatePage = () => {
}, [watchedType, t]);

const onSubmit = async (data: NotificationFormData) => {
const payload = dropStaleAuth(data);
const result = isEditMode
? await patch(`/notifications/${notificationId}`, data)
: await post("/notifications", data);
? await patch(`/notifications/${notificationId}`, payload)
: await post("/notifications", payload);
if (result) {
navigate("/notifications");
}
Expand Down Expand Up @@ -149,7 +151,8 @@ const NotificationsCreatePage = () => {
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" && (
watchedType !== "pushover" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
Expand Down Expand Up @@ -308,10 +311,10 @@ const NotificationsCreatePage = () => {
<TextField
{...field}
type="text"
fieldLabel={t(
"pages.notifications.form.accessToken.optionAccessToken"
fieldLabel={t("pages.notifications.form.auth.optionAccessToken")}
placeholder={t(
"pages.notifications.form.auth.placeholderAccessToken"
)}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
Expand All @@ -322,6 +325,130 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionNtfyAddress")}
placeholder={t(
"pages.notifications.form.ntfy.placeholderNtfyAddress"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.auth.title")}
subtitle={t("pages.notifications.form.auth.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="authType"
control={control}
defaultValue={"authType" in defaults ? defaults.authType : "none"}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t("pages.notifications.form.auth.optionAuthType")}
error={!!fieldState.error}
onChange={field.onChange}
>
{AuthTypes.map((type: string) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform="capitalize">{type}</Typography>
</MenuItem>
))}
</Select>
)}
/>
{watchedAuthType === "basic" && (
<>
<Controller
name="username"
control={control}
defaultValue={"username" in defaults ? defaults.username : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.auth.optionUsername")}
placeholder={t(
"pages.notifications.form.auth.placeholderUsername"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
shouldUnregister={true}
/>
<Controller
name="password"
control={control}
defaultValue={"password" in defaults ? defaults.password : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.notifications.form.auth.optionPassword")}
placeholder={t(
"pages.notifications.form.auth.placeholderPassword"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
shouldUnregister={true}
/>
</>
)}
{watchedAuthType === "bearer" && (
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.auth.optionAccessToken")}
placeholder={t(
"pages.notifications.form.auth.placeholderAccessToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
shouldUnregister={true}
/>
)}
</Stack>
}
/>
)}

<Stack
direction="row"
justifyContent="flex-end"
Expand Down
7 changes: 7 additions & 0 deletions client/src/Types/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"ntfy",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];

export const AuthTypes = ["none", "basic", "bearer"] as const;
export type AuthType = (typeof AuthTypes)[number];

export interface Notification {
id: string;
userId: string;
Expand All @@ -21,6 +25,9 @@ export interface Notification {
phone?: string;
homeserverUrl?: string;
roomId?: string;
authType?: AuthType;
username?: string;
password?: string;
accessToken?: string;
createdAt: string;
updatedAt: string;
Expand Down
18 changes: 18 additions & 0 deletions client/src/Utils/NotificationUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { NotificationFormData } from "@/Validation/notifications";

export const dropStaleAuth = (data: NotificationFormData): NotificationFormData => {
// Providers That Support Basic/Bearer Auth: Drop Stale Data
if (data.type !== "ntfy") return data;
const authType = data.authType ?? "none";
const base = { ...data, authType };
switch (authType) {
case "none":
return { ...base, username: "", password: "", accessToken: "" };
case "basic":
return { ...base, accessToken: "" };
case "bearer":
return { ...base, username: "", password: "" };
default:
return base;
}
};
43 changes: 42 additions & 1 deletion client/src/Validation/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AuthTypes } from "@/Types/Notification";
import { z } from "zod";

const baseSchema = z.object({
Expand Down Expand Up @@ -62,7 +63,16 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});

export const notificationSchema = z.discriminatedUnion("type", [
const ntfySchema = baseSchema.extend({
type: z.literal("ntfy"),
address: z.string().min(1, "URL is required").url("Please enter a valid URL"),
authType: z.enum(AuthTypes).optional(),
username: z.string().optional(),
password: z.string().optional(),
accessToken: z.string().optional(),
});

export const baseNotificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
discordSchema,
Expand All @@ -72,6 +82,37 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
ntfySchema,
]);

export const notificationSchema = baseNotificationSchema.superRefine((data, ctx) => {
if (data.type === "ntfy") {
if (data.authType === "basic") {
if (!data.username) {
ctx.addIssue({
code: "custom",
message: "Username is required",
path: ["username"],
});
}
if (!data.password) {
ctx.addIssue({
code: "custom",
message: "Password is required",
path: ["password"],
});
}
}
if (data.authType === "bearer") {
if (!data.accessToken) {
ctx.addIssue({
code: "custom",
message: "Token is required",
path: ["accessToken"],
});
}
}
}
});

export type NotificationFormData = z.infer<typeof notificationSchema>;
19 changes: 16 additions & 3 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -913,9 +913,16 @@
"title": "Notification channles are used to:"
},
"form": {
"accessToken": {
"optionAccessToken": "Access token",
"placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123"
"auth": {
"title": "Authentication",
"description": "Configure authentication for your notification channel.",
"optionAuthType": "Authentication Type",
"optionUsername": "Username",
"placeholderUsername": "Enter Username",
"optionPassword": "Password",
"placeholderPassword": "Enter Password",
"optionAccessToken": "Access Token",
"placeholderAccessToken": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123"
},
"address": {
"description": "The address where notifications will be sent.",
Expand Down Expand Up @@ -968,6 +975,12 @@
"placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi",
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"ntfy": {
"title": "Ntfy configuration",
"description": "Configure your Ntfy URL for notifications.",
"optionNtfyAddress": "Ntfy URL",
"placeholderNtfyAddress": "https://ntfy.sh/your-topic"
}
},
"table": {
Expand Down
3 changes: 3 additions & 0 deletions server/src/config/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
TeamsProvider,
TelegramProvider,
PushoverProvider,
NtfyProvider,
// Interfaces
INetworkService,
IEmailService,
Expand Down Expand Up @@ -300,6 +301,7 @@ export const initializeServices = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const ntfyProvider = new NtfyProvider(logger);

const notificationsService = new NotificationsService(
notificationsRepository,
Expand All @@ -313,6 +315,7 @@ export const initializeServices = async ({
teamsProvider,
telegramProvider,
pushoverProvider,
ntfyProvider,
settingsService,
logger,
notificationMessageBuilder
Expand Down
Loading