mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
system: notifications: add transport testing functionality and related UI components
This commit is contained in:
@@ -290,6 +290,59 @@ func DeleteTransport(service *notifications.Service) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport(service *notifications.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "invalid_transport_id",
|
||||
Error: "invalid_transport_id",
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = service.TestTransport(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "transport_not_found",
|
||||
Error: "transport_not_found",
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if isTestTransportBadRequest(err) {
|
||||
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "failed_to_test_transport",
|
||||
Error: err.Error(),
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "failed_to_test_transport",
|
||||
Error: err.Error(),
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, internal.APIResponse[any]{
|
||||
Status: "success",
|
||||
Message: "transport_test_sent",
|
||||
Error: "",
|
||||
Data: nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(value string, fallback int) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
@@ -297,3 +350,24 @@ func parseInt(value string, fallback int) int {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isTestTransportBadRequest(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(msg, "invalid_"):
|
||||
return true
|
||||
case strings.HasPrefix(msg, "transport_name_required"):
|
||||
return true
|
||||
case strings.HasPrefix(msg, "ntfy_"):
|
||||
return true
|
||||
case strings.HasPrefix(msg, "smtp_"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/alchemillahq/sylve/internal/db/models"
|
||||
@@ -106,3 +107,136 @@ func TestNotificationsListHandlerReturnsItems(t *testing.T) {
|
||||
t.Fatalf("expected_1_item got: %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTransportHandlerSendsNtfyTransport(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := newHandlerTestService(t)
|
||||
ntfyCalls := 0
|
||||
svc.SetNtfySender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, token string) error {
|
||||
ntfyCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
view, err := svc.UpdateTransportConfig(context.Background(), notifications.TransportConfigUpdate{
|
||||
Transports: []notifications.TransportConfigEntryUpdate{
|
||||
{
|
||||
Name: "Ntfy",
|
||||
Type: notifications.TransportTypeNtfy,
|
||||
Enabled: false,
|
||||
Ntfy: ¬ifications.NtfyTransportConfigUpdate{
|
||||
BaseURL: "https://ntfy.sh",
|
||||
Topic: "alerts",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed_to_seed_ntfy_transport: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/notifications/transports/:id/test", TestTransport(svc))
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/notifications/transports/"+strconv.FormatUint(uint64(view.Transports[0].ID), 10)+"/test",
|
||||
nil,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected_200 got: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ntfyCalls != 1 {
|
||||
t.Fatalf("expected_ntfy_called_once got: %d", ntfyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTransportHandlerSendsSMTPTransport(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := newHandlerTestService(t)
|
||||
emailCalls := 0
|
||||
svc.SetEmailSender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, password string) error {
|
||||
emailCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
view, err := svc.UpdateTransportConfig(context.Background(), notifications.TransportConfigUpdate{
|
||||
Transports: []notifications.TransportConfigEntryUpdate{
|
||||
{
|
||||
Name: "SMTP",
|
||||
Type: notifications.TransportTypeSMTP,
|
||||
Enabled: false,
|
||||
Email: ¬ifications.EmailTransportConfigUpdate{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
SMTPFrom: "alerts@example.com",
|
||||
Recipients: []string{"alerts@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed_to_seed_smtp_transport: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/notifications/transports/:id/test", TestTransport(svc))
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/notifications/transports/"+strconv.FormatUint(uint64(view.Transports[0].ID), 10)+"/test",
|
||||
nil,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected_200 got: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if emailCalls != 1 {
|
||||
t.Fatalf("expected_email_called_once got: %d", emailCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTransportHandlerReturnsBadRequestForInvalidTransportConfig(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := newHandlerTestService(t)
|
||||
view, err := svc.UpdateTransportConfig(context.Background(), notifications.TransportConfigUpdate{
|
||||
Transports: []notifications.TransportConfigEntryUpdate{
|
||||
{
|
||||
Name: "Broken SMTP",
|
||||
Type: notifications.TransportTypeSMTP,
|
||||
Enabled: true,
|
||||
Email: ¬ifications.EmailTransportConfigUpdate{
|
||||
SMTPHost: "",
|
||||
SMTPPort: 587,
|
||||
SMTPFrom: "alerts@example.com",
|
||||
Recipients: []string{"alerts@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed_to_seed_broken_smtp_transport: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/notifications/transports/:id/test", TestTransport(svc))
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/notifications/transports/"+strconv.FormatUint(uint64(view.Transports[0].ID), 10)+"/test",
|
||||
nil,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected_400 got: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +517,7 @@ func RegisterRoutes(r *gin.Engine,
|
||||
notifications.GET("/transports", notificationsHandlers.GetConfig(notificationService))
|
||||
notifications.PUT("/transports", notificationsHandlers.UpdateConfig(notificationService))
|
||||
notifications.DELETE("/transports/:id", notificationsHandlers.DeleteTransport(notificationService))
|
||||
notifications.POST("/transports/:id/test", notificationsHandlers.TestTransport(notificationService))
|
||||
}
|
||||
|
||||
users := auth.Group("/users")
|
||||
|
||||
@@ -32,11 +32,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultNtfyBaseURL = "https://ntfy.sh"
|
||||
defaultSMTPPort = 587
|
||||
defaultTransportName = "Default"
|
||||
defaultListLimit = 50
|
||||
maxListLimit = 500
|
||||
defaultNtfyBaseURL = "https://ntfy.sh"
|
||||
defaultSMTPPort = 587
|
||||
defaultListLimit = 50
|
||||
maxListLimit = 500
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -391,6 +390,44 @@ func (s *Service) DeleteTransport(ctx context.Context, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) TestTransport(ctx context.Context, id uint) error {
|
||||
if s == nil || s.DB == nil {
|
||||
return fmt.Errorf("notifications_service_not_initialized")
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("invalid_transport_id")
|
||||
}
|
||||
|
||||
cfg, err := s.resolveTransportForUpdate(s.DB.WithContext(ctx), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
input := notifier.EventInput{
|
||||
Kind: "system.notifications.test",
|
||||
Title: "Sylve Notification Test",
|
||||
Body: fmt.Sprintf("This is a test notification sent at %s.", now.Format(time.RFC3339)),
|
||||
Severity: string(models.NotificationSeverityInfo),
|
||||
Source: "settings.notifications",
|
||||
Fingerprint: fmt.Sprintf("transport-test-%d-%d", id, now.UnixNano()),
|
||||
Metadata: map[string]string{
|
||||
"transportId": strconv.FormatUint(uint64(id), 10),
|
||||
},
|
||||
}
|
||||
|
||||
switch normalizeTransportType(cfg.Type) {
|
||||
case TransportTypeNtfy:
|
||||
token := strings.TrimSpace(cfg.NtfyAuthToken)
|
||||
return s.ntfySender(ctx, cfg, input, token)
|
||||
case TransportTypeSMTP:
|
||||
password := strings.TrimSpace(cfg.SMTPPassword)
|
||||
return s.emailSender(ctx, cfg, input, password)
|
||||
default:
|
||||
return fmt.Errorf("invalid_transport_type")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetTransportConfig(ctx context.Context) (TransportConfigView, error) {
|
||||
if s == nil || s.DB == nil {
|
||||
return TransportConfigView{}, fmt.Errorf("notifications_service_not_initialized")
|
||||
@@ -427,7 +464,7 @@ func (s *Service) UpdateTransportConfig(ctx context.Context, input TransportConf
|
||||
|
||||
cfg.Name = strings.TrimSpace(entry.Name)
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = defaultTransportName
|
||||
return fmt.Errorf("transport_name_required")
|
||||
}
|
||||
cfg.Type = transportType
|
||||
|
||||
@@ -577,10 +614,6 @@ func (s *Service) ensureTransportConfigs(tx *gorm.DB) ([]models.NotificationTran
|
||||
for idx := range configs {
|
||||
updated := false
|
||||
cfg := &configs[idx]
|
||||
if strings.TrimSpace(cfg.Name) == "" {
|
||||
cfg.Name = defaultTransportName
|
||||
updated = true
|
||||
}
|
||||
normalizedType := normalizeTransportType(cfg.Type)
|
||||
if normalizedType == "" {
|
||||
if cfg.NtfyEnabled && !cfg.EmailEnabled {
|
||||
@@ -668,10 +701,6 @@ func (s *Service) toTransportConfigView(configs []models.NotificationTransportCo
|
||||
Type: normalizeTransportType(cfg.Type),
|
||||
}
|
||||
|
||||
if entry.Name == "" {
|
||||
entry.Name = defaultTransportName
|
||||
}
|
||||
|
||||
switch entry.Type {
|
||||
case TransportTypeNtfy:
|
||||
entry.Enabled = cfg.NtfyEnabled
|
||||
|
||||
@@ -276,6 +276,101 @@ func TestTransportConfigStoresRecipientsAndSecretFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTransportConfigRejectsEmptyTransportName(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
|
||||
_, err := svc.UpdateTransportConfig(context.Background(), TransportConfigUpdate{
|
||||
Transports: []TransportConfigEntryUpdate{
|
||||
{
|
||||
Name: " ",
|
||||
Type: TransportTypeNtfy,
|
||||
Enabled: true,
|
||||
Ntfy: &NtfyTransportConfigUpdate{
|
||||
BaseURL: "https://ntfy.sh",
|
||||
Topic: "alerts",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected_error_for_blank_transport_name")
|
||||
}
|
||||
if err.Error() != "transport_name_required" {
|
||||
t.Fatalf("expected_transport_name_required got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTransportSendsForNtfyAndSMTPEvenWhenDisabled(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
|
||||
ntfyCalls := 0
|
||||
emailCalls := 0
|
||||
svc.SetNtfySender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, token string) error {
|
||||
ntfyCalls++
|
||||
return nil
|
||||
})
|
||||
svc.SetEmailSender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, password string) error {
|
||||
emailCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
view, err := svc.UpdateTransportConfig(context.Background(), TransportConfigUpdate{
|
||||
Transports: []TransportConfigEntryUpdate{
|
||||
{
|
||||
Name: "Ntfy Transport",
|
||||
Type: TransportTypeNtfy,
|
||||
Enabled: false,
|
||||
Ntfy: &NtfyTransportConfigUpdate{
|
||||
BaseURL: "https://ntfy.sh",
|
||||
Topic: "alerts",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SMTP Transport",
|
||||
Type: TransportTypeSMTP,
|
||||
Enabled: false,
|
||||
Email: &EmailTransportConfigUpdate{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
SMTPFrom: "alerts@example.com",
|
||||
Recipients: []string{"alerts@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update_transport_config_failed: %v", err)
|
||||
}
|
||||
|
||||
var ntfyID uint
|
||||
var smtpID uint
|
||||
for _, transport := range view.Transports {
|
||||
switch transport.Type {
|
||||
case TransportTypeNtfy:
|
||||
ntfyID = transport.ID
|
||||
case TransportTypeSMTP:
|
||||
smtpID = transport.ID
|
||||
}
|
||||
}
|
||||
if ntfyID == 0 || smtpID == 0 {
|
||||
t.Fatalf("expected_both_transport_ids ntfy=%d smtp=%d", ntfyID, smtpID)
|
||||
}
|
||||
|
||||
if err := svc.TestTransport(context.Background(), ntfyID); err != nil {
|
||||
t.Fatalf("test_ntfy_transport_failed: %v", err)
|
||||
}
|
||||
if err := svc.TestTransport(context.Background(), smtpID); err != nil {
|
||||
t.Fatalf("test_smtp_transport_failed: %v", err)
|
||||
}
|
||||
|
||||
if ntfyCalls != 1 {
|
||||
t.Fatalf("expected_ntfy_test_call_once got: %d", ntfyCalls)
|
||||
}
|
||||
if emailCalls != 1 {
|
||||
t.Fatalf("expected_smtp_test_call_once got: %d", emailCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressionDoesNotCrossKindsWithSameFingerprint(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
|
||||
|
||||
@@ -45,3 +45,7 @@ export async function updateNotificationTransports(
|
||||
export async function deleteNotificationTransport(id: number): Promise<APIResponse> {
|
||||
return await apiRequest(`/notifications/transports/${id}`, APIResponseSchema, 'DELETE');
|
||||
}
|
||||
|
||||
export async function testNotificationTransport(id: number): Promise<APIResponse> {
|
||||
return await apiRequest(`/notifications/transports/${id}/test`, APIResponseSchema, 'POST');
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
import SimpleSelect from '$lib/components/custom/SimpleSelect.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import CustomCheckbox from '$lib/components/ui/custom-input/checkbox.svelte';
|
||||
import ComboBox from '$lib/components/ui/custom-input/combobox.svelte';
|
||||
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import type { User } from '$lib/types/auth';
|
||||
import type { NotificationConfig, UpdateNotificationConfigInput } from '$lib/types/notifications';
|
||||
import { handleAPIError, isAPIResponse } from '$lib/utils/http';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type TransportType = 'ntfy' | 'smtp';
|
||||
@@ -24,7 +27,7 @@
|
||||
smtpUsername: string;
|
||||
smtpFrom: string;
|
||||
smtpUseTls: boolean;
|
||||
smtpRecipients: string;
|
||||
smtpRecipients: string[];
|
||||
smtpPassword: string;
|
||||
smtpHasPassword: boolean;
|
||||
};
|
||||
@@ -33,13 +36,15 @@
|
||||
open: boolean;
|
||||
edit: boolean;
|
||||
id?: number;
|
||||
users: User[];
|
||||
transports: NotificationConfig['transports'];
|
||||
afterChange: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), edit, id, transports, afterChange }: Props = $props();
|
||||
let { open = $bindable(), edit, id, users, transports, afterChange }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let smtpRecipientsOpen = $state(false);
|
||||
|
||||
function defaultForm(index = 1, type: TransportType = 'smtp'): TransportForm {
|
||||
return {
|
||||
@@ -55,7 +60,7 @@
|
||||
smtpUsername: '',
|
||||
smtpFrom: '',
|
||||
smtpUseTls: true,
|
||||
smtpRecipients: '',
|
||||
smtpRecipients: [],
|
||||
smtpPassword: '',
|
||||
smtpHasPassword: false
|
||||
};
|
||||
@@ -87,7 +92,7 @@
|
||||
smtpUsername: editingTransport.email?.smtpUsername ?? '',
|
||||
smtpFrom: editingTransport.email?.smtpFrom ?? '',
|
||||
smtpUseTls: editingTransport.email?.smtpUseTls ?? true,
|
||||
smtpRecipients: (editingTransport.email?.recipients ?? []).join(', '),
|
||||
smtpRecipients: [...(editingTransport.email?.recipients ?? [])],
|
||||
smtpPassword: '',
|
||||
smtpHasPassword: editingTransport.email?.hasPassword ?? false
|
||||
};
|
||||
@@ -97,17 +102,47 @@
|
||||
}
|
||||
});
|
||||
|
||||
function parseRecipients(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]+/g)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
const smtpRecipientOptions = $derived.by(() => {
|
||||
const seen = new SvelteSet<string>();
|
||||
const options: { label: string; value: string }[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
const email = user.email.trim();
|
||||
if (!email || seen.has(email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(email);
|
||||
options.push({
|
||||
label: user.username ? `${user.username} <${email}>` : email,
|
||||
value: email
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
function normalizeRecipients(values: string[]): string[] {
|
||||
const seen = new SvelteSet<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const recipient = value.trim();
|
||||
if (!recipient || seen.has(recipient)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(recipient);
|
||||
normalized.push(recipient);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildEntry(f: TransportForm): UpdateNotificationConfigInput['transports'][number] {
|
||||
return {
|
||||
...(f.id ? { id: f.id } : {}),
|
||||
name: f.name.trim() || 'Default',
|
||||
name: f.name.trim(),
|
||||
type: f.type,
|
||||
enabled: f.enabled,
|
||||
ntfy:
|
||||
@@ -126,7 +161,7 @@
|
||||
smtpUsername: f.smtpUsername,
|
||||
smtpFrom: f.smtpFrom,
|
||||
smtpUseTls: f.smtpUseTls,
|
||||
recipients: parseRecipients(f.smtpRecipients),
|
||||
recipients: normalizeRecipients(f.smtpRecipients),
|
||||
...(f.smtpPassword.trim().length > 0 ? { smtpPassword: f.smtpPassword.trim() } : {})
|
||||
}
|
||||
: null
|
||||
@@ -156,6 +191,14 @@
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (form.name.trim().length === 0) {
|
||||
toast.error('Transport name is required', {
|
||||
duration: 5000,
|
||||
position: 'bottom-center'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
const entry = buildEntry(form);
|
||||
@@ -219,7 +262,7 @@
|
||||
smtpUsername: editingTransport.email?.smtpUsername ?? '',
|
||||
smtpFrom: editingTransport.email?.smtpFrom ?? '',
|
||||
smtpUseTls: editingTransport.email?.smtpUseTls ?? true,
|
||||
smtpRecipients: (editingTransport.email?.recipients ?? []).join(', '),
|
||||
smtpRecipients: [...(editingTransport.email?.recipients ?? [])],
|
||||
smtpPassword: '',
|
||||
smtpHasPassword: editingTransport.email?.hasPassword ?? false
|
||||
};
|
||||
@@ -309,12 +352,18 @@
|
||||
: 'Optional'}
|
||||
revealOnFocus={true}
|
||||
/>
|
||||
<CustomValueInput
|
||||
label="Recipients (comma or newline separated)"
|
||||
type="textarea"
|
||||
<ComboBox
|
||||
bind:open={smtpRecipientsOpen}
|
||||
label="Recipients"
|
||||
bind:value={form.smtpRecipients}
|
||||
placeholder=""
|
||||
textAreaClasses="min-h-20"
|
||||
data={smtpRecipientOptions}
|
||||
placeholder="Select or type recipients"
|
||||
width="w-full"
|
||||
multiple={true}
|
||||
allowCustom={true}
|
||||
onValueChange={(value) => {
|
||||
form.smtpRecipients = normalizeRecipients(Array.isArray(value) ? value : []);
|
||||
}}
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-x-4">
|
||||
<CustomCheckbox label="Use TLS/STARTTLS" bind:checked={form.smtpUseTls} />
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { deleteNotificationTransport, getNotificationTransports } from '$lib/api/notifications';
|
||||
import {
|
||||
deleteNotificationTransport,
|
||||
getNotificationTransports,
|
||||
testNotificationTransport
|
||||
} from '$lib/api/notifications';
|
||||
import AlertDialog from '$lib/components/custom/Dialog/Alert.svelte';
|
||||
import CreateOrEdit from '$lib/components/custom/Notifications/CreateOrEdit.svelte';
|
||||
import TreeTable from '$lib/components/custom/TreeTable.svelte';
|
||||
import Search from '$lib/components/custom/TreeTable/Search.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import type { User } from '$lib/types/auth';
|
||||
import type { Column, Row } from '$lib/types/components/tree-table';
|
||||
import type { NotificationConfig } from '$lib/types/notifications';
|
||||
import { handleAPIError, isAPIResponse, updateCache } from '$lib/utils/http';
|
||||
@@ -14,6 +19,7 @@
|
||||
|
||||
interface Data {
|
||||
config: NotificationConfig;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
let { data }: { data: Data } = $props();
|
||||
@@ -73,6 +79,24 @@
|
||||
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
|
||||
|
||||
let query: string = $state('');
|
||||
let testingTransport = $state(false);
|
||||
|
||||
async function testSelectedTransport() {
|
||||
if (!activeRow?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
testingTransport = true;
|
||||
const result = await testNotificationTransport(Number(activeRow.id));
|
||||
testingTransport = false;
|
||||
|
||||
if (isAPIResponse(result) && result.status === 'success') {
|
||||
toast.success('Test notification sent', { position: 'bottom-center' });
|
||||
} else {
|
||||
handleAPIError(result);
|
||||
toast.error('Failed to send test notification', { position: 'bottom-center' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet button(type: string)}
|
||||
@@ -107,6 +131,23 @@
|
||||
<span>Delete</span>
|
||||
</div>
|
||||
</Button>
|
||||
{:else if type === 'test'}
|
||||
<Button
|
||||
onclick={testSelectedTransport}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-6.5"
|
||||
disabled={testingTransport}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
{#if testingTransport}
|
||||
<span class="icon-[mdi--loading] mr-1 h-4 w-4 animate-spin"></span>
|
||||
{:else}
|
||||
<span class="icon-[mdi--flask-outline] mr-1 h-4 w-4"></span>
|
||||
{/if}
|
||||
<span>Test</span>
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -120,6 +161,7 @@
|
||||
<span>New</span>
|
||||
</div>
|
||||
</Button>
|
||||
{@render button('test')}
|
||||
{@render button('edit')}
|
||||
{@render button('delete')}
|
||||
</div>
|
||||
@@ -136,6 +178,7 @@
|
||||
<CreateOrEdit
|
||||
bind:open={modals.create.open}
|
||||
edit={false}
|
||||
users={data.users}
|
||||
transports={(configResource.current as NotificationConfig).transports || []}
|
||||
afterChange={() => configResource.refetch()}
|
||||
/>
|
||||
@@ -146,6 +189,7 @@
|
||||
bind:open={modals.edit.open}
|
||||
edit={true}
|
||||
id={modals.edit.id}
|
||||
users={data.users}
|
||||
transports={(configResource.current as NotificationConfig).transports || []}
|
||||
afterChange={() => configResource.refetch()}
|
||||
/>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { getNotificationTransports } from '$lib/api/notifications';
|
||||
import { listUsers } from '$lib/api/auth/local';
|
||||
import { SEVEN_DAYS } from '$lib/utils';
|
||||
import { cachedFetch, isAPIResponse } from '$lib/utils/http';
|
||||
|
||||
export async function load() {
|
||||
const response = await cachedFetch(
|
||||
'notification-config',
|
||||
async () => await getNotificationTransports(),
|
||||
SEVEN_DAYS
|
||||
);
|
||||
const [response, usersResponse] = await Promise.all([
|
||||
cachedFetch('notification-config', async () => await getNotificationTransports(), SEVEN_DAYS),
|
||||
cachedFetch('users', async () => await listUsers(), SEVEN_DAYS)
|
||||
]);
|
||||
|
||||
const config = isAPIResponse(response)
|
||||
? {
|
||||
transports: []
|
||||
}
|
||||
: response;
|
||||
const users = Array.isArray(usersResponse) ? usersResponse : [];
|
||||
|
||||
return {
|
||||
config
|
||||
config,
|
||||
users
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user