diff --git a/apps/web/app/settings/components/SettingsNav.tsx b/apps/web/app/settings/components/SettingsNav.tsx index e0df68b..f6ec33d 100644 --- a/apps/web/app/settings/components/SettingsNav.tsx +++ b/apps/web/app/settings/components/SettingsNav.tsx @@ -1,9 +1,10 @@ export function SettingsNav() { return ( -
- Profile - Members - Security +
+ Profile + Members + Security + Notifications
); } diff --git a/apps/web/app/settings/notifications/notifications.css b/apps/web/app/settings/notifications/notifications.css new file mode 100644 index 0000000..0c38070 --- /dev/null +++ b/apps/web/app/settings/notifications/notifications.css @@ -0,0 +1,212 @@ +:root { + --color-bg-main: #0b0e14; + --color-bg-card: rgba(17, 22, 32, 0.75); + --color-border-card: rgba(255, 255, 255, 0.08); + --color-text-primary: #f8fafc; + --color-text-secondary: #94a3b8; + --color-accent: #8b5cf6; + --color-success: #10b981; + --color-error: #ef4444; +} + +.notifications-container { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem; + min-height: 100vh; + background-color: var(--color-bg-main); + color: var(--color-text-primary); + font-family: + 'Outfit', + 'Inter', + -apple-system, + sans-serif; +} + +.notifications-header { + margin-bottom: 2rem; +} + +.notifications-title { + font-size: 2.25rem; + font-weight: 700; + letter-spacing: -0.025em; + margin: 0; + background: linear-gradient(135deg, #ffffff 0%, #94a3b8 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.notifications-subtitle { + color: var(--color-text-secondary); + margin-top: 0.5rem; + font-size: 0.95rem; +} + +.notifications-layout { + display: flex; + gap: 2rem; +} + +.notifications-nav { + width: 14rem; + border-right: 1px solid var(--color-border-card); + padding-right: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.notifications-nav-link { + color: var(--color-text-secondary); + text-decoration: none; + font-size: 0.95rem; + transition: color 0.15s ease; +} + +.notifications-nav-link:hover { + color: var(--color-text-primary); +} + +.notifications-nav-link--active { + color: var(--color-accent); + font-weight: 500; +} + +.notifications-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.notifications-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border-card); + border-radius: 12px; + padding: 1.5rem; + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.notifications-card-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-border-card); + padding-bottom: 0.75rem; +} + +.notifications-card-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.notifications-toggle-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +.notifications-toggle-input { + width: 1.25rem; + height: 1.25rem; + accent-color: var(--color-accent); +} + +.notifications-fields { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.notifications-field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.notifications-label { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.notifications-input { + background: rgba(15, 23, 42, 0.8); + border: 1px solid var(--color-border-card); + border-radius: 8px; + color: var(--color-text-primary); + padding: 0.75rem 1rem; + font-size: 0.95rem; + width: 100%; + box-sizing: border-box; +} + +.notifications-input:focus { + outline: 2px solid rgba(139, 92, 246, 0.5); + border-color: var(--color-accent); +} + +.notifications-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.notifications-error { + color: var(--color-error); + font-size: 0.8rem; + margin-top: 0.25rem; +} + +.notifications-actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; +} + +.notifications-button { + background: var(--color-accent); + color: white; + border: none; + border-radius: 8px; + padding: 0.75rem 1.5rem; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.notifications-button:hover { + opacity: 0.9; +} + +.notifications-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.notifications-status { + font-size: 0.875rem; + padding: 0.25rem 0.75rem; + border-radius: 4px; +} + +.notifications-status--success { + color: var(--color-success); + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.notifications-status--error { + color: var(--color-error); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); +} diff --git a/apps/web/app/settings/notifications/page.spec.tsx b/apps/web/app/settings/notifications/page.spec.tsx new file mode 100644 index 0000000..2eb3eeb --- /dev/null +++ b/apps/web/app/settings/notifications/page.spec.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import NotificationPreferencesPage from './page'; + +describe('NotificationPreferencesPage', () => { + beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); + }); + + it('renders page headers and disabled form toggles initially', () => { + render(); + + expect(screen.getByRole('heading', { name: /notification preferences/i })).toBeInTheDocument(); + expect( + screen.getByText(/configure where sentinel delivers security alerts/i), + ).toBeInTheDocument(); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach(cb => { + expect(cb).not.toBeChecked(); + }); + }); + + it('toggles Discord input visibility and validates invalid discord webhook URL', () => { + render(); + + const toggles = screen.getAllByRole('checkbox'); + // First toggle is Discord + const discCb = toggles[0]; + + fireEvent.click(discCb); + expect(discCb).toBeChecked(); + + const input = screen.getByLabelText(/webhook url/i); + expect(input).toBeInTheDocument(); + + // Input invalid URL and save + fireEvent.change(input, { target: { value: 'https://invalid-discord.com' } }); + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + + expect(screen.getByText(/invalid discord webhook url/i)).toBeInTheDocument(); + expect(screen.getByText(/validation failed/i)).toBeInTheDocument(); + }); + + it('saves Discord preferences successfully on valid input', () => { + render(); + + const toggles = screen.getAllByRole('checkbox'); + fireEvent.click(toggles[0]); // Enable Discord + + const input = screen.getByLabelText(/webhook url/i); + fireEvent.change(input, { + target: { value: 'https://discord.com/api/webhooks/123456789/ABC_XYZ-123' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + + expect(screen.queryByText(/invalid discord webhook url/i)).not.toBeInTheDocument(); + expect(screen.getByText(/settings saved successfully!/i)).toBeInTheDocument(); + + // Verify localStorage persistence + const saved = JSON.parse( + window.localStorage.getItem('sentinel_notification_preferences') || '{}', + ); + expect(saved.discord.enabled).toBe(true); + expect(saved.discord.webhookUrl).toBe('https://discord.com/api/webhooks/123456789/ABC_XYZ-123'); + }); + + it('toggles Telegram inputs and validates Bot Token and Chat ID formats', () => { + render(); + + const toggles = screen.getAllByRole('checkbox'); + fireEvent.click(toggles[1]); // Enable Telegram + + const botTokenInput = screen.getByLabelText(/bot token/i); + const chatIdInput = screen.getByLabelText(/chat id/i); + + // Empty validation check + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + expect(screen.getByText(/bot token is required/i)).toBeInTheDocument(); + expect(screen.getByText(/chat id is required/i)).toBeInTheDocument(); + + // Invalid format check + fireEvent.change(botTokenInput, { target: { value: 'invalid_token' } }); + fireEvent.change(chatIdInput, { target: { value: 'not-an-integer' } }); + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + + expect(screen.getByText(/invalid telegram bot token format/i)).toBeInTheDocument(); + expect(screen.getByText(/invalid chat id/i)).toBeInTheDocument(); + + // Valid formats check + fireEvent.change(botTokenInput, { + target: { value: '123456789:ABCDefGhIJKlmNoPQRsTUVwxyZ123456789' }, + }); + fireEvent.change(chatIdInput, { target: { value: '-100123456789' } }); + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + + expect(screen.queryByText(/invalid telegram bot token format/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/invalid chat id/i)).not.toBeInTheDocument(); + expect(screen.getByText(/settings saved successfully!/i)).toBeInTheDocument(); + }); + + it('toggles Custom Webhook inputs and validates URL and Secret fields', () => { + render(); + + const toggles = screen.getAllByRole('checkbox'); + fireEvent.click(toggles[2]); // Enable Custom Webhook + + const urlInput = screen.getByLabelText(/endpoint url/i); + const secretInput = screen.getByLabelText(/signing secret/i); + + // Empty validation + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + expect(screen.getByText(/endpoint url is required/i)).toBeInTheDocument(); + expect(screen.getByText(/signing secret is required/i)).toBeInTheDocument(); + + // Invalid format/length + fireEvent.change(urlInput, { target: { value: 'not-a-url' } }); + fireEvent.change(secretInput, { target: { value: 'short' } }); + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + + expect(screen.getByText(/invalid url format/i)).toBeInTheDocument(); + expect(screen.getByText(/signing secret must be at least 8 characters/i)).toBeInTheDocument(); + + // Valid inputs + fireEvent.change(urlInput, { target: { value: 'https://api.sentinel.com/webhook' } }); + fireEvent.change(secretInput, { target: { value: 'supersecret123' } }); + fireEvent.click(screen.getByRole('button', { name: /save preferences/i })); + + expect(screen.queryByText(/invalid url format/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/signing secret must be at least 8/i)).not.toBeInTheDocument(); + expect(screen.getByText(/settings saved successfully!/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/settings/notifications/page.tsx b/apps/web/app/settings/notifications/page.tsx new file mode 100644 index 0000000..fb5c22a --- /dev/null +++ b/apps/web/app/settings/notifications/page.tsx @@ -0,0 +1,390 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { SettingsNav } from '../components/SettingsNav'; +import './notifications.css'; + +interface DiscordSettings { + enabled: boolean; + webhookUrl: string; +} + +interface TelegramSettings { + enabled: boolean; + botToken: string; + chatId: string; +} + +interface WebhookSettings { + enabled: boolean; + endpointUrl: string; + secret: string; +} + +interface PreferencesState { + discord: DiscordSettings; + telegram: TelegramSettings; + webhook: WebhookSettings; +} + +const STORAGE_KEY = 'sentinel_notification_preferences'; + +const initialPreferences: PreferencesState = { + discord: { + enabled: false, + webhookUrl: '', + }, + telegram: { + enabled: false, + botToken: '', + chatId: '', + }, + webhook: { + enabled: false, + endpointUrl: '', + secret: '', + }, +}; + +export default function NotificationPreferencesPage() { + const [preferences, setPreferences] = useState(initialPreferences); + const [errors, setErrors] = useState>({}); + const [status, setStatus] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + + // Load from localStorage on mount + useEffect(() => { + if (typeof window !== 'undefined') { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + // Merge with initialPreferences to ensure all fields exist + setPreferences({ + discord: { ...initialPreferences.discord, ...parsed.discord }, + telegram: { ...initialPreferences.telegram, ...parsed.telegram }, + webhook: { ...initialPreferences.webhook, ...parsed.webhook }, + }); + } catch { + // Keep default if JSON parsing fails + } + } + } + }, []); + + const validate = (): boolean => { + const newErrors: Record = {}; + + // Discord validation + if (preferences.discord.enabled) { + const url = preferences.discord.webhookUrl.trim(); + if (!url) { + newErrors.discordUrl = 'Webhook URL is required.'; + } else { + const discordRegex = + /^https:\/\/(?:discord|discordapp)\.com\/api\/webhooks\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; + if (!discordRegex.test(url)) { + newErrors.discordUrl = + 'Invalid Discord webhook URL. Must match format: https://discord.com/api/webhooks/{webhookId}/{webhookToken}'; + } + } + } + + // Telegram validation + if (preferences.telegram.enabled) { + const token = preferences.telegram.botToken.trim(); + const chat = preferences.telegram.chatId.trim(); + + if (!token) { + newErrors.telegramToken = 'Bot Token is required.'; + } else { + const tokenRegex = /^\d+:[A-Za-z0-9_-]{35}$/; + if (!tokenRegex.test(token)) { + newErrors.telegramToken = + 'Invalid Telegram bot token format. Must match standard token (e.g. 123456789:ABCDefGhIJKlmNoPQRsTUVwxyZ123456789).'; + } + } + + if (!chat) { + newErrors.telegramChatId = 'Chat ID is required.'; + } else { + const chatIdRegex = /^-?\d+$/; + if (!chatIdRegex.test(chat)) { + newErrors.telegramChatId = 'Invalid Chat ID. Must be a valid integer.'; + } + } + } + + // Webhook validation + if (preferences.webhook.enabled) { + const url = preferences.webhook.endpointUrl.trim(); + const secret = preferences.webhook.secret.trim(); + + if (!url) { + newErrors.webhookUrl = 'Endpoint URL is required.'; + } else { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + newErrors.webhookUrl = 'Endpoint URL must use HTTP or HTTPS protocol.'; + } + } catch { + newErrors.webhookUrl = 'Invalid URL format.'; + } + } + + if (!secret) { + newErrors.webhookSecret = 'Signing Secret is required.'; + } else if (secret.length < 8) { + newErrors.webhookSecret = 'Signing Secret must be at least 8 characters.'; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + setStatus(null); + + if (validate()) { + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } + setStatus({ message: 'Settings saved successfully!', type: 'success' }); + } else { + setStatus({ message: 'Validation failed. Please correct the errors below.', type: 'error' }); + } + }; + + return ( +
+
+

Notification Preferences

+

+ Configure where Sentinel delivers security alerts and events. +

+
+ +
+ + +
+
+ {/* Discord section */} +
+
+

Discord Alerts

+ +
+ + {preferences.discord.enabled && ( +
+
+ + + setPreferences({ + ...preferences, + discord: { ...preferences.discord, webhookUrl: e.target.value }, + }) + } + aria-describedby={errors.discordUrl ? 'discord-url-error' : undefined} + /> + {errors.discordUrl && ( + + {errors.discordUrl} + + )} +
+
+ )} +
+ + {/* Telegram section */} +
+
+

Telegram Alerts

+ +
+ + {preferences.telegram.enabled && ( +
+
+ + + setPreferences({ + ...preferences, + telegram: { ...preferences.telegram, botToken: e.target.value }, + }) + } + aria-describedby={errors.telegramToken ? 'telegram-token-error' : undefined} + /> + {errors.telegramToken && ( + + {errors.telegramToken} + + )} +
+ +
+ + + setPreferences({ + ...preferences, + telegram: { ...preferences.telegram, chatId: e.target.value }, + }) + } + aria-describedby={errors.telegramChatId ? 'telegram-chat-error' : undefined} + /> + {errors.telegramChatId && ( + + {errors.telegramChatId} + + )} +
+
+ )} +
+ + {/* Custom Webhook section */} +
+
+

Custom Webhook

+ +
+ + {preferences.webhook.enabled && ( +
+
+ + + setPreferences({ + ...preferences, + webhook: { ...preferences.webhook, endpointUrl: e.target.value }, + }) + } + aria-describedby={errors.webhookUrl ? 'webhook-url-error' : undefined} + /> + {errors.webhookUrl && ( + + {errors.webhookUrl} + + )} +
+ +
+ + + setPreferences({ + ...preferences, + webhook: { ...preferences.webhook, secret: e.target.value }, + }) + } + aria-describedby={errors.webhookSecret ? 'webhook-secret-error' : undefined} + /> + {errors.webhookSecret && ( + + {errors.webhookSecret} + + )} +
+
+ )} +
+ + {/* Actions and Status messages */} +
+ + {status && ( + + {status.message} + + )} +
+
+
+
+
+ ); +}