add: new notifiers and ui fixes

This commit is contained in:
Théo LAGACHE
2026-01-07 13:33:23 +01:00
parent 2a1acbfde8
commit df9390847f
30 changed files with 7175 additions and 228 deletions
@@ -7,11 +7,11 @@ export const NotificationChannelFormSchema = z.object({
.min(5, "Name must be at least 5 characters long")
.max(40, "Name must be at most 40 characters long"),
provider: z.enum(["slack", "smtp"], {
provider: z.enum(["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], {
required_error: "Provider is required",
}),
config: z.record(z.string()).optional(),
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
enabled: z.boolean().default(true),
@@ -8,7 +8,6 @@ import {Input} from "@/components/ui/input";
import {
NotificationChannelFormSchema, NotificationChannelFormType
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
@@ -20,12 +19,31 @@ import {
import {
NotifierSlackForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form";
import {
NotifierDiscordForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-discord.form";
import {
NotifierTelegramForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-telegram.form";
import {
NotifierGotifyForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-gotify.form";
import {
NotifierNtfyForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-ntfy.form";
import {
NotifierWebhookForm
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-webhook.form";
import {Button} from "@/components/ui/button";
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
NotifierTestChannelButton
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button";
import {useEffect} from "react";
import {notificationTypes} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {cn} from "@/lib/utils";
import {Card} from "@/components/ui/card";
import {ArrowLeft} from "lucide-react";
type NotifierFormProps = {
onSuccessAction?: () => void;
@@ -34,7 +52,7 @@ type NotifierFormProps = {
adminView?: boolean
};
export const NotifierForm = ({onSuccessAction, organization, defaultValues, adminView}: NotifierFormProps) => {
export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => {
const router = useRouter();
const isCreate = !Boolean(defaultValues);
@@ -75,10 +93,36 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
});
const provider = form.watch("provider");
const selectedProviderDetails = notificationTypes.find(t => t.value === provider);
if (isCreate && !provider) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
{notificationTypes.map((type) => {
const Icon = type.icon;
return (
<Card
key={type.value}
className={cn(
"flex flex-col items-center justify-center gap-3 p-4 cursor-pointer hover:bg-accent/50 hover:border-primary/50 transition-all",
)}
onClick={() => {
form.setValue("provider", type.value as any);
form.setValue("config", {});
}}
>
<div className="h-10 w-10 bg-secondary rounded-full flex items-center justify-center">
<Icon className="h-6 w-6 text-foreground"/>
</div>
<span className="font-medium text-sm">{type.label}</span>
</Card>
);
})}
</div>
);
}
return (
<Form
form={form}
className="flex flex-col gap-4"
@@ -86,6 +130,32 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
await mutationAddNotificationChannel.mutateAsync(values);
}}
>
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
{selectedProviderDetails && (
<div className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
<selectedProviderDetails.icon className="h-5 w-5"/>
</div>
)}
<div className="flex-1">
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
</div>
{isCreate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
form.setValue("provider", undefined as any);
form.setValue("config", undefined);
}}
>
<ArrowLeft className="h-4 w-4 mr-2"/>
Change
</Button>
)}
</div>
<FormField
control={form.control}
name="name"
@@ -93,7 +163,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
<FormItem>
<FormLabel>Channel Name</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g., Primary email, Team Slack" value={field.value ?? ""}/>
<Input {...field} placeholder={`My ${selectedProviderDetails?.label} Channel`} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
@@ -104,28 +174,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
control={form.control}
name="provider"
render={({field}) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
form.setValue("config", {});
field.onChange(value);
}}
value={field.value || ""}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="smtp">SMTP (Email)</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
{/*<SelectItem value="curl">Curl</SelectItem>*/}
{/*<SelectItem value="webhook">Webhook</SelectItem>*/}
</SelectContent>
</Select>
</FormControl>
<FormMessage/>
</FormItem>
<input type="hidden" {...field} value={field.value || ""} />
)}
/>
@@ -137,8 +186,28 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
<NotifierSlackForm form={form}/>
)}
{provider === "discord" && (
<NotifierDiscordForm form={form}/>
)}
<div className="flex justify-between">
{provider === "telegram" && (
<NotifierTelegramForm form={form}/>
)}
{provider === "gotify" && (
<NotifierGotifyForm form={form}/>
)}
{provider === "ntfy" && (
<NotifierNtfyForm form={form}/>
)}
{provider === "webhook" && (
<NotifierWebhookForm form={form}/>
)}
<div className="flex justify-between mt-4">
<div>
{defaultValues && (
<NotifierTestChannelButton organizationId={organization?.id}
@@ -164,4 +233,4 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues, admi
</div>
</Form>
);
};
};
@@ -0,0 +1,30 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierDiscordFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierDiscordForm = ({form}: NotifierDiscordFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.discordWebhook"
render={({field}) => (
<FormItem>
<FormLabel>Discord Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,43 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierGotifyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierGotifyForm = ({form}: NotifierGotifyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.gotifyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Gotify Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.gotifyAppToken"
render={({field}) => (
<FormItem>
<FormLabel>Application Token</FormLabel>
<FormControl>
<Input {...field} placeholder="A1b2C3d4E5f6G7h"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,58 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierNtfyFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.ntfyTopic"
render={({field}) => (
<FormItem>
<FormLabel>Topic Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-secret-topic"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyServerUrl"
render={({field}) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.sh (default)"/>
</FormControl>
<p className="text-xs text-muted-foreground">Leave empty to use the official ntfy.sh server.</p>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.ntfyToken"
render={({field}) => (
<FormItem>
<FormLabel>Access Token (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="tk_..."/>
</FormControl>
<p className="text-xs text-muted-foreground">Only required for protected topics or self-hosted instances with auth.</p>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,48 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierTelegramFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.telegramBotToken"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Bot Token</FormLabel>
<FormControl>
<Input {...field} placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.telegramChatId"
render={({field}) => (
<FormItem>
<FormLabel>Telegram Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-100123456789 or 123456789"/>
</FormControl>
<p className="text-xs text-muted-foreground">
You must start the conversation with the bot first (<strong>/start</strong>). <br/>
For groups, add the bot to the group. <br/>
You can use <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer" className="underline">@userinfobot</a> to find your ID.
</p>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
@@ -0,0 +1,65 @@
import {UseFormReturn} from "react-hook-form";
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>
}
export const NotifierWebhookForm = ({form}: NotifierWebhookFormProps) => {
return (
<>
<Separator className="my-1"/>
<FormField
control={form.control}
name="config.webhookUrl"
render={({field}) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/api/webhook"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecretHeader"
render={({field}) => (
<FormItem>
<FormLabel>Header Name (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="X-Webhook-Secret"/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name="config.webhookSecret"
render={({field}) => (
<FormItem>
<FormLabel>Secret Value (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Secret value..."/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
If provided, the secret will be sent in the specified header (defaults to <code>X-Webhook-Secret</code>).
</p>
</>
)
}