diff --git a/CHANGELOG.md b/CHANGELOG.md index 046f776b..b7e27645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **webhooks:** Custom CI/CD webhooks — create webhooks targeting specific stacks and actions (deploy, restart, stop, start, pull), trigger them from GitHub Actions, GitLab CI, or any HTTP client with HMAC-SHA256 signature authentication (Pro) * **webhooks:** Execution history tracking — last 100 executions per webhook with status, duration, and error details * **webhooks:** Webhook management UI in Settings with create/edit/delete, enable/disable toggle, one-time secret reveal, and copy-to-clipboard for trigger URLs +* **rbac:** Role-based access control with admin and viewer roles — viewers get full read-only access to dashboard, stacks, logs, and stats while admins retain full control (Pro) +* **rbac:** User management UI in Settings — create, edit, and delete users with username/password/role controls, protected by admin-only access +* **rbac:** Automatic migration of existing single-admin credentials to the new multi-user system on first boot +* **rbac:** Viewer restrictions across the entire UI — read-only editor, hidden action buttons, disabled host console, and disabled resource management +* **atomic:** Atomic deployments with automatic rollback — Sencho backs up compose.yaml and .env before deploying, and auto-restores if health probes detect crashed containers (Pro) +* **atomic:** Manual rollback button in the stack action bar — restore the previous deployment with one click when a backup exists (Pro) +* **atomic:** Health probes added to stack updates (previously only on deploys) — crashed containers trigger auto-rollback during updates too +* **atomic:** Webhook-triggered deploys and updates now use atomic rollback for Pro users +* **fleet:** Fleet-wide backups — snapshot all compose files and .env files across every node (local and remote) into a central backup stored in SQLite (Pro) +* **fleet:** Snapshot detail view — browse captured files per node and stack with inline preview in a collapsible tree +* **fleet:** Per-stack restore from any snapshot — overwrite current compose files with the snapshot version, with optional one-click redeploy (Pro admin) +* **fleet:** Graceful handling of offline nodes during snapshot creation — skipped nodes are recorded with reason and displayed as warnings ### Fixed diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index 3e491c92..c5d659b6 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -34,6 +34,9 @@ export async function setupTestDb(): Promise { db.updateGlobalSetting('auth_password_hash', passwordHash); db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET); + // Also seed the users table (RBAC login reads from here) + db.addUser({ username: TEST_USERNAME, password_hash: passwordHash, role: 'admin' }); + return tmpDir; } diff --git a/backend/src/index.ts b/backend/src/index.ts index e80fb4cc..c08ec746 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -195,7 +195,7 @@ app.use(nodeContextMiddleware); declare global { namespace Express { interface Request { - user?: { username: string }; + user?: { username: string; role: 'admin' | 'viewer' }; nodeId: number; } } @@ -228,9 +228,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): 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; scope?: string }; - // Accept both user sessions and node proxy tokens - req.user = { username: decoded.username || 'node-proxy' }; + const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string }; + // Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens. + req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' }; next(); } catch (err) { console.error('[Auth] Token validation failed:', (err as Error).message); @@ -314,8 +314,11 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response) dbSvc.updateGlobalSetting('admin_email', admin_email.trim()); } + // Create admin user in users table + dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' }); + // Issue JWT and log user in - const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); + const token = jwt.sign({ username, role: 'admin' }, jwtSecret, { expiresIn: '24h' }); res.cookie(COOKIE_NAME, token, getCookieOptions(req)); res.json({ success: true, message: 'Setup completed successfully' }); } catch (error) { @@ -334,16 +337,16 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response) } try { - const settings = DatabaseService.getInstance().getGlobalSettings(); - const storedUsername = settings.auth_username; - const storedHash = settings.auth_password_hash; + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername(username); - if (storedUsername && storedHash && username === storedUsername) { - const isValid = await bcrypt.compare(password, storedHash); + if (user) { + const isValid = await bcrypt.compare(password, user.password_hash); if (isValid) { + const settings = db.getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('JWT secret missing from DB'); - const token = jwt.sign({ username }, jwtSecret, { expiresIn: '24h' }); + const token = jwt.sign({ username: user.username, role: user.role }, jwtSecret, { expiresIn: '24h' }); res.cookie(COOKIE_NAME, token, getCookieOptions(req)); res.json({ success: true, message: 'Login successful' }); return; @@ -357,7 +360,7 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response) } }); -// Update password endpoint +// Update password endpoint — any authenticated user can change their own password app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise => { try { const { oldPassword, newPassword } = req.body; @@ -371,21 +374,22 @@ app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response } const dbSvc = DatabaseService.getInstance(); - const settings = dbSvc.getGlobalSettings(); - const storedHash = settings.auth_password_hash; + const user = dbSvc.getUserByUsername(req.user!.username); - if (!storedHash) { - res.status(400).json({ error: 'Auth not configured properly' }); + if (!user) { + res.status(400).json({ error: 'User not found' }); return; } - const isValid = await bcrypt.compare(oldPassword, storedHash); + const isValid = await bcrypt.compare(oldPassword, user.password_hash); if (!isValid) { res.status(401).json({ error: 'Invalid old password' }); return; } const newHash = await bcrypt.hash(newPassword, 10); + dbSvc.updateUser(user.id, { password_hash: newHash }); + // Keep global_settings in sync for backward compat dbSvc.updateGlobalSetting('auth_password_hash', newHash); res.json({ success: true, message: 'Password updated successfully' }); } catch (error) { @@ -410,6 +414,7 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void = // Generate a long-lived node proxy token for Sencho-to-Sencho authentication app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; try { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; @@ -445,6 +450,14 @@ const requirePro = (_req: Request, res: Response): boolean => { return true; }; +const requireAdmin = (req: Request, res: Response): boolean => { + if (req.user?.role !== 'admin') { + res.status(403).json({ error: 'Admin access required.', code: 'ADMIN_REQUIRED' }); + return false; + } + return true; +}; + app.get('/api/license', (_req: Request, res: Response): void => { try { const info = LicenseService.getInstance().getLicenseInfo(); @@ -456,6 +469,7 @@ app.get('/api/license', (_req: Request, res: Response): void => { }); app.post('/api/license/activate', async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; try { const { license_key } = req.body; if (!license_key || typeof license_key !== 'string') { @@ -475,6 +489,7 @@ app.post('/api/license/activate', async (req: Request, res: Response): Promise => { + if (!requireAdmin(_req, res)) return; try { const result = await LicenseService.getInstance().deactivate(); if (result.success) { @@ -751,6 +766,354 @@ async function fetchRemoteNodeOverview(node: Node): Promise { } } +// ─── Fleet Snapshots (Pro) ─── + +interface SnapshotNodeData { + nodeId: number; + nodeName: string; + stacks: Array<{ + stackName: string; + files: Array<{ filename: string; content: string }>; + }>; +} + +async function captureLocalNodeFiles(node: Node): Promise { + const fsService = FileSystemService.getInstance(node.id); + const stackNames = await fsService.getStacks(); + const stacks: SnapshotNodeData['stacks'] = []; + + for (const stackName of stackNames) { + const files: Array<{ filename: string; content: string }> = []; + try { + const composeContent = await fsService.getStackContent(stackName); + files.push({ filename: 'compose.yaml', content: composeContent }); + } catch { + // Stack has no compose file — skip + continue; + } + try { + const envContent = await fsService.getEnvContent(stackName); + files.push({ filename: '.env', content: envContent }); + } catch { + // No .env file — that's fine + } + stacks.push({ stackName, files }); + } + + return { nodeId: node.id, nodeName: node.name, stacks }; +} + +async function captureRemoteNodeFiles(node: Node): Promise { + if (!node.api_url || !node.api_token) { + throw new Error('Remote node not configured'); + } + + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${node.api_token}` }; + + const stacksRes = await fetch(`${baseUrl}/api/stacks`, { + headers, + signal: AbortSignal.timeout(15000), + }); + if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node'); + const stackNames = await stacksRes.json() as string[]; + + const stacks: SnapshotNodeData['stacks'] = []; + + for (const stackName of stackNames) { + const files: Array<{ filename: string; content: string }> = []; + try { + const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { + headers, + signal: AbortSignal.timeout(15000), + }); + if (composeRes.ok) { + const content = await composeRes.text(); + files.push({ filename: 'compose.yaml', content }); + } + } catch { + continue; + } + try { + const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { + headers, + signal: AbortSignal.timeout(15000), + }); + if (envRes.ok) { + const content = await envRes.text(); + files.push({ filename: '.env', content }); + } + } catch { + // No .env — skip + } + if (files.length > 0) { + stacks.push({ stackName, files }); + } + } + + return { nodeId: node.id, nodeName: node.name, stacks }; +} + +// Create fleet snapshot +app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requirePro(req, res)) return; + + try { + const { description = '' } = req.body; + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + const username = req.user?.username || 'admin'; + + const results = await Promise.allSettled( + nodes.map(async (node) => { + if (node.type === 'remote') { + return captureRemoteNodeFiles(node); + } + return captureLocalNodeFiles(node); + }) + ); + + const capturedNodes: SnapshotNodeData[] = []; + const skippedNodes: Array<{ nodeId: number; nodeName: string; reason: string }> = []; + + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + capturedNodes.push(result.value); + } else { + console.error(`[Fleet Snapshot] Failed to capture node ${nodes[i].name}:`, result.reason); + skippedNodes.push({ + nodeId: nodes[i].id, + nodeName: nodes[i].name, + reason: result.reason instanceof Error ? result.reason.message : 'Unknown error', + }); + } + }); + + let totalStacks = 0; + const allFiles: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }> = []; + + for (const nodeData of capturedNodes) { + totalStacks += nodeData.stacks.length; + for (const stack of nodeData.stacks) { + for (const file of stack.files) { + allFiles.push({ + nodeId: nodeData.nodeId, + nodeName: nodeData.nodeName, + stackName: stack.stackName, + filename: file.filename, + content: file.content, + }); + } + } + } + + const snapshotId = db.createSnapshot( + description, + username, + capturedNodes.length, + totalStacks, + JSON.stringify(skippedNodes), + ); + + if (allFiles.length > 0) { + db.insertSnapshotFiles(snapshotId, allFiles); + } + + const snapshot = db.getSnapshot(snapshotId); + res.status(201).json(snapshot); + } catch (error) { + console.error('[Fleet Snapshot] Create error:', error); + res.status(500).json({ error: 'Failed to create fleet snapshot' }); + } +}); + +// List fleet snapshots +app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + + try { + const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100); + const offset = parseInt(req.query.offset as string, 10) || 0; + const db = DatabaseService.getInstance(); + const snapshots = db.getSnapshots(limit, offset); + const total = db.getSnapshotCount(); + res.json({ snapshots, total }); + } catch (error) { + console.error('[Fleet Snapshot] List error:', error); + res.status(500).json({ error: 'Failed to list fleet snapshots' }); + } +}); + +// Get snapshot detail +app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + + try { + const id = parseInt(req.params.id as string, 10); + const db = DatabaseService.getInstance(); + const snapshot = db.getSnapshot(id); + if (!snapshot) { + res.status(404).json({ error: 'Snapshot not found' }); + return; + } + + const files = db.getSnapshotFiles(id); + + // Group files by node and stack + const nodesMap = new Map> }>(); + for (const file of files) { + if (!nodesMap.has(file.node_id)) { + nodesMap.set(file.node_id, { nodeId: file.node_id, nodeName: file.node_name, stacks: new Map() }); + } + const nodeEntry = nodesMap.get(file.node_id)!; + if (!nodeEntry.stacks.has(file.stack_name)) { + nodeEntry.stacks.set(file.stack_name, []); + } + nodeEntry.stacks.get(file.stack_name)!.push({ filename: file.filename, content: file.content }); + } + + const nodes = Array.from(nodesMap.values()).map(n => ({ + nodeId: n.nodeId, + nodeName: n.nodeName, + stacks: Array.from(n.stacks.entries()).map(([stackName, stackFiles]) => ({ + stackName, + files: stackFiles, + })), + })); + + res.json({ ...snapshot, nodes }); + } catch (error) { + console.error('[Fleet Snapshot] Detail error:', error); + res.status(500).json({ error: 'Failed to fetch snapshot details' }); + } +}); + +// Restore a stack from snapshot +app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requirePro(req, res)) return; + + try { + const snapshotId = parseInt(req.params.id as string, 10); + const { nodeId, stackName, redeploy = false } = req.body; + + if (!nodeId || !stackName) { + res.status(400).json({ error: 'nodeId and stackName are required' }); + return; + } + + const db = DatabaseService.getInstance(); + const snapshot = db.getSnapshot(snapshotId); + if (!snapshot) { + res.status(404).json({ error: 'Snapshot not found' }); + return; + } + + const files = db.getSnapshotStackFiles(snapshotId, nodeId, stackName); + if (files.length === 0) { + res.status(404).json({ error: 'No files found for this stack in the snapshot' }); + return; + } + + const node = db.getNode(nodeId); + if (!node) { + res.status(404).json({ error: 'Target node no longer exists' }); + return; + } + + if (node.type === 'local') { + const fsService = FileSystemService.getInstance(node.id); + + // Backup current files before restore + try { + await fsService.backupStackFiles(stackName); + } catch { + // Stack may not exist yet — that's ok + } + + for (const file of files) { + if (file.filename === 'compose.yaml') { + await fsService.saveStackContent(stackName, file.content); + } else if (file.filename === '.env') { + await fsService.saveEnvContent(stackName, file.content); + } + } + + if (redeploy) { + const composeService = ComposeService.getInstance(node.id); + await composeService.deployStack(stackName); + } + } else { + // Remote node + if (!node.api_url || !node.api_token) { + res.status(503).json({ error: 'Remote node not configured' }); + return; + } + + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers: Record = { + Authorization: `Bearer ${node.api_token}`, + 'Content-Type': 'application/json', + }; + + for (const file of files) { + if (file.filename === 'compose.yaml') { + const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, { + method: 'PUT', + headers, + body: JSON.stringify({ content: file.content }), + signal: AbortSignal.timeout(15000), + }); + if (!putRes.ok) throw new Error('Failed to restore compose file on remote node'); + } else if (file.filename === '.env') { + const putRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, { + method: 'PUT', + headers, + body: JSON.stringify({ content: file.content }), + signal: AbortSignal.timeout(15000), + }); + if (!putRes.ok) throw new Error('Failed to restore env file on remote node'); + } + } + + if (redeploy) { + await fetch(`${baseUrl}/api/compose/${encodeURIComponent(stackName)}/up`, { + method: 'POST', + headers, + signal: AbortSignal.timeout(30000), + }); + } + } + + res.json({ message: 'Stack restored successfully', redeployed: redeploy }); + } catch (error) { + console.error('[Fleet Snapshot] Restore error:', error); + res.status(500).json({ error: 'Failed to restore stack from snapshot' }); + } +}); + +// Delete snapshot +app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requirePro(req, res)) return; + + try { + const id = parseInt(req.params.id as string, 10); + const db = DatabaseService.getInstance(); + const snapshot = db.getSnapshot(id); + if (!snapshot) { + res.status(404).json({ error: 'Snapshot not found' }); + return; + } + db.deleteSnapshot(id); + res.json({ message: 'Snapshot deleted' }); + } catch (error) { + console.error('[Fleet Snapshot] Delete error:', error); + res.status(500).json({ error: 'Failed to delete snapshot' }); + } +}); + // ─── Webhooks (Pro) ─── CRUD requires auth + Pro, trigger is public with HMAC ─── // Webhook CRUD (auth + Pro required) @@ -767,6 +1130,7 @@ app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): P }); app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; if (!requirePro(req, res)) return; try { const { name, stack_name, action, enabled } = req.body; @@ -795,6 +1159,7 @@ app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): P }); app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; if (!requirePro(req, res)) return; try { const id = parseInt(req.params.id as string, 10); @@ -817,6 +1182,7 @@ app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response) }); app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; if (!requirePro(req, res)) return; try { const id = parseInt(req.params.id as string, 10); @@ -879,7 +1245,8 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi // Execute asynchronously — return 202 immediately res.status(202).json({ message: 'Webhook accepted', action }); - svc.execute(id, action, triggerSource).catch(err => { + const atomic = LicenseService.getInstance().getTier() === 'pro'; + svc.execute(id, action, triggerSource, atomic).catch(err => { console.error(`[Webhooks] Execution error for webhook ${id}:`, err); }); } catch (error) { @@ -888,6 +1255,150 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi } }); +// --- User Management (local-only, admin + Pro gated for creation) --- + +app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const users = DatabaseService.getInstance().getUsers(); + res.json(users); + } catch (error) { + console.error('[Users] List error:', error); + res.status(500).json({ error: 'Failed to fetch users' }); + } +}); + +app.post('/api/users', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requirePro(req, res)) return; + try { + const { username, password, role } = req.body; + + if (!username || !password || !role) { + res.status(400).json({ error: 'Username, password, and role are required' }); + return; + } + if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) { + res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' }); + return; + } + if (typeof password !== 'string' || password.length < 6) { + res.status(400).json({ error: 'Password must be at least 6 characters' }); + return; + } + if (role !== 'admin' && role !== 'viewer') { + res.status(400).json({ error: 'Role must be "admin" or "viewer"' }); + return; + } + + const db = DatabaseService.getInstance(); + const existing = db.getUserByUsername(username); + if (existing) { + res.status(409).json({ error: 'A user with this username already exists' }); + return; + } + + const passwordHash = await bcrypt.hash(password, 10); + const id = db.addUser({ username, password_hash: passwordHash, role }); + res.status(201).json({ id, username, role }); + } catch (error) { + console.error('[Users] Create error:', error); + res.status(500).json({ error: 'Failed to create user' }); + } +}); + +app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + const db = DatabaseService.getInstance(); + const user = db.getUser(id); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + const { username, password, role } = req.body; + const updates: Partial<{ username: string; password_hash: string; role: string }> = {}; + + if (username !== undefined) { + if (typeof username !== 'string' || username.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(username)) { + res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' }); + return; + } + const existing = db.getUserByUsername(username); + if (existing && existing.id !== id) { + res.status(409).json({ error: 'A user with this username already exists' }); + return; + } + updates.username = username; + } + + if (role !== undefined) { + if (role !== 'admin' && role !== 'viewer') { + res.status(400).json({ error: 'Role must be "admin" or "viewer"' }); + return; + } + // Prevent demoting yourself + if (user.username === req.user!.username && role !== user.role) { + res.status(400).json({ error: 'Cannot change your own role' }); + return; + } + // Prevent removing the last admin + if (user.role === 'admin' && role === 'viewer' && db.getAdminCount() <= 1) { + res.status(400).json({ error: 'Cannot demote the only admin user' }); + return; + } + updates.role = role; + } + + if (password !== undefined) { + if (typeof password !== 'string' || password.length < 6) { + res.status(400).json({ error: 'Password must be at least 6 characters' }); + return; + } + updates.password_hash = await bcrypt.hash(password, 10); + } + + db.updateUser(id, updates); + res.json({ success: true }); + } catch (error) { + console.error('[Users] Update error:', error); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + const db = DatabaseService.getInstance(); + const user = db.getUser(id); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + // Cannot delete yourself + if (user.username === req.user!.username) { + res.status(400).json({ error: 'Cannot delete your own account' }); + return; + } + + // Cannot delete the last admin + if (user.role === 'admin' && db.getAdminCount() <= 1) { + res.status(400).json({ error: 'Cannot delete the only admin user' }); + return; + } + + db.deleteUser(id); + res.json({ success: true }); + } catch (error) { + console.error('[Users] Delete error:', error); + res.status(500).json({ error: 'Failed to delete user' }); + } +}); + // Remote Node HTTP Proxy - single global instance. // Previously, createProxyMiddleware was called inside the request handler on every API // call, spawning a new proxy instance (and http-proxy server) each time. This caused: @@ -1264,6 +1775,7 @@ app.get('/api/stacks/:stackName', async (req: Request, res: Response) => { }); app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { @@ -1409,6 +1921,7 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { }); app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { @@ -1442,6 +1955,7 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { }); app.post('/api/stacks', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { stackName } = req.body; if (!stackName || typeof stackName !== 'string') { @@ -1462,6 +1976,7 @@ app.post('/api/stacks', async (req: Request, res: Response) => { }); app.delete('/api/stacks/:name', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; const stackName = req.params.name as string; try { // Stage 1: Tell Docker to clean up ghost networks/containers @@ -1503,6 +2018,7 @@ app.get('/api/containers/:id/logs', async (req: Request, res: Response) => { }); app.post('/api/containers/:id/start', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); @@ -1514,6 +2030,7 @@ app.post('/api/containers/:id/start', async (req: Request, res: Response) => { }); app.post('/api/containers/:id/stop', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); @@ -1525,6 +2042,7 @@ app.post('/api/containers/:id/stop', async (req: Request, res: Response) => { }); app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); @@ -1537,17 +2055,21 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { // End of legacy container routes app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; - await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined); + const atomic = LicenseService.getInstance().getTier() === 'pro'; + await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); res.json({ message: 'Deployed successfully' }); } catch (error: any) { console.error('Failed to deploy stack:', error); - res.status(500).json({ error: error.message || 'Failed to deploy stack' }); + const rolledBack = LicenseService.getInstance().getTier() === 'pro'; + res.status(500).json({ error: error.message || 'Failed to deploy stack', rolledBack }); } }); app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined); @@ -1558,6 +2080,7 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { }); app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(req.nodeId); @@ -1576,6 +2099,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) = }); app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(req.nodeId); @@ -1594,6 +2118,7 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { }); app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; const dockerController = DockerController.getInstance(req.nodeId); @@ -1613,13 +2138,49 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => // Update stack: pull images and recreate containers app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const stackName = req.params.stackName as string; - // Await update completion - await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined); + const atomic = LicenseService.getInstance().getTier() === 'pro'; + await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic); res.json({ status: 'Update completed' }); } catch (error) { - res.status(500).json({ error: 'Failed to update' }); + const rolledBack = LicenseService.getInstance().getTier() === 'pro'; + res.status(500).json({ error: 'Failed to update', rolledBack }); + } +}); + +// Manual rollback endpoint (Pro + Admin) +app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + if (!requirePro(req, res)) return; + try { + const stackName = req.params.stackName as string; + const fsSvc = FileSystemService.getInstance(req.nodeId); + const backupInfo = await fsSvc.getBackupInfo(stackName); + if (!backupInfo.exists) { + return res.status(404).json({ error: 'No backup available for this stack.' }); + } + await fsSvc.restoreStackFiles(stackName); + // Re-deploy with restored files (non-atomic to avoid loops) + await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, false); + res.json({ message: 'Stack rolled back successfully.' }); + } catch (error: any) { + console.error('Rollback failed:', error); + res.status(500).json({ error: error.message || 'Rollback failed.' }); + } +}); + +// Backup info endpoint (read-only) +app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => { + try { + const stackName = req.params.stackName as string; + const fsSvc = FileSystemService.getInstance(req.nodeId); + const info = await fsSvc.getBackupInfo(stackName); + res.json(info); + } catch (error: any) { + console.error('Failed to get backup info:', error); + res.status(500).json({ error: error.message || 'Failed to get backup info.' }); } }); @@ -1925,6 +2486,7 @@ app.get('/api/agents', async (req: Request, res: Response) => { }); app.post('/api/agents', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const agent = req.body; DatabaseService.getInstance().upsertAgent(agent); @@ -1981,6 +2543,7 @@ app.get('/api/settings', async (req: Request, res: Response) => { }); app.post('/api/settings', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { key, value } = req.body; if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) { @@ -2000,6 +2563,7 @@ app.post('/api/settings', async (req: Request, res: Response) => { }); app.patch('/api/settings', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const parsed = SettingsPatchSchema.safeParse(req.body); if (!parsed.success) { @@ -2042,6 +2606,7 @@ const AlertCreateSchema = z.object({ }); app.post('/api/alerts', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; const parsed = AlertCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors }); @@ -2057,6 +2622,7 @@ app.post('/api/alerts', async (req: Request, res: Response) => { }); app.delete('/api/alerts/:id', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const id = parseInt(req.params.id as string, 10); DatabaseService.getInstance().deleteStackAlert(id); @@ -2104,6 +2670,7 @@ app.delete('/api/notifications', authMiddleware, async (req: Request, res: Respo }); app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { type, url } = req.body; await NotificationService.getInstance().testDispatch(type, url); @@ -2119,6 +2686,7 @@ app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Re // to receive a short-lived token. The remote's WS upgrade handler allows 'console_session' // tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths. app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; try { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; @@ -2149,6 +2717,7 @@ app.get('/api/system/orphans', async (req: Request, res: Response) => { }); app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { containerIds } = req.body; if (!Array.isArray(containerIds)) { @@ -2164,6 +2733,7 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { }); app.post('/api/system/prune/system', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { target, scope } = req.body as { target: string; scope?: string }; if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { @@ -2249,6 +2819,7 @@ app.get('/api/system/networks', async (req: Request, res: Response) => { }); app.post('/api/system/images/delete', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); @@ -2262,6 +2833,7 @@ app.post('/api/system/images/delete', async (req: Request, res: Response) => { }); app.post('/api/system/volumes/delete', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); @@ -2275,6 +2847,7 @@ app.post('/api/system/volumes/delete', async (req: Request, res: Response) => { }); app.post('/api/system/networks/delete', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); @@ -2299,11 +2872,13 @@ app.get('/api/templates', async (req: Request, res: Response) => { }); app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; templateService.clearCache(); res.json({ success: true }); }); app.post('/api/templates/deploy', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { stackName, template, envVars } = req.body; @@ -2336,7 +2911,8 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { // 4. Deploy the stack with atomic rollback try { - await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined); + const atomic = LicenseService.getInstance().getTier() === 'pro'; + await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); res.json({ success: true, message: 'Template deployed successfully' }); } catch (deployError: any) { const rawError = deployError.message || String(deployError); @@ -2387,6 +2963,7 @@ app.get('/api/image-updates', authMiddleware, (_req: Request, res: Response) => }); app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Response) => { + if (!requireAdmin(_req, res)) return; try { const triggered = ImageUpdateService.getInstance().triggerManualRefresh(); if (!triggered) { @@ -2431,6 +3008,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => { // Create a new node app.post('/api/nodes', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const { name, type, compose_dir, is_default, api_url, api_token } = req.body; @@ -2471,6 +3049,7 @@ app.post('/api/nodes', async (req: Request, res: Response) => { // Update a node app.put('/api/nodes/:id', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const id = parseInt(req.params.id as string); const updates = req.body; @@ -2496,6 +3075,7 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => { // Delete a node app.delete('/api/nodes/:id', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; try { const id = parseInt(req.params.id as string); DatabaseService.getInstance().deleteNode(id); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 76f07ea8..877fa4ed 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import path from 'path'; import WebSocket from 'ws'; import DockerController from './DockerController'; +import { FileSystemService } from './FileSystemService'; import { LogFormatter } from './LogFormatter'; import { NodeRegistry } from './NodeRegistry'; @@ -77,43 +78,74 @@ export class ComposeService { await this.execute('docker', ['compose', action], stackDir, ws); } - async deployStack(stackName: string, ws?: WebSocket): Promise { + async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise { const stackDir = path.join(this.baseDir, stackName); + const sendOutput = (data: string) => { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); + }; - try { - const dockerController = DockerController.getInstance(this.nodeId); - const legacyContainers = await dockerController.getContainersByStack(stackName); - if (legacyContainers && legacyContainers.length > 0) { - if (ws && ws.readyState === WebSocket.OPEN) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`); - await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id)); + // Atomic: backup files before deploying + if (atomic) { + try { + const fsSvc = FileSystemService.getInstance(this.nodeId); + await fsSvc.backupStackFiles(stackName); + sendOutput('=== Backup created for atomic deployment ===\n'); + } catch (e) { + console.warn(`Failed to backup stack files for ${stackName}:`, e); } - } catch (e) { - console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); } - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + try { + try { + const dockerController = DockerController.getInstance(this.nodeId); + const legacyContainers = await dockerController.getContainersByStack(stackName); + if (legacyContainers && legacyContainers.length > 0) { + sendOutput(`=== Cleaning up existing containers for clean deployment ===\n`); + await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id)); + } + } catch (e) { + console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); + } - // Post-Deploy Health Probe - await new Promise(resolve => setTimeout(resolve, 3000)); + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); - const dockerController = DockerController.getInstance(this.nodeId); - const containers = await dockerController.getDocker().listContainers({ - all: true, - filters: { label: [`com.docker.compose.project=${stackName}`] } - }); + // Post-Deploy Health Probe + await new Promise(resolve => setTimeout(resolve, 3000)); - for (const containerInfo of containers) { - if (containerInfo.State === 'exited') { - const container = dockerController.getDocker().getContainer(containerInfo.Id); - const inspectData = await container.inspect(); - const exitCode = inspectData.State.ExitCode; + const dockerController = DockerController.getInstance(this.nodeId); + const containers = await dockerController.getDocker().listContainers({ + all: true, + filters: { label: [`com.docker.compose.project=${stackName}`] } + }); - if (exitCode !== 0) { - const logs = await container.logs({ stdout: true, stderr: true, tail: 50 }); - const logStr = logs.toString('utf-8'); - throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`); + for (const containerInfo of containers) { + if (containerInfo.State === 'exited') { + const container = dockerController.getDocker().getContainer(containerInfo.Id); + const inspectData = await container.inspect(); + const exitCode = inspectData.State.ExitCode; + + if (exitCode !== 0) { + const logs = await container.logs({ stdout: true, stderr: true, tail: 50 }); + const logStr = logs.toString('utf-8'); + throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`); + } } } + } catch (deployError) { + // Atomic: auto-rollback on failure + if (atomic) { + sendOutput('\n=== Deployment failed — rolling back to previous version ===\n'); + try { + const fsSvc = FileSystemService.getInstance(this.nodeId); + await fsSvc.restoreStackFiles(stackName); + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + sendOutput('=== Rolled back successfully ===\n'); + } catch (rollbackError) { + console.error(`Rollback failed for ${stackName}:`, rollbackError); + sendOutput('=== Rollback failed — manual intervention may be required ===\n'); + } + } + throw deployError; } } @@ -228,29 +260,81 @@ export class ComposeService { startStream(); } - async updateStack(stackName: string, ws?: WebSocket): Promise { + async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise { const stackDir = path.join(this.baseDir, stackName); const sendOutput = (data: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }; - try { - const dockerController = DockerController.getInstance(this.nodeId); - const legacyContainers = await dockerController.getContainersByStack(stackName); - if (legacyContainers && legacyContainers.length > 0) { - sendOutput(`=== Cleaning up existing containers for clean update ===\n`); - await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id)); + // Atomic: backup files before updating + if (atomic) { + try { + const fsSvc = FileSystemService.getInstance(this.nodeId); + await fsSvc.backupStackFiles(stackName); + sendOutput('=== Backup created for atomic update ===\n'); + } catch (e) { + console.warn(`Failed to backup stack files for ${stackName}:`, e); } - } catch (e) { - console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); } - sendOutput('=== Pulling latest images ===\n'); - await this.execute('docker', ['compose', 'pull'], stackDir, ws); + try { + try { + const dockerController = DockerController.getInstance(this.nodeId); + const legacyContainers = await dockerController.getContainersByStack(stackName); + if (legacyContainers && legacyContainers.length > 0) { + sendOutput(`=== Cleaning up existing containers for clean update ===\n`); + await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id)); + } + } catch (e) { + console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); + } - sendOutput('=== Recreating containers ===\n'); - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); - sendOutput('=== Stack updated successfully ===\n'); + sendOutput('=== Pulling latest images ===\n'); + await this.execute('docker', ['compose', 'pull'], stackDir, ws); + + sendOutput('=== Recreating containers ===\n'); + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + + // Post-Update Health Probe + await new Promise(resolve => setTimeout(resolve, 3000)); + + const dockerController = DockerController.getInstance(this.nodeId); + const containers = await dockerController.getDocker().listContainers({ + all: true, + filters: { label: [`com.docker.compose.project=${stackName}`] } + }); + + for (const containerInfo of containers) { + if (containerInfo.State === 'exited') { + const container = dockerController.getDocker().getContainer(containerInfo.Id); + const inspectData = await container.inspect(); + const exitCode = inspectData.State.ExitCode; + + if (exitCode !== 0) { + const logs = await container.logs({ stdout: true, stderr: true, tail: 50 }); + const logStr = logs.toString('utf-8'); + throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`); + } + } + } + + sendOutput('=== Stack updated successfully ===\n'); + } catch (updateError) { + // Atomic: auto-rollback on failure + if (atomic) { + sendOutput('\n=== Update failed — rolling back to previous version ===\n'); + try { + const fsSvc = FileSystemService.getInstance(this.nodeId); + await fsSvc.restoreStackFiles(stackName); + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + sendOutput('=== Rolled back successfully ===\n'); + } catch (rollbackError) { + console.error(`Rollback failed for ${stackName}:`, rollbackError); + sendOutput('=== Rollback failed — manual intervention may be required ===\n'); + } + } + throw updateError; + } } public async downStack(stackName: string): Promise { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index d54cef46..8d899216 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -59,6 +59,15 @@ export interface WebhookExecution { executed_at: number; } +export interface User { + id: number; + username: string; + password_hash: string; + role: 'admin' | 'viewer'; + created_at: number; + updated_at: number; +} + export interface NotificationHistory { id?: number; level: 'info' | 'warning' | 'error'; @@ -67,6 +76,26 @@ export interface NotificationHistory { is_read: boolean; } +export interface FleetSnapshot { + id: number; + description: string; + created_by: string; + node_count: number; + stack_count: number; + skipped_nodes: string; + created_at: number; +} + +export interface FleetSnapshotFile { + id: number; + snapshot_id: number; + node_id: number; + node_name: string; + stack_name: string; + filename: string; + content: string; +} + export class DatabaseService { private static instance: DatabaseService; private db: Database.Database; @@ -83,6 +112,7 @@ export class DatabaseService { this.initSchema(); this.migrateJsonConfig(dataDir); + this.migrateAdminToUsersTable(); } public static getInstance(): DatabaseService { @@ -188,6 +218,38 @@ export class DatabaseService { ); CREATE INDEX IF NOT EXISTS idx_webhook_executions_webhook ON webhook_executions(webhook_id); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS fleet_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + description TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL, + node_count INTEGER NOT NULL, + stack_count INTEGER NOT NULL, + skipped_nodes TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS fleet_snapshot_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + node_id INTEGER NOT NULL, + node_name TEXT NOT NULL, + stack_name TEXT NOT NULL, + filename TEXT NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY(snapshot_id) REFERENCES fleet_snapshots(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_snapshot_files_snapshot ON fleet_snapshot_files(snapshot_id); `); // Apply migrations safely (ignore if columns already exist) @@ -231,6 +293,22 @@ export class DatabaseService { } } + private migrateAdminToUsersTable(): void { + const userCount = (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0; + if (userCount > 0) return; + + const settings = this.getGlobalSettings(); + const username = settings.auth_username; + const passwordHash = settings.auth_password_hash; + if (!username || !passwordHash) return; + + const now = Date.now(); + this.db.prepare( + 'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)' + ).run(username, passwordHash, 'admin', now, now); + console.log(`Migrated admin user "${username}" to users table.`); + } + private migrateJsonConfig(dataDir: string) { const configPath = path.join(dataDir, 'sencho.json'); if (fs.existsSync(configPath)) { @@ -593,4 +671,105 @@ export class DatabaseService { return result.lastInsertRowid as number; } + + // --- Users --- + + public getUsers(): Omit[] { + return this.db.prepare('SELECT id, username, role, created_at, updated_at FROM users ORDER BY created_at ASC').all() as Omit[]; + } + + public getUser(id: number): User | undefined { + return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined; + } + + public getUserByUsername(username: string): User | undefined { + return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined; + } + + public addUser(user: { username: string; password_hash: string; role: 'admin' | 'viewer' }): number { + const now = Date.now(); + const result = this.db.prepare( + 'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)' + ).run(user.username, user.password_hash, user.role, now, now); + return result.lastInsertRowid as number; + } + + public updateUser(id: number, updates: Partial<{ username: string; password_hash: string; role: string }>): void { + const fields: string[] = []; + const values: (string | number)[] = []; + + if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); } + if (updates.password_hash !== undefined) { fields.push('password_hash = ?'); values.push(updates.password_hash); } + if (updates.role !== undefined) { fields.push('role = ?'); values.push(updates.role); } + + if (fields.length === 0) return; + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + this.db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); + } + + public deleteUser(id: number): void { + this.db.prepare('DELETE FROM users WHERE id = ?').run(id); + } + + public getUserCount(): number { + return (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0; + } + + public getAdminCount(): number { + return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number })?.count || 0; + } + + // --- Fleet Snapshots --- + + public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string): number { + const result = this.db.prepare( + 'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(description, createdBy, nodeCount, stackCount, skippedNodes, Date.now()); + return result.lastInsertRowid as number; + } + + public insertSnapshotFiles(snapshotId: number, files: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>): void { + const insert = this.db.prepare( + 'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)' + ); + const insertMany = this.db.transaction((rows: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>) => { + for (const row of rows) { + insert.run(snapshotId, row.nodeId, row.nodeName, row.stackName, row.filename, row.content); + } + }); + insertMany(files); + } + + public getSnapshots(limit = 50, offset = 0): FleetSnapshot[] { + return this.db.prepare( + 'SELECT * FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?' + ).all(limit, offset) as FleetSnapshot[]; + } + + public getSnapshot(id: number): FleetSnapshot | undefined { + return this.db.prepare('SELECT * FROM fleet_snapshots WHERE id = ?').get(id) as FleetSnapshot | undefined; + } + + public getSnapshotFiles(snapshotId: number): FleetSnapshotFile[] { + return this.db.prepare( + 'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? ORDER BY node_name, stack_name' + ).all(snapshotId) as FleetSnapshotFile[]; + } + + public getSnapshotStackFiles(snapshotId: number, nodeId: number, stackName: string): FleetSnapshotFile[] { + return this.db.prepare( + 'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? AND node_id = ? AND stack_name = ?' + ).all(snapshotId, nodeId, stackName) as FleetSnapshotFile[]; + } + + public deleteSnapshot(id: number): void { + this.db.prepare('DELETE FROM fleet_snapshots WHERE id = ?').run(id); + } + + public getSnapshotCount(): number { + return (this.db.prepare('SELECT COUNT(*) as count FROM fleet_snapshots').get() as { count: number })?.count || 0; + } } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 54889602..bac6822a 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -236,4 +236,70 @@ export class FileSystemService { console.error('Migration error:', error); } } + + /** + * Backup stack files (compose.yaml + .env) to .sencho-backup/ within the stack dir. + */ + async backupStackFiles(stackName: string): Promise { + const stackDir = path.join(this.baseDir, stackName); + const backupDir = path.join(stackDir, '.sencho-backup'); + await fsPromises.mkdir(backupDir, { recursive: true }); + + // Copy compose file + const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; + for (const file of composeFiles) { + const src = path.join(stackDir, file); + try { + await fsPromises.access(src); + await fsPromises.copyFile(src, path.join(backupDir, file)); + } catch { + // File doesn't exist, skip + } + } + + // Copy .env if it exists + const envSrc = path.join(stackDir, '.env'); + try { + await fsPromises.access(envSrc); + await fsPromises.copyFile(envSrc, path.join(backupDir, '.env')); + } catch { + // No .env to backup + } + + // Write timestamp marker + await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8'); + } + + /** + * Restore stack files from .sencho-backup/ back to the stack dir. + */ + async restoreStackFiles(stackName: string): Promise { + const stackDir = path.join(this.baseDir, stackName); + const backupDir = path.join(stackDir, '.sencho-backup'); + + const items = await fsPromises.readdir(backupDir); + for (const item of items) { + if (item === '.timestamp') continue; + await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item)); + } + } + + /** + * Get backup info for a stack. + */ + async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> { + const backupDir = path.join(this.baseDir, stackName, '.sencho-backup'); + try { + await fsPromises.access(backupDir); + const tsFile = path.join(backupDir, '.timestamp'); + try { + const ts = await fsPromises.readFile(tsFile, 'utf-8'); + return { exists: true, timestamp: parseInt(ts, 10) || null }; + } catch { + return { exists: true, timestamp: null }; + } + } catch { + return { exists: false, timestamp: null }; + } + } } diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts index e0dc8c3e..689e0d2d 100644 --- a/backend/src/services/WebhookService.ts +++ b/backend/src/services/WebhookService.ts @@ -34,7 +34,7 @@ export class WebhookService { ); } - public async execute(webhookId: number, action: string, triggerSource: string | null): Promise<{ success: boolean; error?: string; duration_ms: number }> { + public async execute(webhookId: number, action: string, triggerSource: string | null, atomic?: boolean): Promise<{ success: boolean; error?: string; duration_ms: number }> { const db = DatabaseService.getInstance(); const webhook = db.getWebhook(webhookId); if (!webhook) throw new Error('Webhook not found'); @@ -62,7 +62,7 @@ export class WebhookService { const compose = ComposeService.getInstance(defaultNodeId); switch (action) { case 'deploy': - await compose.deployStack(webhook.stack_name); + await compose.deployStack(webhook.stack_name, undefined, atomic); break; case 'restart': await compose.runCommand(webhook.stack_name, 'restart'); @@ -74,7 +74,7 @@ export class WebhookService { await compose.runCommand(webhook.stack_name, 'start'); break; case 'pull': - await compose.updateStack(webhook.stack_name); + await compose.updateStack(webhook.stack_name, undefined, atomic); break; default: throw new Error(`Unknown action: ${action}`); diff --git a/docs/docs.json b/docs/docs.json index 74056ccb..c209dbc4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -37,7 +37,10 @@ "features/multi-node", "features/fleet-view", "features/alerts-notifications", - "features/webhooks" + "features/webhooks", + "features/rbac", + "features/atomic-deployments", + "features/fleet-backups" ] }, { diff --git a/docs/features/atomic-deployments.mdx b/docs/features/atomic-deployments.mdx new file mode 100644 index 00000000..b0386f25 --- /dev/null +++ b/docs/features/atomic-deployments.mdx @@ -0,0 +1,33 @@ +--- +title: Atomic Deployments +description: Zero-downtime deployments with automatic rollback for Sencho Pro users. +--- + + + Atomic Deployments require a Sencho Pro license. Community Edition uses standard deployments without backup or rollback. + + +Sencho Pro wraps every deployment in a safety net. Before applying changes, it backs up your current configuration. If the deployment fails, it automatically rolls back to the previous working state. + +## How it works + +1. **Backup** — Before a deploy or update, Sencho copies `compose.yaml` and `.env` files to a `.sencho-backup/` directory inside the stack folder +2. **Deploy** — Sencho runs the requested compose operation (up, pull + recreate, etc.) +3. **Health probe** — After deployment, Sencho checks whether any containers exited with a non-zero exit code +4. **Auto-rollback** — If a crash is detected, Sencho restores the backed-up files and re-deploys automatically + +This entire sequence happens transparently. You see a single deploy action — Sencho handles the safety logic behind the scenes. + +## Manual rollback + +Pro admins can manually roll back to the previous deployment at any time by clicking the **Rollback** button in the stack action bar. The button appears whenever a backup exists, and its tooltip shows the timestamp of the last backup. + +Clicking Rollback restores the backed-up `compose.yaml` and `.env` files, then re-deploys the stack with the restored configuration. + +## Webhook support + +Deployments triggered via webhooks also use atomic rollback. Whether you deploy manually through the UI or automatically from a CI/CD pipeline, the same backup-and-recover flow applies. + +## Community Edition behavior + +Community users continue to use the standard deploy flow — no backup is created and no rollback is available. Upgrading to Pro enables atomic deployments immediately with no configuration required. diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx new file mode 100644 index 00000000..93fe8494 --- /dev/null +++ b/docs/features/fleet-backups.mdx @@ -0,0 +1,84 @@ +--- +title: Fleet-Wide Backups +description: Snapshot compose files across all nodes for disaster recovery and auditing. +--- + + + Fleet-Wide Backups require a Sencho Pro license. The feature is available to Pro admins in the Fleet View. + + +Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet — local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time. + +## Creating a snapshot + +1. Navigate to **Fleet View** and select the **Snapshots** tab +2. Click **Create Snapshot** +3. Optionally enter a description (e.g. "Before v2 migration") +4. Click **Create** — Sencho captures files from every reachable node + +During creation, Sencho connects to each node in parallel: +- **Local nodes** — reads files directly from the compose directory +- **Remote nodes** — fetches files via the Distributed API proxy using the node's API token + +If a remote node is offline or unreachable, it is **skipped gracefully**. The snapshot is still created with data from all reachable nodes, and skipped nodes are recorded with the reason for the failure. + +## Browsing snapshots + +The snapshot list shows: +- **Date** — when the snapshot was taken +- **Description** — your optional label +- **Scope** — how many nodes and stacks were captured +- **Warnings** — an indicator if any nodes were skipped + +Click **View** to open the detail view, which presents a collapsible tree: + +``` +Node A (local) + ├── traefik/ + │ ├── compose.yaml + │ └── .env + └── postgres/ + └── compose.yaml +Node B (remote) + └── grafana/ + ├── compose.yaml + └── .env +``` + +Expand any file to preview its contents inline. + +## Restoring from a snapshot + +Admins can restore individual stacks from any snapshot: + +1. Open a snapshot's detail view +2. Find the stack you want to restore +3. Click **Restore** +4. Optionally check **Redeploy stack after restore** to immediately apply the restored configuration +5. Confirm the action + +Sencho writes the snapshot's files back to the target node: +- **Local nodes** — files are written directly, and the current files are backed up first (creating a rollback point via the atomic deployment system) +- **Remote nodes** — files are pushed via the Distributed API proxy + + + Restoring overwrites the current compose and environment files on the target node. If atomic deployments are enabled, the current files are backed up before restoration. + + +## Deleting snapshots + +Admins can delete snapshots from the list view. Deleting a snapshot permanently removes all captured file data from the database. This action cannot be undone. + +## Access control + +| Action | Admin | Viewer | +|--------|-------|--------| +| View snapshot list | Yes | Yes | +| Browse snapshot contents | Yes | Yes | +| Create snapshot | Yes | No | +| Restore from snapshot | Yes | No | +| Delete snapshot | Yes | No | + +## Storage + +Snapshots are stored in Sencho's SQLite database. Compose files are typically small (under 10 KB each), so even hundreds of snapshots consume minimal disk space. For very large fleets, consider periodically deleting old snapshots to keep the database lean. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 1d07835f..30fdf82b 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -19,6 +19,18 @@ Add remote Sencho instances as nodes. All dashboard operations — stack managem Monitor your entire infrastructure from a single screen. The fleet dashboard shows all nodes with health metrics, container counts, and resource usage. Pro users unlock fleet health summary cards, container drill-down, search, sorting, filtering, and critical node detection. [Learn more →](/features/fleet-view) +## RBAC & user management + +Create viewer accounts with read-only access to dashboards, logs, and file contents — while keeping deploy and edit permissions locked to admins. Sencho Pro supports two roles: Admin (full access) and Viewer (read-only). [Learn more →](/features/rbac) + +## Atomic deployments + +Pro users get automatic backup and rollback on every deployment. Before applying changes, Sencho snapshots your compose and environment files. If containers crash after deploy, the previous configuration is restored automatically. [Learn more →](/features/atomic-deployments) + +## Fleet-wide backups + +Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy — even to remote nodes. Offline nodes are gracefully skipped with visible warnings. [Learn more →](/features/fleet-backups) + ## Real-time logs & stats Stream container logs and resource metrics (CPU, memory, network I/O) live in the browser via WebSocket and Server-Sent Events connections. The Home dashboard shows historical CPU and RAM charts over the last 24 hours. diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx new file mode 100644 index 00000000..fd615cc9 --- /dev/null +++ b/docs/features/rbac.mdx @@ -0,0 +1,58 @@ +--- +title: RBAC & User Management +description: Role-based access control for Sencho Pro — create admin and viewer accounts to control who can modify your stacks. +--- + + + RBAC requires a Sencho Pro license. Community Edition supports a single admin account only. + + +Sencho Pro introduces role-based access control with two distinct roles: **Admin** and **Viewer**. This lets you give team members read-only access to your infrastructure without risking accidental changes. + +## Roles + +| Role | Description | +|------|-------------| +| **Admin** | Full access to all features — deploy, edit, manage users, configure nodes, and more | +| **Viewer** | Read-only access to dashboards, logs, stats, and file contents | + +### What viewers can see + +- Dashboard and home metrics +- Stacks list and stack detail view +- Compose and `.env` file contents (read-only) +- Per-container stats and logs +- Fleet view +- Resources hub (images, volumes, networks — read-only) +- Global logs +- Notifications + +### What viewers cannot do + +- Edit compose or environment files +- Deploy, restart, stop, or start stacks +- Create or delete stacks +- Manage users, nodes, webhooks, or alerts +- Access the host console +- Prune resources +- Change settings + +## Managing users + +Admins can manage accounts in **Settings → Users**. From there you can: + +- **Create** a new user with a username, password, and role (Admin or Viewer) +- **Edit** an existing user's password or role +- **Delete** a user account + +## Migration from single-admin setup + +When you upgrade to Sencho Pro, your existing single-admin credentials are automatically migrated to the new users table. No manual action is required — your login continues to work as before, and your account is assigned the Admin role. + +## License tiers + +| Tier | Admin accounts | Viewer accounts | +|------|---------------|-----------------| +| **Community** | 1 | 0 | +| **Personal Pro** | 1 | 1 | +| **Team Pro** | Unlimited | Unlimited | diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 5076677e..91e5da19 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -10,6 +10,7 @@ import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react" import { toast } from "sonner"; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; export interface TemplateEnv { name: string; @@ -44,6 +45,7 @@ interface AppStoreViewProps { } export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { + const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const [templates, setTemplates] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -471,9 +473,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
+ } {/* Search Input & Stack List */} @@ -1119,6 +1169,7 @@ export default function EditorLayout() { Fleet {/* Console Toggle */} + {isAdmin && ( + )} {/* Resources Toggle */} + {isPro && backupInfo.exists && ( + + + + + + + {backupInfo.timestamp + ? `Roll back to backup from ${new Date(backupInfo.timestamp).toLocaleString()}` + : 'Roll back to previous deployment'} + + + + )} + + {loadingDetail ? ( +
+ + +
+ + +
+ +
+ ) : selectedSnapshot ? ( + <> + {/* Header card */} +
+

+ {selectedSnapshot.description || 'Untitled Snapshot'} +

+

+ Created by {selectedSnapshot.created_by} on{' '} + {new Date(selectedSnapshot.created_at).toLocaleString()} +

+
+ + {selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''} + + + {selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''} + +
+
+ + {/* Skipped nodes warning */} + {(() => { + const skipped = parseSkippedNodes(selectedSnapshot.skipped_nodes); + if (skipped.length === 0) return null; + return ( +
+
+ + + Some nodes were unreachable during snapshot creation: + +
+
    + {skipped.map(node => ( +
  • + {node.nodeName} + {' — '} + {node.reason} +
  • + ))} +
+
+ ); + })()} + + {/* Node / Stack / File tree */} +
+ {selectedSnapshot.nodes.map(node => { + const nodeExpanded = expandedNodes.has(node.nodeId); + return ( +
+ {/* Node header */} + + + {/* Stacks */} + {nodeExpanded && ( +
+ {node.stacks.map(stack => { + const stackKey = `${node.nodeId}:${stack.stackName}`; + const stackExpanded = expandedStacks.has(stackKey); + return ( +
+ + + {/* Files */} + {stackExpanded && ( +
+ {stack.files.map(file => { + const fileKey = `${stackKey}:${file.filename}`; + const showPreview = previewFiles.has(fileKey); + return ( +
+
+ + {file.filename} + +
+ {showPreview && ( +
+                                                                                        {file.content}
+                                                                                    
+ )} +
+ ); + })} + + {/* Restore button (admin only) */} + {isAdmin && ( + + )} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ + ) : null} + + ); + } + + // --- List View --- + + return ( +
+ {/* Header */} +
+
+ +

Fleet Snapshots

+
+ {isAdmin && !showCreateForm && ( + + )} +
+ + {/* Create form */} + {showCreateForm && ( +
+ setDescription(e.target.value)} + disabled={creating} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} + /> +
+ + +
+
+ )} + + {/* Loading state */} + {loading ? ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+ ) : snapshots.length === 0 ? ( + /* Empty state */ +
+ +

No snapshots yet

+

+ Create your first fleet snapshot to back up compose files across all nodes. +

+
+ ) : ( + /* Snapshots table */ +
+ + + + Date + Description + Scope + Warnings + Actions + + + + {snapshots.map(snapshot => { + const skipped = parseSkippedNodes(snapshot.skipped_nodes); + const skippedNames = skipped.map(s => s.nodeName).join(', '); + return ( + + + {new Date(snapshot.created_at).toLocaleString()} + + + {snapshot.description ? ( + snapshot.description + ) : ( + No description + )} + + + {snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''} + {' · '} + {snapshot.stack_count} stack{snapshot.stack_count !== 1 ? 's' : ''} + + + {skipped.length > 0 ? ( + + + {skipped.length} + + ) : ( + None + )} + + +
+ + {isAdmin && ( + + + + + + + Delete snapshot? + + This will permanently delete this fleet snapshot. This action cannot be undone. + + + + Cancel + handleDelete(snapshot.id)} + > + Delete + + + + + )} +
+
+
+ ); + })} +
+
+
+ )} +
+ ); +} + +// --- Restore Button Sub-Component --- + +function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: { + nodeId: number; + nodeName: string; + stackName: string; + restoring: boolean; + onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise; +}) { + const [redeploy, setRedeploy] = useState(false); + + return ( + + + + + + + + Restore {stackName} on {nodeName}? + + + This will overwrite the current compose files with the snapshot version. + + +
+ setRedeploy(checked === true)} + /> + +
+ + Cancel + onRestore(nodeId, stackName, redeploy)} + > + {restoring && } + Restore + + +
+
+ ); +} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 01192fcb..a0a90a7d 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity, - Play, Square, RotateCcw, ExternalLink, + Play, Square, RotateCcw, ExternalLink, Camera, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -11,9 +11,11 @@ import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { apiFetch } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; import { ProGate } from './ProGate'; +import FleetSnapshots from './FleetSnapshots'; import { toast } from 'sonner'; // --- Types --- @@ -567,210 +569,229 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { - {/* Loading State */} - {loading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- -
- - - -
- - - -
- ))} -
- )} - - {/* Empty State */} - {!loading && nodes.length === 0 && ( -
- -

No nodes configured

-

Add nodes in Settings to see your fleet here.

-
- )} - - {/* Fleet Content */} - {!loading && nodes.length > 0 && ( - <> - {/* Pro: Fleet Health Summary Cards */} - {isPro && onlineNodes.length > 0 && ( -
- - - 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined} - /> - 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'} - alert={criticalCount > 0} - /> -
- )} - - {/* Pro: Search, Sort & Filter Toolbar */} + + + Overview {isPro && ( -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="pl-9 h-9" - /> -
- - {/* Sort */} - - - - - {/* Filter pills */} -
- {(['all', 'online', 'offline'] as FilterStatus[]).map(status => ( - - ))} -
- -
- {(['all', 'local', 'remote'] as FilterType[]).map(type => ( - - ))} -
- - -
+ + Snapshots + )} +
- {/* Node Grid */} - {processedNodes.length > 0 ? ( + + {/* Loading State */} + {loading && (
- {processedNodes.map(node => ( - + {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + + +
+ + + +
))}
- ) : ( -
- -

No nodes match your filters

-

Try adjusting your search or filter criteria.

- + )} + + {/* Empty State */} + {!loading && nodes.length === 0 && ( +
+ +

No nodes configured

+

Add nodes in Settings to see your fleet here.

)} - {/* Pro auto-refresh indicator */} - {isPro && ( -

- Auto-refreshing every 30 seconds -

- )} + {/* Fleet Content */} + {!loading && nodes.length > 0 && ( + <> + {/* Pro: Fleet Health Summary Cards */} + {isPro && onlineNodes.length > 0 && ( +
+ + + 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined} + /> + 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'} + alert={criticalCount > 0} + /> +
+ )} - {/* Free tier: Pro gate for advanced features */} - {!isPro && nodes.length > 0 && ( -
- - {/* Preview of what Pro unlocks */} -
-
-
-
-
+ {/* Pro: Search, Sort & Filter Toolbar */} + {isPro && ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ + {/* Sort */} + + + + + {/* Filter pills */} +
+ {(['all', 'online', 'offline'] as FilterStatus[]).map(status => ( + + ))} +
+ +
+ {(['all', 'local', 'remote'] as FilterType[]).map(type => ( + + ))} +
+ +
-
-
-
+ )} + + {/* Node Grid */} + {processedNodes.length > 0 ? ( +
+ {processedNodes.map(node => ( + + ))}
- -
+ ) : ( +
+ +

No nodes match your filters

+

Try adjusting your search or filter criteria.

+ +
+ )} + + {/* Pro auto-refresh indicator */} + {isPro && ( +

+ Auto-refreshing every 30 seconds +

+ )} + + {/* Free tier: Pro gate for advanced features */} + {!isPro && nodes.length > 0 && ( +
+ + {/* Preview of what Pro unlocks */} +
+
+
+
+
+
+
+
+
+
+ +
+ )} + )} - - )} + + + {isPro && ( + + + + )} +
); } diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 5e654665..007f7b94 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -11,6 +11,7 @@ import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck } from 'lucide-react'; import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; @@ -305,6 +306,7 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { // ── Main Component ───────────────────────────────────────────────────────────── export default function ResourcesView() { + const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); @@ -485,7 +487,7 @@ export default function ResourcesView() { {/* Quick Clean */} - + {isAdmin && Quick Clean @@ -531,7 +533,7 @@ export default function ResourcesView() { />
- + }
{/* Resource Tabs */} @@ -609,9 +611,9 @@ export default function ResourcesView() {
- + } ))} @@ -656,9 +658,9 @@ export default function ResourcesView() { {vol.Mountpoint} - + } ))} @@ -705,7 +707,7 @@ export default function ResourcesView() { {net.Scope} - + } ))} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 7fcde88e..47288d46 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -18,10 +18,12 @@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History } from 'lucide-react'; +import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil } from 'lucide-react'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { ProBadge } from './ProBadge'; import { ProGate } from './ProGate'; @@ -46,7 +48,7 @@ interface PatchableSettings { log_retention_days?: string; } -type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about'; +type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about'; interface WebhookItem { id: number; @@ -372,8 +374,279 @@ function WebhooksSection({ isPro }: { isPro: boolean }) { ); } +interface UserItem { + id: number; + username: string; + role: 'admin' | 'viewer'; + created_at: number; +} + +function UsersSection() { + const { user: currentUser } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [saving, setSaving] = useState(false); + + // Form state + const [formUsername, setFormUsername] = useState(''); + const [formPassword, setFormPassword] = useState(''); + const [formConfirmPassword, setFormConfirmPassword] = useState(''); + const [formRole, setFormRole] = useState<'admin' | 'viewer'>('viewer'); + + const fetchUsers = async () => { + try { + const res = await apiFetch('/users', { localOnly: true }); + if (res.ok) setUsers(await res.json()); + } catch { /* ignore */ } finally { setLoading(false); } + }; + + useEffect(() => { fetchUsers(); }, []); + + const resetForm = () => { + setFormUsername(''); + setFormPassword(''); + setFormConfirmPassword(''); + setFormRole('viewer'); + setEditingUser(null); + setShowForm(false); + }; + + const handleSave = async () => { + if (!formUsername || formUsername.length < 3) { + toast.error('Username must be at least 3 characters.'); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(formUsername)) { + toast.error('Username can only contain letters, numbers, underscores, and hyphens.'); + return; + } + if (!editingUser && !formPassword) { + toast.error('Password is required for new users.'); + return; + } + if (formPassword && formPassword.length < 6) { + toast.error('Password must be at least 6 characters.'); + return; + } + if (formPassword && formPassword !== formConfirmPassword) { + toast.error('Passwords do not match.'); + return; + } + setSaving(true); + try { + if (editingUser) { + const body: Record = { username: formUsername, role: formRole }; + if (formPassword) body.password = formPassword; + const res = await apiFetch(`/users/${editingUser.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + localOnly: true, + }); + if (!res.ok) { + const err = await res.json(); + toast.error(err?.error || err?.message || 'Failed to update user.'); + return; + } + toast.success('User updated.'); + } else { + const res = await apiFetch('/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: formUsername, password: formPassword, role: formRole }), + localOnly: true, + }); + if (!res.ok) { + const err = await res.json(); + toast.error(err?.error || err?.message || 'Failed to create user.'); + return; + } + toast.success('User created.'); + } + resetForm(); + fetchUsers(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Something went wrong.'; + toast.error(msg); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (userId: number) => { + try { + const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true }); + if (!res.ok) { + const err = await res.json(); + toast.error(err?.error || err?.message || 'Failed to delete user.'); + return; + } + toast.success('User deleted.'); + fetchUsers(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Something went wrong.'; + toast.error(msg); + } + }; + + const startEdit = (u: UserItem) => { + setEditingUser(u); + setFormUsername(u.username); + setFormRole(u.role); + setFormPassword(''); + setFormConfirmPassword(''); + setShowForm(true); + }; + + return ( + +
+
+
+

User Management

+

Create and manage user accounts with role-based access control.

+
+ {!showForm && ( + + )} +
+ + {/* Add/Edit Form */} + {showForm && ( +
+

{editingUser ? 'Edit User' : 'New User'}

+
+
+ + setFormUsername(e.target.value)} + placeholder="username" + /> +
+
+ + +
+
+
+
+ + setFormPassword(e.target.value)} + placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'} + /> +
+
+ + setFormConfirmPassword(e.target.value)} + placeholder="Confirm password" + /> +
+
+
+ + +
+
+ )} + + {/* Users Table */} + {loading ? ( +
+ + +
+ ) : users.length === 0 ? ( +
No users found.
+ ) : ( +
+ + + + + + + + + + + {users.map((u) => { + const isSelf = u.username === currentUser?.username; + return ( + + + + + + + ); + })} + +
UsernameRoleCreatedActions
+ {u.username} + {isSelf && (you)} + + + {u.role} + + + {new Date(u.created_at).toLocaleDateString()} + +
+ + + + + + + + Delete user "{u.username}"? + + This action cannot be undone. The user will lose access immediately. + + + + Cancel + handleDelete(u.id)}>Delete + + + +
+
+
+ )} +
+
+ ); +} + export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { activeNode } = useNodes(); + const { isAdmin } = useAuth(); const { license, isPro, activate, deactivate } = useLicense(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('account'); @@ -383,7 +656,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { + if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -721,6 +994,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {!isRemote && ( } label="License" /> )} + {!isRemote && isAdmin && ( + } label="Users" /> + )} } @@ -1092,6 +1368,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { )} + {activeSection === 'users' && ( + + )} + {activeSection === 'developer' && (
diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index 0c1cb629..49e874b5 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -15,6 +15,7 @@ import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from ' import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; interface StackAlert { id?: number; @@ -39,6 +40,7 @@ interface AgentStatus { } export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) { + const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; @@ -266,7 +268,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
- + }
)) @@ -284,7 +286,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
{/* Add New Alert Form */} -
+ {isAdmin &&

Add New Rule

@@ -419,7 +421,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP 'Add Rule' )} -
+
}
diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index d5cabc97..f39cd960 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -15,7 +15,7 @@ interface UserProfileDropdownProps { } export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { - const { logout } = useAuth(); + const { logout, user, isAdmin } = useAuth(); const { license, isPro } = useLicense(); return ( @@ -33,8 +33,12 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
-

admin

+

{user?.username ?? 'admin'}

+ + {user?.role ?? 'admin'} + + · {isPro ? : Community}
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 81691514..20a45903 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -2,10 +2,17 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from ' type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated'; +interface UserInfo { + username: string; + role: 'admin' | 'viewer'; +} + interface AuthContextType { appStatus: AppStatus; isAuthenticated: boolean; needsSetup: boolean; + user: UserInfo | null; + isAdmin: boolean; login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>; logout: () => Promise; completeSetup: () => void; @@ -16,6 +23,7 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [appStatus, setAppStatus] = useState('loading'); + const [user, setUser] = useState(null); const checkAuth = async () => { try { @@ -24,9 +32,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { credentials: 'include', }); const statusData = await statusResponse.json(); - + if (statusData.needsSetup) { setAppStatus('needsSetup'); + setUser(null); return; } @@ -34,13 +43,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { const authResponse = await fetch('/api/auth/check', { credentials: 'include', }); - + if (authResponse.ok) { + const data = await authResponse.json(); + setUser(data.user ?? null); setAppStatus('authenticated'); } else { + setUser(null); setAppStatus('notAuthenticated'); } } catch { + setUser(null); setAppStatus('notAuthenticated'); } }; @@ -67,6 +80,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (response.ok && data.success) { setAppStatus('authenticated'); + // Fetch user info (role, username) so isAdmin is correct immediately + await checkAuth(); return { success: true }; } else { return { success: false, error: data.error || 'Login failed' }; @@ -85,23 +100,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Logout error:', error); } finally { + setUser(null); setAppStatus('notAuthenticated'); } }; const completeSetup = () => { - setAppStatus('authenticated'); + // Fetch user info so isAdmin is correct after setup + checkAuth(); }; return ( - {children}