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
+
);
}
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 (
+
+ );
+}