Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions apps/web/app/settings/components/SettingsNav.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
export function SettingsNav() {
return (
<div className="w-56 border-r pr-4 space-y-2">
<a href="#profile">Profile</a>
<a href="#members">Members</a>
<a href="#security">Security</a>
<div className="w-56 border-r pr-4 space-y-2 flex flex-col">
<a href="/settings#profile">Profile</a>
<a href="/settings#members">Members</a>
<a href="/settings#security">Security</a>
<a href="/settings/notifications">Notifications</a>
</div>
);
}
212 changes: 212 additions & 0 deletions apps/web/app/settings/notifications/notifications.css
Original file line number Diff line number Diff line change
@@ -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);
}
137 changes: 137 additions & 0 deletions apps/web/app/settings/notifications/page.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<NotificationPreferencesPage />);

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(<NotificationPreferencesPage />);

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(<NotificationPreferencesPage />);

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(<NotificationPreferencesPage />);

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(<NotificationPreferencesPage />);

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