import { useState, useEffect } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react'; import { api } from '../api/client'; import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client'; import { Button } from './Button'; import { Toggle } from './Toggle'; interface AddNotificationModalProps { provider?: NotificationProvider | null; onClose: () => void; } const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'callmebot', 'webhook', 'homeassistant']; export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const isEditing = !!provider; const [name, setName] = useState(provider?.name || ''); const [providerType, setProviderType] = useState(provider?.provider_type || 'email'); const [printerId, setPrinterId] = useState(provider?.printer_id || null); const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false); const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00'); const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00'); // Daily digest const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false); const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00'); // Event toggles const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false); const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true); const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true); const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true); const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false); const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false); const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false); const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false); const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false); const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false); const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false); const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false); const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false); const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false); // Provider-specific config (scalar fields only — event_priorities is split out // into its own state because it's an object, not a string). const [config, setConfig] = useState>( provider?.config ? Object.fromEntries( Object.entries(provider.config) .filter(([k]) => k !== 'event_priorities') .map(([k, v]) => [k, String(v)]), ) : {}, ); // Per-event ntfy priority (#990). Map of event key → 1-5. Persisted into // config.event_priorities on save; only sent when the provider is ntfy. const initialEventPriorities = (() => { const raw = provider?.config?.event_priorities; if (!raw || typeof raw !== 'object') return {} as Record; const out: Record = {}; for (const [k, v] of Object.entries(raw as Record)) { const n = Number(v); if (Number.isInteger(n) && n >= 1 && n <= 5) out[k] = n; } return out; })(); const [eventPriorities, setEventPriorities] = useState>(initialEventPriorities); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [error, setError] = useState(null); // Fetch printers for linking const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // Test configuration mutation const testMutation = useMutation({ mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }), onSuccess: (result) => { setTestResult(result); setError(null); }, onError: (err: Error) => { setTestResult({ success: false, message: err.message }); }, }); // Create mutation const createMutation = useMutation({ mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notification-providers'] }); onClose(); }, onError: (err: Error) => { setError(err.message); }, }); // Update mutation const updateMutation = useMutation({ mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notification-providers'] }); onClose(); }, onError: (err: Error) => { setError(err.message); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!name.trim()) { setError(t('notifications.nameRequired')); return; } // Validate provider-specific config const requiredFields = getRequiredFields(providerType); for (const field of requiredFields) { if (!config[field.key]?.trim()) { setError(t('notifications.fieldRequired', { field: field.label })); return; } } // HA custom service-data must be a JSON object (#1441) if (providerType === 'homeassistant' && config.data?.trim()) { let parsed: unknown; try { parsed = JSON.parse(config.data); } catch { setError(t('notifications.haDataInvalid')); return; } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { setError(t('notifications.haDataInvalid')); return; } } const finalConfig: Record = providerType === 'ntfy' && Object.keys(eventPriorities).length > 0 ? { ...config, event_priorities: eventPriorities } : config; const data = { name: name.trim(), provider_type: providerType, config: finalConfig, printer_id: printerId, quiet_hours_enabled: quietHoursEnabled, quiet_hours_start: quietHoursEnabled ? quietHoursStart : null, quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null, // Daily digest daily_digest_enabled: dailyDigestEnabled, daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null, // Event toggles on_print_start: onPrintStart, on_print_complete: onPrintComplete, on_print_failed: onPrintFailed, on_print_stopped: onPrintStopped, on_print_progress: onPrintProgress, on_printer_offline: onPrinterOffline, on_printer_error: onPrinterError, on_ai_failure_detection: onAiFailureDetection, on_filament_low: onFilamentLow, on_maintenance_due: onMaintenanceDue, on_stock_reorder_alert: onStockReorderAlert, on_stock_break_alert: onStockBreakAlert, on_bed_cooled: onBedCooled, on_first_layer_complete: onFirstLayerComplete, }; if (isEditing) { updateMutation.mutate(data); } else { createMutation.mutate(data); } }; const isPending = createMutation.isPending || updateMutation.isPending; // Get config fields for each provider type const getConfigFields = (type: ProviderType) => { switch (type) { case 'callmebot': return [ { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true }, { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true }, ]; case 'ntfy': return [ { key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false }, { key: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true }, { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false }, ]; case 'pushover': return [ { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true }, { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true }, { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false }, // Emergency priority (2) requires retry/expire — Pushover rejects the // message otherwise. Only shown when priority is set to 2. { key: 'retry', label: t('notifications.pushoverRetry'), placeholder: '60', type: 'number', required: false, showIf: (cfg: Record) => cfg.priority === '2', }, { key: 'expire', label: t('notifications.pushoverExpire'), placeholder: '3600', type: 'number', required: false, showIf: (cfg: Record) => cfg.priority === '2', }, ]; case 'telegram': return [ { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true }, { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true }, ]; case 'email': return [ { key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true }, { key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false }, { key: 'security', label: 'Security', type: 'select', required: false, options: [ { value: 'starttls', label: 'STARTTLS (Port 587)' }, { value: 'ssl', label: 'SSL/TLS (Port 465)' }, { value: 'none', label: 'None (Port 25)' }, ]}, { key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [ { value: 'true', label: 'Enabled' }, { value: 'false', label: 'Disabled' }, ]}, { key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false }, { key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false }, { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true }, { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true }, ]; case 'discord': return [ { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true }, ]; case 'webhook': return [ { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true }, { key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [ { value: 'generic', label: 'Generic JSON' }, { value: 'slack', label: 'Slack / Mattermost' }, ]}, { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false }, { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false, showIf: (cfg: Record) => cfg.payload_format !== 'slack' }, { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record) => cfg.payload_format !== 'slack' }, ]; case 'homeassistant': return [ { key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false }, { key: 'data', label: 'Data (JSON, optional)', placeholder: '{"priority": "high", "ttl": 0, "channel": "3D Printing"}', type: 'textarea', required: false }, ]; default: return []; } }; const getRequiredFields = (type: ProviderType) => { return getConfigFields(type).filter(f => f.required); }; const configFields = getConfigFields(providerType); return (
e.stopPropagation()} > {/* Header */}

{isEditing ? t('notifications.editTitle') : t('notifications.addTitle')}

{/* Form */}
{error && (
{error}
)} {/* Name */}
setName(e.target.value)} placeholder={t('notifications.namePlaceholder')} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
{/* Provider Type */}

{t(`notifications.providerDescriptions.${providerType}`, '')}

{/* Provider-specific configuration */}

{t('notifications.configuration')}

{configFields .filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record) => boolean }).showIf?.(config) !== false) .map((field) => (
{field.type === 'select' && 'options' in field && field.options ? ( ) : field.type === 'textarea' ? (