feat: add SMTP settings page and user management features

- Added a new SMTP settings page with functionality to configure SMTP settings, test connections, and send test emails.
- Implemented user management page to list users, invite new users, and manage user permissions.
- Created modals for inviting users and editing user permissions.
- Added tests for the new SMTP settings and user management functionalities.
- Updated navigation to include links to the new SMTP settings and user management pages.
This commit is contained in:
GitHub Actions
2025-12-05 00:47:57 +00:00
parent d3c5196631
commit c06c2829a6
27 changed files with 6050 additions and 30 deletions

View File

@@ -0,0 +1,208 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import AcceptInvite from '../AcceptInvite'
import * as usersApi from '../../api/users'
// Mock APIs
vi.mock('../../api/users', () => ({
validateInvite: vi.fn(),
acceptInvite: vi.fn(),
listUsers: vi.fn(),
getUser: vi.fn(),
createUser: vi.fn(),
inviteUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
updateUserPermissions: vi.fn(),
}))
// Mock react-router-dom navigate
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (initialRoute: string = '/accept-invite?token=test-token') => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<Routes>
<Route path="/accept-invite" element={<AcceptInvite />} />
<Route path="/login" element={<div>Login Page</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
}
describe('AcceptInvite', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows invalid link message when no token provided', async () => {
renderWithProviders('/accept-invite')
await waitFor(() => {
expect(screen.getByText('Invalid Link')).toBeTruthy()
})
expect(screen.getByText(/This invitation link is invalid/)).toBeTruthy()
})
it('shows validating state initially', () => {
vi.mocked(usersApi.validateInvite).mockReturnValue(new Promise(() => {}))
renderWithProviders()
expect(screen.getByText('Validating invitation...')).toBeTruthy()
})
it('shows error for invalid token', async () => {
vi.mocked(usersApi.validateInvite).mockRejectedValue({
response: { data: { error: 'Token expired' } },
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('Invitation Invalid')).toBeTruthy()
})
})
it('renders accept form for valid token', async () => {
vi.mocked(usersApi.validateInvite).mockResolvedValue({
valid: true,
email: 'invited@example.com',
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByText(/been invited/i)).toBeTruthy()
})
expect(screen.getByText(/invited@example.com/)).toBeTruthy()
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
// Password and confirm password have same placeholder
expect(screen.getAllByPlaceholderText('••••••••').length).toBe(2)
})
it('shows password mismatch error', async () => {
vi.mocked(usersApi.validateInvite).mockResolvedValue({
valid: true,
email: 'invited@example.com',
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
})
const user = userEvent.setup()
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
await user.type(passwordInput, 'password123')
await user.type(confirmInput, 'differentpassword')
await waitFor(() => {
expect(screen.getByText('Passwords do not match')).toBeTruthy()
})
})
it('submits form and shows success', async () => {
vi.mocked(usersApi.validateInvite).mockResolvedValue({
valid: true,
email: 'invited@example.com',
})
vi.mocked(usersApi.acceptInvite).mockResolvedValue({
message: 'Success',
email: 'invited@example.com',
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
})
const user = userEvent.setup()
await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
await user.type(passwordInput, 'securepassword123')
await user.type(confirmInput, 'securepassword123')
await user.click(screen.getByRole('button', { name: 'Create Account' }))
await waitFor(() => {
expect(usersApi.acceptInvite).toHaveBeenCalledWith({
token: 'test-token',
name: 'John Doe',
password: 'securepassword123',
})
})
await waitFor(() => {
expect(screen.getByText('Account Created!')).toBeTruthy()
})
})
it('shows error on submit failure', async () => {
vi.mocked(usersApi.validateInvite).mockResolvedValue({
valid: true,
email: 'invited@example.com',
})
vi.mocked(usersApi.acceptInvite).mockRejectedValue({
response: { data: { error: 'Token has expired' } },
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
})
const user = userEvent.setup()
await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
await user.type(passwordInput, 'securepassword123')
await user.type(confirmInput, 'securepassword123')
await user.click(screen.getByRole('button', { name: 'Create Account' }))
await waitFor(() => {
expect(usersApi.acceptInvite).toHaveBeenCalled()
})
// The toast should show error but we don't need to test toast specifically
})
it('navigates to login after clicking Go to Login button', async () => {
renderWithProviders('/accept-invite')
await waitFor(() => {
expect(screen.getByText('Invalid Link')).toBeTruthy()
})
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Go to Login' }))
expect(mockNavigate).toHaveBeenCalledWith('/login')
})
})

View File

@@ -0,0 +1,209 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import SMTPSettings from '../SMTPSettings'
import * as smtpApi from '../../api/smtp'
// Mock API
vi.mock('../../api/smtp', () => ({
getSMTPConfig: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConnection: vi.fn(),
sendTestEmail: vi.fn(),
}))
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactNode) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
}
describe('SMTPSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders loading state initially', () => {
vi.mocked(smtpApi.getSMTPConfig).mockReturnValue(new Promise(() => {}))
renderWithProviders(<SMTPSettings />)
// Should show loading spinner
expect(document.querySelector('.animate-spin')).toBeTruthy()
})
it('renders SMTP form with existing config', async () => {
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: '********',
from_address: 'noreply@example.com',
encryption: 'starttls',
configured: true,
})
renderWithProviders(<SMTPSettings />)
// Wait for the form to populate with data
await waitFor(() => {
const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
return hostInput.value === 'smtp.example.com'
})
const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
expect(hostInput.value).toBe('smtp.example.com')
const portInput = screen.getByPlaceholderText('587') as HTMLInputElement
expect(portInput.value).toBe('587')
expect(screen.getByText('SMTP Configured')).toBeTruthy()
})
it('shows not configured state when SMTP is not set up', async () => {
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
host: '',
port: 587,
username: '',
password: '',
from_address: '',
encryption: 'starttls',
configured: false,
})
renderWithProviders(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('SMTP Not Configured')).toBeTruthy()
})
})
it('saves SMTP settings successfully', async () => {
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
host: '',
port: 587,
username: '',
password: '',
from_address: '',
encryption: 'starttls',
configured: false,
})
vi.mocked(smtpApi.updateSMTPConfig).mockResolvedValue({
message: 'SMTP configuration saved successfully',
})
renderWithProviders(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeTruthy()
})
const user = userEvent.setup()
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.gmail.com')
await user.type(
screen.getByPlaceholderText('Charon <no-reply@example.com>'),
'test@example.com'
)
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
await waitFor(() => {
expect(smtpApi.updateSMTPConfig).toHaveBeenCalled()
})
})
it('tests SMTP connection', async () => {
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: '********',
from_address: 'noreply@example.com',
encryption: 'starttls',
configured: true,
})
vi.mocked(smtpApi.testSMTPConnection).mockResolvedValue({
success: true,
message: 'Connection successful',
})
renderWithProviders(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Test Connection')).toBeTruthy()
})
const user = userEvent.setup()
await user.click(screen.getByText('Test Connection'))
await waitFor(() => {
expect(smtpApi.testSMTPConnection).toHaveBeenCalled()
})
})
it('shows test email form when SMTP is configured', async () => {
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: '********',
from_address: 'noreply@example.com',
encryption: 'starttls',
configured: true,
})
renderWithProviders(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
})
expect(screen.getByPlaceholderText('recipient@example.com')).toBeTruthy()
})
it('sends test email', async () => {
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: '********',
from_address: 'noreply@example.com',
encryption: 'starttls',
configured: true,
})
vi.mocked(smtpApi.sendTestEmail).mockResolvedValue({
success: true,
message: 'Email sent',
})
renderWithProviders(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
})
const user = userEvent.setup()
await user.type(
screen.getByPlaceholderText('recipient@example.com'),
'test@test.com'
)
await user.click(screen.getByRole('button', { name: /Send Test/i }))
await waitFor(() => {
expect(smtpApi.sendTestEmail).toHaveBeenCalledWith({ to: 'test@test.com' })
})
})
})

View File

@@ -0,0 +1,281 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import UsersPage from '../UsersPage'
import * as usersApi from '../../api/users'
import * as proxyHostsApi from '../../api/proxyHosts'
// Mock APIs
vi.mock('../../api/users', () => ({
listUsers: vi.fn(),
getUser: vi.fn(),
createUser: vi.fn(),
inviteUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
updateUserPermissions: vi.fn(),
validateInvite: vi.fn(),
acceptInvite: vi.fn(),
}))
vi.mock('../../api/proxyHosts', () => ({
getProxyHosts: vi.fn(),
}))
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactNode) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
}
const mockUsers = [
{
id: 1,
uuid: '123-456',
email: 'admin@example.com',
name: 'Admin User',
role: 'admin' as const,
enabled: true,
permission_mode: 'allow_all' as const,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
id: 2,
uuid: '789-012',
email: 'user@example.com',
name: 'Regular User',
role: 'user' as const,
enabled: true,
invite_status: 'accepted' as const,
permission_mode: 'allow_all' as const,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
id: 3,
uuid: '345-678',
email: 'pending@example.com',
name: '',
role: 'user' as const,
enabled: false,
invite_status: 'pending' as const,
permission_mode: 'deny_all' as const,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
]
const mockProxyHosts = [
{
uuid: 'host-1',
name: 'Test Host',
domain_names: 'test.example.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
application: 'none' as const,
locations: [],
enabled: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
]
describe('UsersPage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts)
})
it('renders loading state initially', () => {
vi.mocked(usersApi.listUsers).mockReturnValue(new Promise(() => {}))
renderWithProviders(<UsersPage />)
expect(document.querySelector('.animate-spin')).toBeTruthy()
})
it('renders user list', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('User Management')).toBeTruthy()
})
expect(screen.getByText('Admin User')).toBeTruthy()
expect(screen.getByText('admin@example.com')).toBeTruthy()
expect(screen.getByText('Regular User')).toBeTruthy()
expect(screen.getByText('user@example.com')).toBeTruthy()
})
it('shows pending invite status', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Pending Invite')).toBeTruthy()
})
})
it('shows active status for accepted users', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
})
})
it('opens invite modal when clicking invite button', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Invite User')).toBeTruthy()
})
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Invite User/i }))
await waitFor(() => {
expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
})
})
it('shows permission mode in user list', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getAllByText('Blacklist').length).toBeGreaterThan(0)
})
expect(screen.getByText('Whitelist')).toBeTruthy()
})
it('toggles user enabled status', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'Updated' })
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Regular User')).toBeTruthy()
})
// Find the switch for the non-admin user and toggle it
const switches = screen.getAllByRole('checkbox')
// The second switch should be for the regular user (admin switch is disabled)
const userSwitch = switches.find(
(sw) => !(sw as HTMLInputElement).disabled && (sw as HTMLInputElement).checked
)
if (userSwitch) {
const user = userEvent.setup()
await user.click(userSwitch)
await waitFor(() => {
expect(usersApi.updateUser).toHaveBeenCalledWith(2, { enabled: false })
})
}
})
it('invites a new user', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(usersApi.inviteUser).mockResolvedValue({
id: 4,
uuid: 'new-user',
email: 'new@example.com',
role: 'user',
invite_token: 'test-token-123',
email_sent: false,
expires_at: '2024-01-03T00:00:00Z',
})
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Invite User')).toBeTruthy()
})
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Invite User/i }))
// Wait for modal to open - look for the modal's email input placeholder
await waitFor(() => {
expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
})
await user.type(screen.getByPlaceholderText('user@example.com'), 'new@example.com')
await user.click(screen.getByRole('button', { name: /Send Invite/i }))
await waitFor(() => {
expect(usersApi.inviteUser).toHaveBeenCalledWith({
email: 'new@example.com',
role: 'user',
permission_mode: 'allow_all',
permitted_hosts: [],
})
})
})
it('deletes a user after confirmation', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(usersApi.deleteUser).mockResolvedValue({ message: 'Deleted' })
// Mock window.confirm
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
renderWithProviders(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Regular User')).toBeTruthy()
})
// Find delete buttons (trash icons) - admin user's delete button is disabled
const deleteButtons = screen.getAllByTitle('Delete User')
// Find the first non-disabled delete button
const enabledDeleteButton = deleteButtons.find((btn) => !(btn as HTMLButtonElement).disabled)
expect(enabledDeleteButton).toBeTruthy()
const user = userEvent.setup()
await user.click(enabledDeleteButton!)
await waitFor(() => {
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete this user?')
})
await waitFor(() => {
expect(usersApi.deleteUser).toHaveBeenCalled()
})
confirmSpy.mockRestore()
})
})