@logboard/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -0
- package/README.md +200 -0
- package/bin/logboard +536 -0
- package/client/logger.js +309 -0
- package/config/index.js +142 -0
- package/config.js +2 -0
- package/controllers/AnalyticsController.js +46 -0
- package/controllers/ApiAnalyticsController.js +129 -0
- package/controllers/ApiKeyController.js +58 -0
- package/controllers/AuthController.js +131 -0
- package/controllers/HealthController.js +56 -0
- package/controllers/LogController.js +197 -0
- package/controllers/OrgController.js +152 -0
- package/controllers/RoleConfigController.js +20 -0
- package/controllers/SettingsController.js +39 -0
- package/controllers/StreamController.js +55 -0
- package/controllers/UiController.js +789 -0
- package/controllers/UserController.js +79 -0
- package/lib/batchWriter.js +57 -0
- package/lib/cleanup.js +67 -0
- package/lib/ejs.js +103 -0
- package/lib/emitter.js +5 -0
- package/lib/healthMonitor.js +245 -0
- package/lib/logger.js +21 -0
- package/lib/streams.js +32 -0
- package/lib/theme.js +77 -0
- package/lib/userStore.js +13 -0
- package/lib/utils.js +44 -0
- package/middleware/apiKey.js +82 -0
- package/middleware/auth.js +55 -0
- package/middleware/ipWhitelist.js +59 -0
- package/middleware/org.js +85 -0
- package/middleware/pageAccess.js +20 -0
- package/middleware/rateLimit.js +29 -0
- package/middleware/roles.js +11 -0
- package/package.json +77 -0
- package/routes/alerts.js +18 -0
- package/routes/analytics.js +26 -0
- package/routes/api-analytics.js +30 -0
- package/routes/api-keys.js +12 -0
- package/routes/archive.js +91 -0
- package/routes/audit.js +50 -0
- package/routes/auth.js +22 -0
- package/routes/bookmarks.js +13 -0
- package/routes/health.js +11 -0
- package/routes/logs.js +88 -0
- package/routes/metrics.js +66 -0
- package/routes/notifications.js +14 -0
- package/routes/orgs.js +98 -0
- package/routes/registration.js +202 -0
- package/routes/role-config.js +97 -0
- package/routes/saved-searches.js +12 -0
- package/routes/server.js +151 -0
- package/routes/settings.js +28 -0
- package/routes/status.js +21 -0
- package/routes/stream.js +11 -0
- package/routes/super.js +129 -0
- package/routes/ui.js +120 -0
- package/routes/users.js +13 -0
- package/server.js +172 -0
- package/services/AlertRulesService.js +323 -0
- package/services/AnalyticsService.js +665 -0
- package/services/ApiAnalyticsService.js +471 -0
- package/services/ApiKeyService.js +166 -0
- package/services/AuditService.js +249 -0
- package/services/AuthService.js +234 -0
- package/services/BookmarkService.js +49 -0
- package/services/GlobalSettingsService.js +44 -0
- package/services/LogService.js +1066 -0
- package/services/MetricsService.js +116 -0
- package/services/NotificationService.js +70 -0
- package/services/OrgService.js +217 -0
- package/services/ReportService.js +247 -0
- package/services/RoleConfigService.js +201 -0
- package/services/SavedSearchService.js +63 -0
- package/services/SettingsService.js +220 -0
- package/services/UserService.js +121 -0
- package/setup.js +132 -0
- package/views/404.ejs +8 -0
- package/views/alerts.ejs +190 -0
- package/views/analytics.ejs +209 -0
- package/views/api-analytics.ejs +660 -0
- package/views/api-keys.ejs +150 -0
- package/views/archive.ejs +123 -0
- package/views/audit.ejs +314 -0
- package/views/bookmarks.ejs +54 -0
- package/views/custom-dashboard.ejs +162 -0
- package/views/dashboard.ejs +186 -0
- package/views/diff.ejs +98 -0
- package/views/health.ejs +269 -0
- package/views/heatmap.ejs +126 -0
- package/views/insights.ejs +334 -0
- package/views/invite.ejs +74 -0
- package/views/live.ejs +299 -0
- package/views/login.ejs +64 -0
- package/views/logo.png +0 -0
- package/views/logs.ejs +754 -0
- package/views/notifications.ejs +58 -0
- package/views/partials/head.ejs +282 -0
- package/views/partials/sidebar.ejs +168 -0
- package/views/register.ejs +100 -0
- package/views/roles.ejs +279 -0
- package/views/saved-searches.ejs +51 -0
- package/views/service-map.ejs +142 -0
- package/views/settings.ejs +1159 -0
- package/views/sidebar.ejs +129 -0
- package/views/status.ejs +100 -0
- package/views/super-admin-admins.ejs +58 -0
- package/views/super-admin-analytics.ejs +49 -0
- package/views/super-admin-orgs.ejs +310 -0
- package/views/super-admin-profile.ejs +77 -0
- package/views/super-admin-settings.ejs +108 -0
- package/views/super-admin-system.ejs +46 -0
- package/views/users.ejs +153 -0
package/routes/audit.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const svc = require('../services/AuditService');
|
|
4
|
+
const { authenticate } = require('../middleware/auth');
|
|
5
|
+
const requireRole = require('../middleware/roles');
|
|
6
|
+
|
|
7
|
+
const router = Router();
|
|
8
|
+
const admin = [authenticate, requireRole('admin')];
|
|
9
|
+
const auth = [authenticate];
|
|
10
|
+
|
|
11
|
+
// List audit entries
|
|
12
|
+
router.get('/', ...admin, async (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const { limit=200, offset=0, q='', action='' } = req.query;
|
|
15
|
+
res.json(await svc.list({ limit: Number(limit), offset: Number(offset), q, action }));
|
|
16
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Analytics
|
|
20
|
+
router.get('/analytics', ...admin, async (req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const days = Number(req.query.days||7);
|
|
23
|
+
res.json(await svc.getAnalytics(days));
|
|
24
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Page view beacon (called from client JS on page unload)
|
|
28
|
+
router.post('/page-view', ...auth, async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const { page, durationSec } = req.body;
|
|
31
|
+
await svc.logPageView(req.user.username, page, durationSec, req.ip);
|
|
32
|
+
res.json({ ok: true });
|
|
33
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Log view event
|
|
37
|
+
router.post('/log-view', ...auth, async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
const { service, date, q } = req.body;
|
|
40
|
+
await svc.logLogAccess(req.user.username, service, date, q, req.ip);
|
|
41
|
+
res.json({ ok: true });
|
|
42
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Clear
|
|
46
|
+
router.delete('/', ...admin, async (req, res) => {
|
|
47
|
+
try { await svc.clear(); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
module.exports = router;
|
package/routes/auth.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const AuthService = require('../services/AuthService');
|
|
4
|
+
const AuthController = require('../controllers/AuthController');
|
|
5
|
+
const { authenticate } = require('../middleware/auth');
|
|
6
|
+
const rateLimit = require('../middleware/rateLimit');
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
const svc = new AuthService();
|
|
10
|
+
const ctrl = new AuthController(svc);
|
|
11
|
+
const loginLimit = rateLimit({ windowMs: 15 * 60_000, max: 10, message: 'Too many login attempts' });
|
|
12
|
+
|
|
13
|
+
router.post('/login', loginLimit, (req, res) => ctrl.login(req, res));
|
|
14
|
+
router.post('/logout', (req, res) => ctrl.logout(req, res));
|
|
15
|
+
router.get('/verify', (req, res) => ctrl.verify(req, res));
|
|
16
|
+
router.get('/2fa/setup', authenticate, (req, res) => ctrl.setup2fa(req, res));
|
|
17
|
+
router.post('/2fa/enable', authenticate, (req, res) => ctrl.enable2fa(req, res));
|
|
18
|
+
router.post('/2fa/disable', authenticate, (req, res) => ctrl.disable2fa(req, res));
|
|
19
|
+
router.get('/2fa/status', authenticate, (req, res) => ctrl.totpStatus(req, res));
|
|
20
|
+
router.post('/change-password', authenticate, (req, res) => ctrl.changePassword(req, res));
|
|
21
|
+
|
|
22
|
+
module.exports = router;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const svc = require('../services/BookmarkService');
|
|
4
|
+
const { authenticate } = require('../middleware/auth');
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
router.get('/', authenticate, async (req, res) => { try { res.json(await svc.getAll(req.user.username)); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
9
|
+
router.post('/', authenticate, async (req, res) => { try { res.status(201).json(await svc.create(req.user.username, req.body)); } catch (e) { res.status(e.status||500).json({ error: e.message }); } });
|
|
10
|
+
router.put('/:id/note', authenticate, async (req, res) => { try { res.json(await svc.updateNote(req.user.username, req.params.id, req.body.note||'')); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
11
|
+
router.delete('/:id', authenticate, async (req, res) => { try { await svc.delete(req.user.username, req.params.id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
12
|
+
|
|
13
|
+
module.exports = router;
|
package/routes/health.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const HealthController = require('../controllers/HealthController');
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
const ctrl = new HealthController();
|
|
7
|
+
|
|
8
|
+
router.get('/', (req, res) => ctrl.health(req, res));
|
|
9
|
+
router.get('/metrics', (req, res) => ctrl.metrics(req, res));
|
|
10
|
+
|
|
11
|
+
module.exports = router;
|
package/routes/logs.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const LogController = require('../controllers/LogController');
|
|
4
|
+
const validateApiKey = require('../middleware/apiKey');
|
|
5
|
+
const { authenticate } = require('../middleware/auth');
|
|
6
|
+
const requireRole = require('../middleware/roles');
|
|
7
|
+
const rateLimit = require('../middleware/rateLimit');
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
const ingestLimit = rateLimit({ windowMs: 60_000, max: 1000, message: 'Ingest rate limit exceeded' });
|
|
11
|
+
|
|
12
|
+
// batchWriter is injected by server.js via module.exports.setWriter
|
|
13
|
+
let ctrl;
|
|
14
|
+
module.exports.setWriter = function (batchWriter) {
|
|
15
|
+
const LogService = require('../services/LogService');
|
|
16
|
+
ctrl = new LogController(new LogService(batchWriter));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ── Ingest (API key auth) ────────────────────────────────────────────────
|
|
20
|
+
router.post('/', validateApiKey(), ingestLimit, (req, res) => ctrl.ingest(req, res));
|
|
21
|
+
|
|
22
|
+
router.post('/bulk', validateApiKey(), ingestLimit, (req, res) => ctrl.ingest(req, res));
|
|
23
|
+
|
|
24
|
+
// ── Read (JWT auth, viewer+) ─────────────────────────────────────────────
|
|
25
|
+
router.get('/services', authenticate, (req, res) => ctrl.services(req, res));
|
|
26
|
+
router.get('/tail/:appName/:date', authenticate, (req, res) => ctrl.tail(req, res));
|
|
27
|
+
router.get('/search', authenticate, (req, res) => ctrl.search(req, res));
|
|
28
|
+
router.get('/replay', authenticate, (req, res) => ctrl.replay(req, res));
|
|
29
|
+
router.get('/download/:appName/:date', authenticate, (req, res) => ctrl.download(req, res));
|
|
30
|
+
router.get('/context/:appName/:date', authenticate, (req, res) => ctrl.context(req, res));
|
|
31
|
+
|
|
32
|
+
router.get('/global-search', authenticate, async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const { q, limit = 50, dates } = req.query;
|
|
35
|
+
if (!q || q.length < 2) { return res.json([]); }
|
|
36
|
+
const ls = ctrl ? ctrl.svc : new (require('../services/LogService'))(null);
|
|
37
|
+
const services = await ls.getServices();
|
|
38
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
39
|
+
const yesterday = new Date(Date.now()-864e5).toISOString().slice(0, 10);
|
|
40
|
+
const searchDates = dates ? dates.split(',').filter(Boolean) : [today, yesterday];
|
|
41
|
+
const results = [];
|
|
42
|
+
const ql = q.toLowerCase();
|
|
43
|
+
|
|
44
|
+
for (const { appName } of services.slice(0, 15)) {
|
|
45
|
+
for (const date of searchDates) {
|
|
46
|
+
try {
|
|
47
|
+
const r = await ls.tail(appName, date, 300);
|
|
48
|
+
const matches = (r.lines||[]).filter((l) => l.toLowerCase().includes(ql)).slice(0, 4);
|
|
49
|
+
matches.forEach((line) => {
|
|
50
|
+
let msg=line, ts='';
|
|
51
|
+
try { const p=JSON.parse(line); msg=p.message||line; ts=p.ts?p.ts.replace('T', ' ').slice(0, 19):''; } catch {}
|
|
52
|
+
results.push({ appName, date, msg: msg.slice(0, 120), ts });
|
|
53
|
+
});
|
|
54
|
+
} catch {}
|
|
55
|
+
if (results.length >= Number(limit)) { break; }
|
|
56
|
+
}
|
|
57
|
+
if (results.length >= Number(limit)) { break; }
|
|
58
|
+
}
|
|
59
|
+
res.json(results.slice(0, Number(limit)));
|
|
60
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
61
|
+
});
|
|
62
|
+
router.get('/trace', authenticate, (req, res) => {
|
|
63
|
+
if (!ctrl) { return res.status(503).json({ error: 'Log service not ready yet' }); }
|
|
64
|
+
ctrl.traceSearch(req, res);
|
|
65
|
+
});
|
|
66
|
+
router.get('/search-range', authenticate, (req, res) => {
|
|
67
|
+
if (!ctrl) { return res.status(503).json({ error: 'Log service not ready yet' }); }
|
|
68
|
+
ctrl.searchRange(req, res);
|
|
69
|
+
});
|
|
70
|
+
router.get('/clusters/:appName/:date', authenticate, (req, res) => {
|
|
71
|
+
if (!ctrl) { return res.status(503).json({ error: 'Log service not ready yet' }); }
|
|
72
|
+
ctrl.clusters(req, res);
|
|
73
|
+
});
|
|
74
|
+
router.get('/clusters-range/:appName', authenticate, async (req, res) => {
|
|
75
|
+
if (!ctrl) { return res.status(503).json({ error: 'Log service not ready yet' }); }
|
|
76
|
+
try {
|
|
77
|
+
const { fromDate, toDate } = req.query;
|
|
78
|
+
if (!fromDate || !toDate) { return res.status(400).json({ error: 'fromDate and toDate required' }); }
|
|
79
|
+
res.json(await ctrl.svc.clusterErrorsRange(req.params.appName, fromDate, toDate));
|
|
80
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
router.get('/anomalies/:appName', authenticate, (req, res) => {
|
|
84
|
+
if (!ctrl) { return res.status(503).json({ error: 'Log service not ready yet' }); }
|
|
85
|
+
ctrl.anomalies(req, res);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
module.exports.router = router;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { Router } = require("express");
|
|
3
|
+
const { authenticate } = require("../middleware/auth");
|
|
4
|
+
const validateApiKey = require("../middleware/apiKey");
|
|
5
|
+
const MetricsService = require("../services/MetricsService");
|
|
6
|
+
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
// Helper to get org-aware MetricsService
|
|
10
|
+
function getSvc(req) {
|
|
11
|
+
return new MetricsService(req.org || null);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// POST /api/metrics — SDK pushes metrics (API key auth, scope: metrics:write or logs:write)
|
|
15
|
+
router.post("/", validateApiKey("logs:write"), async (req, res) => {
|
|
16
|
+
// Attach org from API key's orgSlug if available
|
|
17
|
+
if (!req.org && req.apiKey) {
|
|
18
|
+
const slug = req.apiKey.orgSlug || "default";
|
|
19
|
+
try {
|
|
20
|
+
const OrgService = require("../services/OrgService");
|
|
21
|
+
req.org = await OrgService.getOrg(slug);
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const appName = req.body.appName || req.apiKey?.name;
|
|
26
|
+
if (!appName) {
|
|
27
|
+
return res.status(400).json({ error: "appName required" });
|
|
28
|
+
}
|
|
29
|
+
await getSvc(req).ingest(appName, req.body);
|
|
30
|
+
res.json({ success: true });
|
|
31
|
+
} catch (e) {
|
|
32
|
+
res.status(500).json({ error: e.message });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// GET /api/metrics/services — list services that have metrics
|
|
37
|
+
router.get("/services", authenticate, async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
res.json({ services: await getSvc(req).listServices() });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
res.status(500).json({ error: e.message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// GET /api/metrics/latest — latest snapshot per service (for status cards)
|
|
46
|
+
router.get("/latest", authenticate, async (req, res) => {
|
|
47
|
+
try {
|
|
48
|
+
const allowedApps = req.query.apps ? req.query.apps.split(",") : [];
|
|
49
|
+
res.json({ services: await getSvc(req).getLatestAll(allowedApps) });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
res.status(500).json({ error: e.message });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// GET /api/metrics/:appName?range=1440 — time series for one service
|
|
56
|
+
router.get("/:appName", authenticate, async (req, res) => {
|
|
57
|
+
try {
|
|
58
|
+
const range = parseInt(req.query.range) || 1440;
|
|
59
|
+
const rows = await getSvc(req).getTimeSeries(req.params.appName, range);
|
|
60
|
+
res.json({ appName: req.params.appName, count: rows.length, rows });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
res.status(500).json({ error: e.message });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
module.exports = router;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const svc = require('../services/NotificationService');
|
|
4
|
+
const { authenticate } = require('../middleware/auth');
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
router.get('/', authenticate, async (req, res) => { try { res.json(await svc.getAll(100)); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
9
|
+
router.get('/unread', authenticate, async (req, res) => { try { res.json({ count: await svc.countUnread() }); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
10
|
+
router.put('/read', authenticate, async (req, res) => { try { await svc.markRead(null); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
11
|
+
router.put('/read/:id', authenticate, async (req, res) => { try { await svc.markRead(req.params.id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
12
|
+
router.delete('/:id', authenticate, async (req, res) => { try { await svc.delete(req.params.id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
13
|
+
|
|
14
|
+
module.exports = router;
|
package/routes/orgs.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const OrgService = require('../services/OrgService');
|
|
4
|
+
const OrgController = require('../controllers/OrgController');
|
|
5
|
+
const { authenticate, authenticateUI } = require('../middleware/auth');
|
|
6
|
+
const { requireSuperAdmin } = require('../middleware/org');
|
|
7
|
+
const requireRole = require('../middleware/roles');
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
const ctrl = new OrgController();
|
|
11
|
+
|
|
12
|
+
// ── Public ────────────────────────────────────────────────────────────────
|
|
13
|
+
router.get ('/invites/:token', (req,res) => ctrl.getInvite(req,res));
|
|
14
|
+
router.post('/invites/accept', (req,res) => ctrl.acceptInvite(req,res));
|
|
15
|
+
router.get ('/invite/:token', (req,res) => ctrl.invitePage(req,res)); // UI page
|
|
16
|
+
|
|
17
|
+
// ── Org admin: invite users ────────────────────────────────────────────────
|
|
18
|
+
router.post('/invites', authenticate, requireRole('admin'), (req,res) => ctrl.createInvite(req,res));
|
|
19
|
+
|
|
20
|
+
// ── Super-admin: list + create orgs ──────────────────────────────────────
|
|
21
|
+
router.get ('/', authenticate, requireSuperAdmin, (req,res) => ctrl.listOrgs(req,res));
|
|
22
|
+
router.post('/', authenticate, requireSuperAdmin, (req,res) => ctrl.createOrg(req,res));
|
|
23
|
+
|
|
24
|
+
// ── Super-admin: bootstrap + migration (no auth required for first-time setup) ──
|
|
25
|
+
router.post('/super-admin/create', (req,res) => ctrl.createSuperAdmin(req,res));
|
|
26
|
+
router.post('/migrate', authenticate, requireSuperAdmin, (req,res) => ctrl.runMigration(req,res));
|
|
27
|
+
|
|
28
|
+
// ── IMPORTANT: specific /:slug/sub routes MUST come before /:slug wildcard ──
|
|
29
|
+
|
|
30
|
+
// ── Super-admin: org detail ───────────────────────────────────────────────
|
|
31
|
+
router.get('/:slug/detail', authenticate, requireSuperAdmin, async (req, res) => {
|
|
32
|
+
try {
|
|
33
|
+
const org = await OrgService.getOrg(req.params.slug);
|
|
34
|
+
if (!org) return res.status(404).json({ error: 'Org not found' });
|
|
35
|
+
const fsP = require('fs').promises;
|
|
36
|
+
const path = require('path');
|
|
37
|
+
|
|
38
|
+
let users = [];
|
|
39
|
+
try {
|
|
40
|
+
const raw = JSON.parse(await fsP.readFile(org.usersFile, 'utf8'));
|
|
41
|
+
users = Object.values(raw).map(u => ({ username: u.username, role: u.role, email: u.email||null, createdAt: u.createdAt }));
|
|
42
|
+
} catch {}
|
|
43
|
+
|
|
44
|
+
const SettingsService = require('../services/SettingsService');
|
|
45
|
+
let settings = {};
|
|
46
|
+
try { settings = await new SettingsService(org).get(); } catch {}
|
|
47
|
+
|
|
48
|
+
let services = [];
|
|
49
|
+
try {
|
|
50
|
+
const dirs = await fsP.readdir(org.logsDir);
|
|
51
|
+
services = dirs.filter(d => !['_archive','app','unknown'].includes(d));
|
|
52
|
+
} catch {}
|
|
53
|
+
|
|
54
|
+
res.json({ slug: org.slug, name: org.name, plan: org.plan, logsDir: org.logsDir, users, settings, services, createdAt: org.createdAt });
|
|
55
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Super-admin: create user in org ──────────────────────────────────────
|
|
59
|
+
router.post('/:slug/create-user', authenticate, requireSuperAdmin, async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const org = await OrgService.getOrg(req.params.slug);
|
|
62
|
+
if (!org) return res.status(404).json({ error: 'Org not found' });
|
|
63
|
+
const { username, password, role = 'admin' } = req.body;
|
|
64
|
+
if (!username || !password || password.length < 8) return res.status(400).json({ error: 'Username and password (min 8 chars) required' });
|
|
65
|
+
const fsP = require('fs').promises;
|
|
66
|
+
const bcrypt = require('bcryptjs');
|
|
67
|
+
let users = {};
|
|
68
|
+
try { users = JSON.parse(await fsP.readFile(org.usersFile, 'utf8')); } catch {}
|
|
69
|
+
if (users[username]) return res.status(409).json({ error: 'Username already exists in this org' });
|
|
70
|
+
users[username] = { username, role, password: await bcrypt.hash(password, 10), createdAt: new Date().toISOString() };
|
|
71
|
+
await fsP.writeFile(org.usersFile, JSON.stringify(users, null, 2), 'utf8');
|
|
72
|
+
res.json({ success: true, username, role });
|
|
73
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── Super-admin: remove user from org ────────────────────────────────────
|
|
77
|
+
router.delete('/:slug/users/:username', authenticate, requireSuperAdmin, async (req, res) => {
|
|
78
|
+
try {
|
|
79
|
+
const org = await OrgService.getOrg(req.params.slug);
|
|
80
|
+
if (!org) return res.status(404).json({ error: 'Org not found' });
|
|
81
|
+
const fsP = require('fs').promises;
|
|
82
|
+
let users = {};
|
|
83
|
+
try { users = JSON.parse(await fsP.readFile(org.usersFile, 'utf8')); } catch {}
|
|
84
|
+
if (!users[req.params.username]) return res.status(404).json({ error: 'User not found' });
|
|
85
|
+
delete users[req.params.username];
|
|
86
|
+
await fsP.writeFile(org.usersFile, JSON.stringify(users, null, 2), 'utf8');
|
|
87
|
+
res.json({ success: true });
|
|
88
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── /:slug wildcard (must be last) ────────────────────────────────────────
|
|
92
|
+
router.get ('/:slug', authenticate, requireSuperAdmin, (req,res) => ctrl.getOrg(req,res));
|
|
93
|
+
router.delete('/:slug', authenticate, requireSuperAdmin, (req,res) => ctrl.deleteOrg(req,res));
|
|
94
|
+
|
|
95
|
+
// ── Super-admin UI ─────────────────────────────────────────────────────────
|
|
96
|
+
router.get('/ui/orgs', authenticateUI, requireSuperAdmin, (req,res) => ctrl.orgsPage(req,res));
|
|
97
|
+
|
|
98
|
+
module.exports = router;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Router } = require('express');
|
|
3
|
+
const bcrypt = require('bcryptjs');
|
|
4
|
+
const jwt = require('jsonwebtoken');
|
|
5
|
+
const OrgService = require('../services/OrgService');
|
|
6
|
+
const GlobalSettings = require('../services/GlobalSettingsService');
|
|
7
|
+
const config = require('../config');
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
|
|
11
|
+
// ── Email registration ────────────────────────────────────────────────────
|
|
12
|
+
router.post('/register', async (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const gs = await GlobalSettings.get();
|
|
15
|
+
if (gs.registrationMode !== 'open') return res.status(403).json({ error: 'Registration is currently closed. Contact your administrator.' });
|
|
16
|
+
|
|
17
|
+
const { orgName, username, email, password } = req.body;
|
|
18
|
+
if (!orgName || !username || !email || !password) return res.status(400).json({ error: 'All fields required' });
|
|
19
|
+
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
|
20
|
+
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) return res.status(400).json({ error: 'Username must be 3–32 alphanumeric chars' });
|
|
21
|
+
|
|
22
|
+
const org = await OrgService.create({
|
|
23
|
+
name: orgName,
|
|
24
|
+
ownerUsername: username,
|
|
25
|
+
ownerPassword: password,
|
|
26
|
+
plan: gs.defaultPlan || 'free',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Set email on user record
|
|
30
|
+
try {
|
|
31
|
+
const fsP = require('fs').promises;
|
|
32
|
+
const users = JSON.parse(await fsP.readFile(org.usersFile,'utf8'));
|
|
33
|
+
users[username].email = email;
|
|
34
|
+
await fsP.writeFile(org.usersFile, JSON.stringify(users, null, 2), 'utf8');
|
|
35
|
+
} catch {}
|
|
36
|
+
|
|
37
|
+
// Issue JWT
|
|
38
|
+
const token = jwt.sign({ username, role: 'admin', orgSlug: org.slug }, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRES_IN || '24h' });
|
|
39
|
+
res.cookie(config.SESSION_NAME, token, { httpOnly: true, sameSite: 'strict', maxAge: 86400000 });
|
|
40
|
+
res.json({ success: true, token, orgSlug: org.slug, redirect: '/dashboard' });
|
|
41
|
+
} catch(e) { res.status(e.status || 500).json({ error: e.message }); }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── Registration page ─────────────────────────────────────────────────────
|
|
45
|
+
router.get('/register', async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const gs = await GlobalSettings.get();
|
|
48
|
+
const settings = {};
|
|
49
|
+
try {
|
|
50
|
+
const SettingsService = require('../services/SettingsService');
|
|
51
|
+
Object.assign(settings, await new SettingsService(null).get());
|
|
52
|
+
} catch {}
|
|
53
|
+
const { buildThemeSnippet } = require('../lib/theme');
|
|
54
|
+
res.render('register', {
|
|
55
|
+
title: 'Create Organisation',
|
|
56
|
+
settings,
|
|
57
|
+
themeSnippet: buildThemeSnippet(settings),
|
|
58
|
+
allowedPages: [],
|
|
59
|
+
allowedCards: [],
|
|
60
|
+
user: null,
|
|
61
|
+
error: req.query.error || null,
|
|
62
|
+
githubEnabled: !!(gs.githubClientId && gs.githubClientSecret),
|
|
63
|
+
googleEnabled: !!(gs.googleClientId && gs.googleClientSecret),
|
|
64
|
+
registrationOpen: gs.registrationMode === 'open',
|
|
65
|
+
sessionName: config.SESSION_NAME,
|
|
66
|
+
baseUrl: gs.appBaseUrl || `http://localhost:${config.PORT}`,
|
|
67
|
+
});
|
|
68
|
+
} catch(e) { res.status(500).send(e.message); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── GitHub OAuth ──────────────────────────────────────────────────────────
|
|
72
|
+
router.get('/auth/github', async (req, res) => {
|
|
73
|
+
try {
|
|
74
|
+
const gs = await GlobalSettings.get();
|
|
75
|
+
if (!gs.githubClientId) return res.redirect('/register?error=GitHub+OAuth+not+configured');
|
|
76
|
+
const base = gs.appBaseUrl || `http://localhost:${config.PORT}`;
|
|
77
|
+
const state = require('crypto').randomBytes(16).toString('hex');
|
|
78
|
+
res.cookie('oauth_state', state, { httpOnly: true, maxAge: 600000 });
|
|
79
|
+
const url = `https://github.com/login/oauth/authorize?client_id=${gs.githubClientId}&redirect_uri=${encodeURIComponent(base+'/auth/github/callback')}&scope=user:email&state=${state}`;
|
|
80
|
+
res.redirect(url);
|
|
81
|
+
} catch(e) { res.redirect('/register?error='+encodeURIComponent(e.message)); }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.get('/auth/github/callback', async (req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const gs = await GlobalSettings.get();
|
|
87
|
+
const { code, state } = req.query;
|
|
88
|
+
if (!code) return res.redirect('/register?error=GitHub+auth+cancelled');
|
|
89
|
+
|
|
90
|
+
// Exchange code for token
|
|
91
|
+
const axios = require('axios');
|
|
92
|
+
const tokenRes = await axios.post('https://github.com/login/oauth/access_token', {
|
|
93
|
+
client_id: gs.githubClientId, client_secret: gs.githubClientSecret, code,
|
|
94
|
+
}, { headers: { Accept: 'application/json' } });
|
|
95
|
+
|
|
96
|
+
const accessToken = tokenRes.data.access_token;
|
|
97
|
+
if (!accessToken) return res.redirect('/register?error=GitHub+token+exchange+failed');
|
|
98
|
+
|
|
99
|
+
// Get GitHub user
|
|
100
|
+
const userRes = await axios.get('https://api.github.com/user', { headers: { Authorization: `token ${accessToken}` } });
|
|
101
|
+
const ghUser = userRes.data;
|
|
102
|
+
const username = ghUser.login;
|
|
103
|
+
const email = ghUser.email || username + '@github.oauth';
|
|
104
|
+
|
|
105
|
+
await _oauthLogin(req, res, { provider: 'github', id: String(ghUser.id), username, email, displayName: ghUser.name || username }, gs);
|
|
106
|
+
} catch(e) { res.redirect('/register?error='+encodeURIComponent(e.message)); }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── Google OAuth ──────────────────────────────────────────────────────────
|
|
110
|
+
router.get('/auth/google', async (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const gs = await GlobalSettings.get();
|
|
113
|
+
if (!gs.googleClientId) return res.redirect('/register?error=Google+OAuth+not+configured');
|
|
114
|
+
const base = gs.appBaseUrl || `http://localhost:${config.PORT}`;
|
|
115
|
+
const state = require('crypto').randomBytes(16).toString('hex');
|
|
116
|
+
res.cookie('oauth_state', state, { httpOnly: true, maxAge: 600000 });
|
|
117
|
+
const params = new URLSearchParams({
|
|
118
|
+
client_id: gs.googleClientId, redirect_uri: base+'/auth/google/callback',
|
|
119
|
+
response_type: 'code', scope: 'openid email profile', state,
|
|
120
|
+
});
|
|
121
|
+
res.redirect('https://accounts.google.com/o/oauth2/v2/auth?' + params.toString());
|
|
122
|
+
} catch(e) { res.redirect('/register?error='+encodeURIComponent(e.message)); }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
router.get('/auth/google/callback', async (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
const gs = await GlobalSettings.get();
|
|
128
|
+
const { code } = req.query;
|
|
129
|
+
if (!code) return res.redirect('/register?error=Google+auth+cancelled');
|
|
130
|
+
const base = gs.appBaseUrl || `http://localhost:${config.PORT}`;
|
|
131
|
+
|
|
132
|
+
const axios = require('axios');
|
|
133
|
+
const tokenRes = await axios.post('https://oauth2.googleapis.com/token', {
|
|
134
|
+
code, client_id: gs.googleClientId, client_secret: gs.googleClientSecret,
|
|
135
|
+
redirect_uri: base+'/auth/google/callback', grant_type: 'authorization_code',
|
|
136
|
+
});
|
|
137
|
+
const idToken = tokenRes.data.id_token;
|
|
138
|
+
const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64').toString());
|
|
139
|
+
const username = (payload.email.split('@')[0]).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
140
|
+
const email = payload.email;
|
|
141
|
+
|
|
142
|
+
await _oauthLogin(req, res, { provider: 'google', id: payload.sub, username, email, displayName: payload.name }, gs);
|
|
143
|
+
} catch(e) { res.redirect('/register?error='+encodeURIComponent(e.message)); }
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── Shared OAuth login/register logic ─────────────────────────────────────
|
|
147
|
+
async function _oauthLogin(req, res, profile, gs) {
|
|
148
|
+
const fsP = require('fs').promises;
|
|
149
|
+
const OrgSvc = require('../services/OrgService');
|
|
150
|
+
|
|
151
|
+
// Check if this OAuth account is linked to an existing org
|
|
152
|
+
const orgs = await OrgSvc.getOrgs();
|
|
153
|
+
for (const slug of Object.keys(orgs)) {
|
|
154
|
+
const p = OrgSvc.orgPaths(slug);
|
|
155
|
+
try {
|
|
156
|
+
const users = JSON.parse(await fsP.readFile(p.usersFile,'utf8'));
|
|
157
|
+
for (const u of Object.values(users)) {
|
|
158
|
+
if (u.oauthProvider === profile.provider && u.oauthId === profile.id) {
|
|
159
|
+
// Existing user — issue JWT and log in
|
|
160
|
+
const token = jwt.sign({ username: u.username, role: u.role||'admin', orgSlug: slug }, config.JWT_SECRET, { expiresIn: '24h' });
|
|
161
|
+
res.cookie(config.SESSION_NAME, token, { httpOnly: true, sameSite: 'strict', maxAge: 86400000 });
|
|
162
|
+
return res.redirect('/dashboard');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// New user — create org
|
|
169
|
+
if (gs.registrationMode !== 'open') return res.redirect('/register?error=Registration+is+closed');
|
|
170
|
+
|
|
171
|
+
let username = profile.username;
|
|
172
|
+
// Ensure username is unique across orgs
|
|
173
|
+
const taken = await _isUsernameTaken(username, orgs);
|
|
174
|
+
if (taken) username = username + '_' + Date.now().toString(36).slice(-4);
|
|
175
|
+
|
|
176
|
+
const org = await OrgSvc.create({ name: profile.displayName + "'s Org", ownerUsername: username, ownerPassword: require('crypto').randomBytes(32).toString('hex'), plan: gs.defaultPlan || 'free' });
|
|
177
|
+
|
|
178
|
+
// Update user record with OAuth info
|
|
179
|
+
try {
|
|
180
|
+
const users = JSON.parse(await fsP.readFile(org.usersFile,'utf8'));
|
|
181
|
+
users[username].email = profile.email;
|
|
182
|
+
users[username].oauthProvider = profile.provider;
|
|
183
|
+
users[username].oauthId = profile.id;
|
|
184
|
+
users[username].displayName = profile.displayName;
|
|
185
|
+
await fsP.writeFile(org.usersFile, JSON.stringify(users, null, 2), 'utf8');
|
|
186
|
+
} catch {}
|
|
187
|
+
|
|
188
|
+
const token = jwt.sign({ username, role: 'admin', orgSlug: org.slug }, config.JWT_SECRET, { expiresIn: '24h' });
|
|
189
|
+
res.cookie(config.SESSION_NAME, token, { httpOnly: true, sameSite: 'strict', maxAge: 86400000 });
|
|
190
|
+
res.redirect('/dashboard');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function _isUsernameTaken(username, orgs) {
|
|
194
|
+
const fsP = require('fs').promises;
|
|
195
|
+
for (const slug of Object.keys(orgs)) {
|
|
196
|
+
const p = require('../services/OrgService').orgPaths(slug);
|
|
197
|
+
try { const u = JSON.parse(await fsP.readFile(p.usersFile,'utf8')); if (u[username]) return true; } catch {}
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = router;
|