diff --git a/backend/src/index.ts b/backend/src/index.ts index d01f0851..d48d2a1b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,7 +6,8 @@ import jwt from 'jsonwebtoken'; import DockerController, { globalDockerNetwork } from './services/DockerController'; import { FileSystemService } from './services/FileSystemService'; import { ComposeService } from './services/ComposeService'; -import { ConfigService } from './services/ConfigService'; +import bcrypt from 'bcrypt'; +import crypto from 'crypto'; // @ts-ignore - composerize lacks proper type definitions import composerize from 'composerize'; import si from 'systeminformation'; @@ -26,9 +27,6 @@ const execAsync = promisify(exec); const app = express(); const PORT = 3000; -// ConfigService for persistent auth storage -const configService = new ConfigService(); - // FileSystemService for stack management const fileSystemService = new FileSystemService(); @@ -76,7 +74,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): } try { - const jwtSecret = await configService.getJwtSecret(); + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) throw new Error('No JWT secret'); const decoded = jwt.verify(token, jwtSecret) as { username: string }; req.user = { username: decoded.username }; next(); @@ -91,7 +91,8 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): // Check if setup is needed app.get('/api/auth/status', async (req: Request, res: Response): Promise => { try { - const needsSetup = await configService.needsSetup(); + const settings = DatabaseService.getInstance().getGlobalSettings(); + const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret; res.json({ needsSetup }); } catch (error) { console.error('Error checking setup status:', error); @@ -102,8 +103,9 @@ app.get('/api/auth/status', async (req: Request, res: Response): Promise = // Initial setup endpoint app.post('/api/auth/setup', async (req: Request, res: Response): Promise => { try { - // Check if setup is still needed - const needsSetup = await configService.needsSetup(); + const dbSvc = DatabaseService.getInstance(); + const settings = dbSvc.getGlobalSettings(); + const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret; if (!needsSetup) { res.status(400).json({ error: 'Setup has already been completed' }); return; @@ -133,10 +135,13 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise = } // Save credentials (this also generates the JWT secret) - await configService.saveConfig(username, password); + const passwordHash = await bcrypt.hash(password, 10); + const jwtSecret = crypto.randomBytes(64).toString('hex'); + dbSvc.updateGlobalSetting('auth_username', username); + dbSvc.updateGlobalSetting('auth_password_hash', passwordHash); + dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret); // Issue JWT and log user in - const jwtSecret = await configService.getJwtSecret(); const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); res.cookie(COOKIE_NAME, token, getCookieOptions(req)); res.json({ success: true, message: 'Setup completed successfully' }); @@ -156,14 +161,20 @@ app.post('/api/auth/login', async (req: Request, res: Response): Promise = } try { - const isValid = await configService.validateCredentials(username, password); + const settings = DatabaseService.getInstance().getGlobalSettings(); + const storedUsername = settings.auth_username; + const storedHash = settings.auth_password_hash; - if (isValid) { - const jwtSecret = await configService.getJwtSecret(); - const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); - res.cookie(COOKIE_NAME, token, getCookieOptions(req)); - res.json({ success: true, message: 'Login successful' }); - return; + if (storedUsername && storedHash && username === storedUsername) { + const isValid = await bcrypt.compare(password, storedHash); + if (isValid) { + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) throw new Error('JWT secret missing from DB'); + const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); + res.cookie(COOKIE_NAME, token, getCookieOptions(req)); + res.json({ success: true, message: 'Login successful' }); + return; + } } res.status(401).json({ error: 'Invalid credentials' }); @@ -173,6 +184,43 @@ app.post('/api/auth/login', async (req: Request, res: Response): Promise = } }); +// Update password endpoint +app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise => { + try { + const { oldPassword, newPassword } = req.body; + if (!oldPassword || !newPassword) { + res.status(400).json({ error: 'Old password and new password are required' }); + return; + } + if (newPassword.length < 6) { + res.status(400).json({ error: 'New password must be at least 6 characters' }); + return; + } + + const dbSvc = DatabaseService.getInstance(); + const settings = dbSvc.getGlobalSettings(); + const storedHash = settings.auth_password_hash; + + if (!storedHash) { + res.status(400).json({ error: 'Auth not configured properly' }); + return; + } + + const isValid = await bcrypt.compare(oldPassword, storedHash); + if (!isValid) { + res.status(401).json({ error: 'Invalid old password' }); + return; + } + + const newHash = await bcrypt.hash(newPassword, 10); + dbSvc.updateGlobalSetting('auth_password_hash', newHash); + res.json({ success: true, message: 'Password updated successfully' }); + } catch (error) { + console.error('Password update error:', error); + res.status(500).json({ error: 'Failed to update password' }); + } +}); + app.post('/api/auth/logout', (req: Request, res: Response): void => { res.clearCookie(COOKIE_NAME, { httpOnly: true, @@ -221,7 +269,9 @@ server.on('upgrade', async (req, socket, head) => { } try { - const jwtSecret = await configService.getJwtSecret(); + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) throw new Error('No JWT secret'); jwt.verify(token, jwtSecret); // Check if this is a stack logs WebSocket request diff --git a/backend/src/services/ConfigService.ts b/backend/src/services/ConfigService.ts deleted file mode 100644 index 192d4510..00000000 --- a/backend/src/services/ConfigService.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { promises as fs } from 'fs'; -import path from 'path'; -import bcrypt from 'bcrypt'; -import crypto from 'crypto'; - -interface AuthConfig { - username: string; - passwordHash: string; - jwtSecret: string; -} - -export class ConfigService { - private dataDir: string; - private configPath: string; - - constructor() { - this.dataDir = process.env.DATA_DIR || '/app/data'; - this.configPath = path.join(this.dataDir, 'sencho.json'); - } - - private async ensureDataDir(): Promise { - try { - await fs.mkdir(this.dataDir, { recursive: true }); - } catch { - // Directory already exists - } - } - - async needsSetup(): Promise { - try { - const config = await this.readConfig(); - return !config || !config.username || !config.passwordHash; - } catch { - return true; - } - } - - async readConfig(): Promise { - try { - const data = await fs.readFile(this.configPath, 'utf-8'); - return JSON.parse(data); - } catch { - return null; - } - } - - async saveConfig(username: string, password: string): Promise { - await this.ensureDataDir(); - const saltRounds = 10; - const passwordHash = await bcrypt.hash(password, saltRounds); - const jwtSecret = crypto.randomBytes(64).toString('hex'); - const config: AuthConfig = { username, passwordHash, jwtSecret }; - await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf-8'); - } - - async validateCredentials(username: string, password: string): Promise { - const config = await this.readConfig(); - if (!config) return false; - - if (username !== config.username) return false; - - return await bcrypt.compare(password, config.passwordHash); - } - - async getJwtSecret(): Promise { - const config = await this.readConfig(); - if (!config || !config.jwtSecret) { - throw new Error('JWT secret not found - setup may not be complete'); - } - return config.jwtSecret; - } -} diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 006a2088..90fc1d8a 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -50,6 +50,7 @@ export class DatabaseService { // Default journal mode is safer for arbitrary Docker volume mounts than WAL this.initSchema(); + this.migrateJsonConfig(dataDir); } public static getInstance(): DatabaseService { @@ -106,6 +107,30 @@ export class DatabaseService { stmt.run('docker_janitor_gb', '5'); } + private migrateJsonConfig(dataDir: string) { + const configPath = path.join(dataDir, 'sencho.json'); + if (fs.existsSync(configPath)) { + try { + const data = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(data); + + if (config.username && config.passwordHash && config.jwtSecret) { + const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)'); + stmt.run('auth_username', config.username); + stmt.run('auth_password_hash', config.passwordHash); + stmt.run('auth_jwt_secret', config.jwtSecret); + + console.log('Successfully migrated sencho.json credentials to SQLite global_settings.'); + + // Delete the file after migrating + fs.unlinkSync(configPath); + } + } catch (err) { + console.error('Failed to migrate sencho.json:', err); + } + } + } + // --- Agents --- public getAgents(): Agent[] { diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 63e5949e..a0338e9a 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; @@ -26,7 +26,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { NotificationSettingsModal } from './NotificationSettingsModal'; +import { SettingsModal } from './SettingsModal'; import { StackAlertSheet } from './StackAlertSheet'; interface ContainerInfo { Id: string; @@ -846,26 +846,6 @@ export default function EditorLayout() { - - {/* Theme Toggle */} - {/* Main Workspace */} @@ -1157,10 +1137,12 @@ export default function EditorLayout() { onClose={() => setMaintenanceModalOpen(false)} /> - {/* Notification Settings Modal */} - setSettingsModalOpen(false)} + isDarkMode={isDarkMode} + setIsDarkMode={setIsDarkMode} /> {/* Stack Alert Sheet */} diff --git a/frontend/src/components/NotificationSettingsModal.tsx b/frontend/src/components/NotificationSettingsModal.tsx deleted file mode 100644 index 2964c7f7..00000000 --- a/frontend/src/components/NotificationSettingsModal.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Input } from '@/components/ui/input'; -import { Switch } from '@/components/ui/switch'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Slider } from '@/components/ui/slider'; -import { toast } from 'sonner'; -import { apiFetch } from '@/lib/api'; - -interface Agent { - type: 'discord' | 'slack' | 'webhook'; - url: string; - enabled: boolean; -} - -interface NotificationSettingsModalProps { - isOpen: boolean; - onClose: () => void; -} - -export function NotificationSettingsModal({ isOpen, onClose }: NotificationSettingsModalProps) { - const [agents, setAgents] = useState>({ - discord: { type: 'discord', url: '', enabled: false }, - slack: { type: 'slack', url: '', enabled: false }, - webhook: { type: 'webhook', url: '', enabled: false }, - }); - - const [settings, setSettings] = useState>({ - host_cpu_limit: '90', - host_ram_limit: '90', - host_disk_limit: '90', - global_crash: '1', - docker_janitor_gb: '5' - }); - - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - if (isOpen) { - fetchAgents(); - fetchSettings(); - } - }, [isOpen]); - - const fetchAgents = async () => { - try { - const res = await apiFetch('/agents'); - if (res.ok) { - const data: Agent[] = await res.json(); - const newAgents = { ...agents }; - data.forEach(a => { - newAgents[a.type] = a; - }); - setAgents(newAgents); - } - } catch (e) { - console.error('Failed to fetch agents', e); - } - }; - - const fetchSettings = async () => { - try { - const res = await apiFetch('/settings'); - if (res.ok) { - const data = await res.json(); - setSettings(prev => ({ ...prev, ...data })); - } - } catch (e) { - console.error('Failed to fetch settings', e); - } - }; - - const handleAgentChange = (type: string, field: keyof Agent, value: any) => { - setAgents(prev => ({ - ...prev, - [type]: { ...prev[type], [field]: value } - })); - }; - - const handleSettingChange = (key: string, value: string) => { - setSettings(prev => ({ ...prev, [key]: value })); - }; - - const saveAgent = async (type: string) => { - setIsLoading(true); - try { - const res = await apiFetch('/agents', { - method: 'POST', - body: JSON.stringify(agents[type]) - }); - if (res.ok) { - toast.success(`${type} settings saved successfully.`); - } else { - toast.error(`Failed to save ${type} settings.`); - } - } catch (e) { - toast.error('Network error.'); - } finally { - setIsLoading(false); - } - }; - - const testAgent = async (type: string) => { - if (!agents[type].url) { - toast.error('Please enter a webhook URL first.'); - return; - } - setIsLoading(true); - try { - const res = await apiFetch('/notifications/test', { - method: 'POST', - body: JSON.stringify({ type, url: agents[type].url }) - }); - if (res.ok) { - toast.success('Test notification sent!'); - } else { - const err = await res.json(); - toast.error(err.details || 'Test failed.'); - } - } catch (e) { - toast.error('Network error.'); - } finally { - setIsLoading(false); - } - }; - - const saveSettings = async () => { - setIsLoading(true); - try { - for (const [key, value] of Object.entries(settings)) { - await apiFetch('/settings', { - method: 'POST', - body: JSON.stringify({ key, value }) - }); - } - toast.success('Global events settings saved.'); - } catch (e) { - toast.error('Failed to save settings.'); - } finally { - setIsLoading(false); - } - }; - - const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => ( -
-
- - handleAgentChange(type, 'enabled', c)} - /> -
-
- - handleAgentChange(type, 'url', e.target.value)} - /> -
-
- - -
-
- ); - - return ( - !open && onClose()}> - - - Notification & Alert Settings - - Configure where alerts are sent and global system limits. - - - - - - Global - Discord - Slack - Webhook - - - -
-
-
- - {settings.host_cpu_limit}% -
- handleSettingChange('host_cpu_limit', v[0].toString())} - /> -
- -
-
- - {settings.host_ram_limit}% -
- handleSettingChange('host_ram_limit', v[0].toString())} - /> -
- -
-
- - {settings.host_disk_limit}% -
- handleSettingChange('host_disk_limit', v[0].toString())} - /> -
- -
- - handleSettingChange('docker_janitor_gb', e.target.value)} - /> -
- -
- - handleSettingChange('global_crash', c ? '1' : '0')} - /> -
- -
- -
-
-
- - {renderAgentTab('discord', 'Discord')} - {renderAgentTab('slack', 'Slack')} - {renderAgentTab('webhook', 'Custom Webhook')} -
-
-
- ); -} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx new file mode 100644 index 00000000..f4407cc3 --- /dev/null +++ b/frontend/src/components/SettingsModal.tsx @@ -0,0 +1,425 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Slider } from '@/components/ui/slider'; +import { toast } from 'sonner'; +import { apiFetch } from '@/lib/api'; +import { Shield, Activity, Bell, Palette, Moon, Sun } from 'lucide-react'; + +interface Agent { + type: 'discord' | 'slack' | 'webhook'; + url: string; + enabled: boolean; +} + +interface SettingsModalProps { + isOpen: boolean; + onClose: () => void; + isDarkMode: boolean; + setIsDarkMode: (mode: boolean) => void; +} + +export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) { + const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance'>('account'); + + // Auth State + const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); + + // Notifications State + const [agents, setAgents] = useState>({ + discord: { type: 'discord', url: '', enabled: false }, + slack: { type: 'slack', url: '', enabled: false }, + webhook: { type: 'webhook', url: '', enabled: false }, + }); + + // System Settings State + const [settings, setSettings] = useState>({ + host_cpu_limit: '90', + host_ram_limit: '90', + host_disk_limit: '90', + global_crash: '1', + docker_janitor_gb: '5' + }); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (isOpen) { + fetchAgents(); + fetchSettings(); + } + }, [isOpen]); + + const fetchAgents = async () => { + try { + const res = await apiFetch('/agents'); + if (res.ok) { + const data: Agent[] = await res.json(); + const newAgents = { ...agents }; + data.forEach(a => { + newAgents[a.type] = a; + }); + setAgents(newAgents); + } + } catch (e) { + console.error('Failed to fetch agents', e); + } + }; + + const fetchSettings = async () => { + try { + const res = await apiFetch('/settings'); + if (res.ok) { + const data = await res.json(); + setSettings(prev => ({ ...prev, ...data })); + } + } catch (e) { + console.error('Failed to fetch settings', e); + } + }; + + const handleAgentChange = (type: string, field: keyof Agent, value: any) => { + setAgents(prev => ({ + ...prev, + [type]: { ...prev[type], [field]: value } + })); + }; + + const handleSettingChange = (key: string, value: string) => { + setSettings(prev => ({ ...prev, [key]: value })); + }; + + const saveAgent = async (type: string) => { + setIsLoading(true); + try { + const res = await apiFetch('/agents', { + method: 'POST', + body: JSON.stringify(agents[type]) + }); + if (res.ok) { + toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved successfully.`); + } else { + toast.error(`Failed to save ${type} settings.`); + } + } catch (e) { + toast.error('Network error.'); + } finally { + setIsLoading(false); + } + }; + + const testAgent = async (type: string) => { + if (!agents[type].url) { + toast.error('Please enter a webhook URL first.'); + return; + } + setIsLoading(true); + try { + const res = await apiFetch('/notifications/test', { + method: 'POST', + body: JSON.stringify({ type, url: agents[type].url }) + }); + if (res.ok) { + toast.success('Test notification sent!'); + } else { + const err = await res.json(); + toast.error(err.details || 'Test failed.'); + } + } catch (e) { + toast.error('Network error.'); + } finally { + setIsLoading(false); + } + }; + + const saveSettings = async () => { + setIsLoading(true); + try { + for (const [key, value] of Object.entries(settings)) { + await apiFetch('/settings', { + method: 'POST', + body: JSON.stringify({ key, value }) + }); + } + toast.success('System limits & watchdog settings saved.'); + } catch (e) { + toast.error('Failed to save settings.'); + } finally { + setIsLoading(false); + } + }; + + const handlePasswordChange = async () => { + if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) { + toast.error("All fields are required"); + return; + } + if (authData.newPassword !== authData.confirmPassword) { + toast.error("New passwords do not match"); + return; + } + + setIsLoading(true); + try { + const res = await apiFetch('/auth/password', { + method: 'PUT', + body: JSON.stringify({ + oldPassword: authData.oldPassword, + newPassword: authData.newPassword + }) + }); + + if (res.ok) { + toast.success('Password updated successfully'); + setAuthData({ oldPassword: '', newPassword: '', confirmPassword: '' }); + } else { + const data = await res.json(); + toast.error(data.error || 'Failed to update password'); + } + } catch (e) { + toast.error('Network error during password change'); + } finally { + setIsLoading(false); + } + }; + + const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => ( +
+
+ + handleAgentChange(type, 'enabled', c)} + /> +
+
+ + handleAgentChange(type, 'url', e.target.value)} + /> +
+
+ + +
+
+ ); + + return ( + !open && onClose()}> + + {/* Sidebar */} +
+
Settings Hub
+ +
+ + {/* Main Content Area */} +
+ {activeSection === 'account' && ( +
+
+

Account & Security

+

Manage your credentials and authentication.

+
+
+
+ + setAuthData(prev => ({ ...prev, oldPassword: e.target.value }))} + /> +
+
+ + setAuthData(prev => ({ ...prev, newPassword: e.target.value }))} + /> +
+
+ + setAuthData(prev => ({ ...prev, confirmPassword: e.target.value }))} + /> +
+ +
+
+ )} + + {activeSection === 'system' && ( +
+
+

System Limits & Watchdog

+

Configure auto-recovery thresholds and server constraints.

+
+ +
+
+
+ + {settings.host_cpu_limit}% +
+ handleSettingChange('host_cpu_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_ram_limit}% +
+ handleSettingChange('host_ram_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_disk_limit}% +
+ handleSettingChange('host_disk_limit', v[0].toString())} + /> +
+ +
+ + handleSettingChange('docker_janitor_gb', e.target.value)} + className="max-w-[200px]" + /> +
+ +
+
+ +

Watch all containers indefinitely

+
+ handleSettingChange('global_crash', c ? '1' : '0')} + /> +
+
+ +
+ +
+
+ )} + + {activeSection === 'notifications' && ( +
+
+

Notifications & Alerts

+

Configure external integrations for crash alerts.

+
+ + + Discord + Slack + Webhook + + {renderAgentTab('discord', 'Discord')} + {renderAgentTab('slack', 'Slack')} + {renderAgentTab('webhook', 'Custom Webhook')} + +
+ )} + + {activeSection === 'appearance' && ( +
+
+

Appearance

+

Customize Sencho's visual theme.

+
+ +
+ + +
+
+ )} + +
+
+
+ ); +}