@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
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'Settings' }) %>
|
|
2
|
+
|
|
3
|
+
<style>
|
|
4
|
+
/* ── Settings-specific styles ─────────────────────────────────────── */
|
|
5
|
+
.settings-layout { display: grid; grid-template-columns: 200px 1fr; gap: 20px; align-items: start; }
|
|
6
|
+
.settings-nav { position: sticky; top: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
7
|
+
.settings-nav-item {
|
|
8
|
+
display: flex; align-items: center; gap: 8px;
|
|
9
|
+
padding: 8px 12px; border-radius: var(--radius); font-size: 12px; font-weight: 500;
|
|
10
|
+
color: var(--text2); cursor: pointer; transition: all .15s; text-decoration: none; border: none;
|
|
11
|
+
background: none; width: 100%; text-align: left;
|
|
12
|
+
}
|
|
13
|
+
.settings-nav-item:hover { background: var(--surface2); color: var(--text); text-decoration: none; }
|
|
14
|
+
.settings-nav-item.active { background: var(--accent-dim); color: var(--accent-l); }
|
|
15
|
+
.settings-nav-item svg { opacity: .7; flex-shrink: 0; }
|
|
16
|
+
.settings-nav-item.active svg { opacity: 1; }
|
|
17
|
+
.settings-section { scroll-margin-top: 8px; }
|
|
18
|
+
.section-header {
|
|
19
|
+
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
|
20
|
+
letter-spacing: .8px; color: var(--text3); margin: 0 0 10px;
|
|
21
|
+
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
|
22
|
+
display: flex; align-items: center; gap: 8px;
|
|
23
|
+
}
|
|
24
|
+
.section-header svg { color: var(--accent); }
|
|
25
|
+
|
|
26
|
+
/* Password strength */
|
|
27
|
+
.strength-bar { height: 3px; border-radius: 2px; background: var(--surface3); margin-top: 6px; overflow: hidden; }
|
|
28
|
+
.strength-fill { height: 100%; border-radius: 2px; transition: width .3s, background .3s; width: 0; }
|
|
29
|
+
.strength-label{ font-size: 11px; margin-top: 4px; }
|
|
30
|
+
|
|
31
|
+
/* Toggle switch */
|
|
32
|
+
.toggle-wrap { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); }
|
|
33
|
+
.toggle-info { display: flex; flex-direction: column; gap: 2px; }
|
|
34
|
+
.toggle-title { font-size: 13px; font-weight: 500; color: var(--text); }
|
|
35
|
+
.toggle-desc { font-size: 11px; color: var(--text3); }
|
|
36
|
+
.toggle { position: relative; width: 38px; height: 22px; flex-shrink: 0; }
|
|
37
|
+
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
|
|
38
|
+
.toggle-slider {
|
|
39
|
+
position: absolute; inset: 0; background: var(--surface3); border-radius: 22px;
|
|
40
|
+
cursor: pointer; transition: background .2s;
|
|
41
|
+
border: 1px solid var(--border2);
|
|
42
|
+
}
|
|
43
|
+
.toggle-slider::before {
|
|
44
|
+
content: ''; position: absolute; width: 16px; height: 16px;
|
|
45
|
+
left: 2px; bottom: 2px; background: var(--text3);
|
|
46
|
+
border-radius: 50%; transition: transform .2s, background .2s;
|
|
47
|
+
}
|
|
48
|
+
.toggle input:checked + .toggle-slider { background: var(--accent); border-color: var(--accent); }
|
|
49
|
+
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); background: #fff; }
|
|
50
|
+
|
|
51
|
+
/* Retention slider */
|
|
52
|
+
.range-row { display: flex; align-items: center; gap: 12px; }
|
|
53
|
+
.range-val {
|
|
54
|
+
min-width: 52px; text-align: center; font-family: 'JetBrains Mono', monospace;
|
|
55
|
+
font-size: 14px; font-weight: 700; color: var(--accent-l);
|
|
56
|
+
background: var(--surface2); border: 1px solid var(--border);
|
|
57
|
+
border-radius: var(--radius); padding: 4px 8px;
|
|
58
|
+
}
|
|
59
|
+
input[type=range] {
|
|
60
|
+
flex: 1; -webkit-appearance: none; height: 4px; border-radius: 2px;
|
|
61
|
+
background: var(--surface3); outline: none; cursor: pointer;
|
|
62
|
+
}
|
|
63
|
+
input[type=range]::-webkit-slider-thumb {
|
|
64
|
+
-webkit-appearance: none; width: 16px; height: 16px;
|
|
65
|
+
border-radius: 50%; background: var(--accent); cursor: pointer;
|
|
66
|
+
border: 2px solid var(--bg); box-shadow: 0 0 0 2px var(--accent);
|
|
67
|
+
transition: transform .15s;
|
|
68
|
+
}
|
|
69
|
+
input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); }
|
|
70
|
+
|
|
71
|
+
/* 2FA QR modal */
|
|
72
|
+
.modal-overlay {
|
|
73
|
+
display: none; position: fixed; inset: 0; z-index: 1000;
|
|
74
|
+
background: rgba(0,0,0,.6); backdrop-filter: blur(4px);
|
|
75
|
+
align-items: center; justify-content: center;
|
|
76
|
+
}
|
|
77
|
+
.modal-overlay.open { display: flex; }
|
|
78
|
+
.modal-box {
|
|
79
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
80
|
+
border-radius: var(--radius-lg); padding: 28px; width: 100%; max-width: 420px;
|
|
81
|
+
box-shadow: 0 24px 60px rgba(0,0,0,.5); animation: modalIn .2s ease;
|
|
82
|
+
}
|
|
83
|
+
@keyframes modalIn { from { transform: translateY(16px) scale(.97); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
84
|
+
.modal-title { font-size: 15px; font-weight: 700; margin-bottom: 4px; color: var(--text); }
|
|
85
|
+
.modal-sub { font-size: 12px; color: var(--text2); margin-bottom: 20px; }
|
|
86
|
+
.qr-wrap { display: flex; justify-content: center; margin-bottom: 20px; }
|
|
87
|
+
.qr-wrap img { border-radius: 8px; border: 4px solid #fff; }
|
|
88
|
+
|
|
89
|
+
/* Input-copy row */
|
|
90
|
+
.copy-row { display: flex; gap: 6px; }
|
|
91
|
+
.copy-row .form-input { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
|
92
|
+
|
|
93
|
+
/* Section card with left accent */
|
|
94
|
+
.card.accent-left { border-left: 3px solid var(--accent); }
|
|
95
|
+
|
|
96
|
+
/* Admin badge */
|
|
97
|
+
.admin-only-tag {
|
|
98
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
99
|
+
font-size: 10px; font-weight: 600; color: var(--yellow);
|
|
100
|
+
background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.25);
|
|
101
|
+
padding: 1px 6px; border-radius: 4px; letter-spacing: .3px;
|
|
102
|
+
}
|
|
103
|
+
</style>
|
|
104
|
+
|
|
105
|
+
<div class="app-shell">
|
|
106
|
+
<%- include('partials/sidebar') %>
|
|
107
|
+
<div class="main-area">
|
|
108
|
+
|
|
109
|
+
<header class="top-header">
|
|
110
|
+
<div class="page-title">Settings</div>
|
|
111
|
+
<div class="header-actions">
|
|
112
|
+
<span style="font-size:12px;color:var(--text3);">
|
|
113
|
+
Signed in as <strong style="color:var(--text)"><%= user.username %></strong>
|
|
114
|
+
<span class="badge <%= user.role === 'admin' ? 'badge-purple' : 'badge-info' %>" style="margin-left:6px;"><%= user.role %></span>
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
</header>
|
|
118
|
+
|
|
119
|
+
<div class="page-content">
|
|
120
|
+
|
|
121
|
+
<% const __presets = typeof PRESETS !== 'undefined' ? PRESETS : {}; %>
|
|
122
|
+
<!-- ── Theme Picker (global admin) ──────────────────────────────────────────────── -->
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
<div class="settings-layout">
|
|
127
|
+
|
|
128
|
+
<!-- ── Left sticky nav ──────────────────────────────────────── -->
|
|
129
|
+
<nav class="settings-nav" id="settings-nav">
|
|
130
|
+
<a class="settings-nav-item active" href="#account" onclick="navClick(this)">
|
|
131
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
132
|
+
Account
|
|
133
|
+
</a>
|
|
134
|
+
<a class="settings-nav-item" href="#password" onclick="navClick(this)">
|
|
135
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
136
|
+
Password
|
|
137
|
+
</a>
|
|
138
|
+
<a class="settings-nav-item" href="#twofa" onclick="navClick(this)">
|
|
139
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
|
|
140
|
+
Two-Factor Auth
|
|
141
|
+
</a>
|
|
142
|
+
<a class="settings-nav-item" href="#retention" onclick="navClick(this)">
|
|
143
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
|
144
|
+
Log Retention
|
|
145
|
+
</a>
|
|
146
|
+
<a class="settings-nav-item" href="#integrations" onclick="navClick(this)">
|
|
147
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
148
|
+
Integrations
|
|
149
|
+
</a>
|
|
150
|
+
<a class="settings-nav-item" href="#stream" onclick="navClick(this)">
|
|
151
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
|
152
|
+
Stream
|
|
153
|
+
</a>
|
|
154
|
+
<% if (isAdmin) { %>
|
|
155
|
+
<a class="settings-nav-item" href="#apikey" onclick="navClick(this)">
|
|
156
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
|
157
|
+
API Key
|
|
158
|
+
</a>
|
|
159
|
+
<% } %>
|
|
160
|
+
<a class="settings-nav-item" href="#ipwhitelist" onclick="navClick(this)">
|
|
161
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
162
|
+
IP Whitelist
|
|
163
|
+
</a>
|
|
164
|
+
<a class="settings-nav-item" href="#appearance" onclick="navClick(this)">
|
|
165
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
166
|
+
Appearance
|
|
167
|
+
</a>
|
|
168
|
+
</nav>
|
|
169
|
+
|
|
170
|
+
<!-- ── Right: settings sections ──────────────────────────────── -->
|
|
171
|
+
<div style="display:flex;flex-direction:column;gap:16px;">
|
|
172
|
+
|
|
173
|
+
<!-- ═══════════════════════════════ ACCOUNT ═══════ -->
|
|
174
|
+
<div class="card settings-section" id="account">
|
|
175
|
+
<div class="section-header">
|
|
176
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
177
|
+
Account
|
|
178
|
+
</div>
|
|
179
|
+
<div style="display:flex;align-items:center;gap:16px;">
|
|
180
|
+
<div style="width:56px;height:56px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;flex-shrink:0;">
|
|
181
|
+
<%= user.username[0].toUpperCase() %>
|
|
182
|
+
</div>
|
|
183
|
+
<div style="flex:1;">
|
|
184
|
+
<div style="font-size:18px;font-weight:700;color:var(--text);margin-bottom:4px;"><%= user.username %></div>
|
|
185
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
186
|
+
<span class="badge <%= user.role === 'admin' ? 'badge-purple' : 'badge-info' %>"><%= user.role %></span>
|
|
187
|
+
<span style="font-size:11px;color:var(--text3);">Session expires in 24h from login</span>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<button class="btn btn-danger btn-sm" onclick="doLogout()">
|
|
191
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16,17 21,12 16,7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
|
192
|
+
Sign Out
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<!-- ═══════════════════════════════ PASSWORD ══════ -->
|
|
198
|
+
<div class="card settings-section" id="password">
|
|
199
|
+
<div class="section-header">
|
|
200
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
201
|
+
Change Password
|
|
202
|
+
</div>
|
|
203
|
+
<div style="max-width:420px;display:flex;flex-direction:column;gap:14px;">
|
|
204
|
+
<div class="form-group" style="margin:0">
|
|
205
|
+
<label class="form-label">Current Password</label>
|
|
206
|
+
<input type="password" id="pw-current" class="form-input" placeholder="Enter current password" autocomplete="current-password"/>
|
|
207
|
+
</div>
|
|
208
|
+
<div class="form-group" style="margin:0">
|
|
209
|
+
<label class="form-label">New Password</label>
|
|
210
|
+
<input type="password" id="pw-new" class="form-input" placeholder="Min. 8 characters" autocomplete="new-password" oninput="updateStrength(this.value)"/>
|
|
211
|
+
<div class="strength-bar"><div id="strength-fill" class="strength-fill"></div></div>
|
|
212
|
+
<div id="strength-label" class="strength-label text-muted"></div>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="form-group" style="margin:0">
|
|
215
|
+
<label class="form-label">Confirm New Password</label>
|
|
216
|
+
<input type="password" id="pw-confirm" class="form-input" placeholder="Repeat new password" autocomplete="new-password"/>
|
|
217
|
+
</div>
|
|
218
|
+
<div>
|
|
219
|
+
<button class="btn btn-primary" id="pw-btn" onclick="changePassword()">
|
|
220
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
|
|
221
|
+
Update Password
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- ═══════════════════════════════ 2FA ══════════ -->
|
|
228
|
+
<div class="card settings-section" id="twofa">
|
|
229
|
+
<div class="section-header">
|
|
230
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
|
|
231
|
+
Two-Factor Authentication
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap;">
|
|
235
|
+
<div style="flex:1;min-width:200px;">
|
|
236
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
|
237
|
+
<span id="totp-dot" class="dot <%= totpEnabled ? 'dot-green dot-pulse' : 'dot-gray' %>"></span>
|
|
238
|
+
<span id="totp-status-text" style="font-size:14px;font-weight:600;color:var(--text)">
|
|
239
|
+
<%= totpEnabled ? 'Enabled' : 'Disabled' %>
|
|
240
|
+
</span>
|
|
241
|
+
<span id="totp-badge" class="badge <%= totpEnabled ? 'badge-green' : 'badge-debug' %>">
|
|
242
|
+
<%= totpEnabled ? 'active' : 'off' %>
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
<p style="font-size:12px;color:var(--text2);max-width:360px;line-height:1.7;">
|
|
246
|
+
Two-factor authentication adds a second layer of security to your account.
|
|
247
|
+
When enabled, you'll need to enter a time-based code from your authenticator
|
|
248
|
+
app (Google Authenticator, Authy, etc.) on every login.
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
<div id="totp-actions" style="display:flex;gap:8px;flex-shrink:0;">
|
|
252
|
+
<% if (totpEnabled) { %>
|
|
253
|
+
<button class="btn btn-danger btn-sm" id="totp-disable-btn" onclick="disable2fa()">
|
|
254
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
255
|
+
Disable 2FA
|
|
256
|
+
</button>
|
|
257
|
+
<% } else { %>
|
|
258
|
+
<button class="btn btn-primary btn-sm" id="totp-enable-btn" onclick="open2faModal()">
|
|
259
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
260
|
+
Enable 2FA
|
|
261
|
+
</button>
|
|
262
|
+
<% } %>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<!-- ═══════════════════════════════ RETENTION ═══ -->
|
|
268
|
+
<div class="card settings-section" id="retention">
|
|
269
|
+
<div class="section-header">
|
|
270
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>
|
|
271
|
+
Log Retention
|
|
272
|
+
<% if (isAdmin) { %><span class="admin-only-tag"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>Admin</span><% } %>
|
|
273
|
+
</div>
|
|
274
|
+
<% if (isAdmin) { %>
|
|
275
|
+
<div style="display:flex;flex-direction:column;gap:16px;">
|
|
276
|
+
<div>
|
|
277
|
+
<label class="form-label" style="margin-bottom:10px;">Retention Period</label>
|
|
278
|
+
<div class="range-row">
|
|
279
|
+
<input type="range" id="retention-range" min="1" max="90" value="<%= settings.retentionDays %>"
|
|
280
|
+
oninput="document.getElementById('retention-val').textContent=this.value+' days';document.getElementById('retention-num').value=this.value"/>
|
|
281
|
+
<span id="retention-val" class="range-val"><%= settings.retentionDays %> days</span>
|
|
282
|
+
</div>
|
|
283
|
+
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:4px;padding:0 2px;">
|
|
284
|
+
<span>1 day</span><span>30 days</span><span>60 days</span><span>90 days</span>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
<div style="display:flex;align-items:center;gap:10px;">
|
|
288
|
+
<div style="flex:1;">
|
|
289
|
+
<label class="form-label">Or enter exact days</label>
|
|
290
|
+
<input type="number" id="retention-num" class="form-input" min="1" max="365" value="<%= settings.retentionDays %>"
|
|
291
|
+
oninput="syncRetentionSlider(this.value)" style="max-width:120px;"/>
|
|
292
|
+
</div>
|
|
293
|
+
<button class="btn btn-primary" style="align-self:flex-end;" onclick="saveRetention()">
|
|
294
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
|
|
295
|
+
Save
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
<hr class="divider">
|
|
299
|
+
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
|
|
300
|
+
<div>
|
|
301
|
+
<div style="font-size:13px;font-weight:500;color:var(--text);margin-bottom:2px;">Manual Cleanup</div>
|
|
302
|
+
<div style="font-size:12px;color:var(--text2);">Delete all log files older than the current retention period right now.</div>
|
|
303
|
+
</div>
|
|
304
|
+
<button class="btn btn-danger btn-sm" id="cleanup-btn" onclick="runCleanup()">
|
|
305
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/></svg>
|
|
306
|
+
Run Cleanup Now
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
<% } else { %>
|
|
311
|
+
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;background:var(--surface2);border-radius:var(--radius);border:1px solid var(--border);">
|
|
312
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text3)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
313
|
+
<div>
|
|
314
|
+
<div style="font-size:13px;font-weight:500;color:var(--text);">Current Retention: <strong style="color:var(--accent-l)"><%= settings.retentionDays %> days</strong></div>
|
|
315
|
+
<div style="font-size:11px;color:var(--text3);margin-top:2px;">Only admins can change retention settings.</div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
<% } %>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<!-- ═══════════════════════════════ INTEGRATIONS ═ -->
|
|
322
|
+
<div class="card settings-section" id="integrations">
|
|
323
|
+
<div class="section-header">
|
|
324
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
325
|
+
Integrations
|
|
326
|
+
<% if (isAdmin) { %><span class="admin-only-tag"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>Admin</span><% } %>
|
|
327
|
+
</div>
|
|
328
|
+
<% if (isAdmin) { %>
|
|
329
|
+
<div style="display:flex;flex-direction:column;gap:14px;max-width:500px;">
|
|
330
|
+
<div>
|
|
331
|
+
<label class="form-label">
|
|
332
|
+
Webhook URL
|
|
333
|
+
<span style="font-size:11px;color:var(--text3);font-weight:400;margin-left:6px;">— triggered on every <span class="badge badge-error" style="font-size:9px;">error</span> log</span>
|
|
334
|
+
</label>
|
|
335
|
+
<div class="copy-row">
|
|
336
|
+
<input type="url" id="webhook-url" class="form-input" placeholder="https://hooks.slack.com/… or leave empty to disable"
|
|
337
|
+
value="<%= settings.webhookUrl %>"/>
|
|
338
|
+
<button class="btn btn-primary btn-sm" style="white-space:nowrap" onclick="saveWebhook()">Save</button>
|
|
339
|
+
</div>
|
|
340
|
+
<div style="font-size:11px;color:var(--text3);margin-top:6px;">
|
|
341
|
+
Payload: <code style="background:var(--surface3);padding:1px 5px;border-radius:3px;font-family:'JetBrains Mono',monospace;">{ "alert": "error_log", "log": { … } }</code>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div style="padding:10px 12px;background:var(--surface2);border-radius:var(--radius);border:1px solid var(--border);display:flex;align-items:center;gap:10px;">
|
|
345
|
+
<span class="dot <%= settings.webhookUrl ? 'dot-green' : 'dot-gray' %>" id="webhook-dot"></span>
|
|
346
|
+
<span id="webhook-status-text" style="font-size:12px;color:var(--text2);">
|
|
347
|
+
<%= settings.webhookUrl ? 'Webhook active — alerts are firing' : 'No webhook configured — alerts are off' %>
|
|
348
|
+
</span>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
<% } else { %>
|
|
352
|
+
<div style="padding:12px 14px;background:var(--surface2);border-radius:var(--radius);border:1px solid var(--border);">
|
|
353
|
+
<div style="font-size:13px;color:var(--text);">Webhook: <strong style="color:<%= settings.webhookUrl ? 'var(--green)' : 'var(--text3)' %>"><%= settings.webhookUrl ? 'Configured' : 'Not set' %></strong></div>
|
|
354
|
+
<div style="font-size:11px;color:var(--text3);margin-top:2px;">Only admins can configure integrations.</div>
|
|
355
|
+
</div>
|
|
356
|
+
<% } %>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<!-- ═══════════════════════════════ STREAM ═══════ -->
|
|
360
|
+
<div class="card settings-section" id="stream">
|
|
361
|
+
<div class="section-header">
|
|
362
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
|
363
|
+
Real-time Stream
|
|
364
|
+
<% if (isAdmin) { %><span class="admin-only-tag"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>Admin</span><% } %>
|
|
365
|
+
</div>
|
|
366
|
+
<div style="display:flex;flex-direction:column;gap:10px;">
|
|
367
|
+
<div class="toggle-wrap">
|
|
368
|
+
<div class="toggle-info">
|
|
369
|
+
<div class="toggle-title">Server-Sent Events (SSE)</div>
|
|
370
|
+
<div class="toggle-desc">Enables the <code style="font-family:'JetBrains Mono',monospace;font-size:11px;">/api/logs/stream</code> endpoint and the Live page real-time feed</div>
|
|
371
|
+
</div>
|
|
372
|
+
<label class="toggle">
|
|
373
|
+
<input type="checkbox" id="stream-toggle" <%= settings.enableStream ? 'checked' : '' %>
|
|
374
|
+
<% if (!isAdmin) { %>disabled<% } %>
|
|
375
|
+
onchange="<% if (isAdmin) { %>saveStream(this.checked)<% } %>"/>
|
|
376
|
+
<span class="toggle-slider"></span>
|
|
377
|
+
</label>
|
|
378
|
+
</div>
|
|
379
|
+
<% if (!isAdmin) { %>
|
|
380
|
+
<div style="font-size:11px;color:var(--text3);padding:0 2px;">Stream is currently <strong style="color:<%= settings.enableStream ? 'var(--green)' : 'var(--red)' %>"><%= settings.enableStream ? 'enabled' : 'disabled' %></strong>. Only admins can change this setting.</div>
|
|
381
|
+
<% } else { %>
|
|
382
|
+
<div style="font-size:11px;color:var(--text3);padding:0 2px;">Changes take effect immediately — connected clients will be dropped if you disable the stream.</div>
|
|
383
|
+
<% } %>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<!-- ═══════════════════════════════ API KEY ══════ -->
|
|
388
|
+
<% if (isAdmin) { %>
|
|
389
|
+
<div class="card settings-section" id="apikey">
|
|
390
|
+
<div class="section-header">
|
|
391
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
|
392
|
+
API Key
|
|
393
|
+
<span class="admin-only-tag"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>Admin</span>
|
|
394
|
+
</div>
|
|
395
|
+
<div style="display:flex;flex-direction:column;gap:12px;max-width:500px;">
|
|
396
|
+
<p style="font-size:12px;color:var(--text2);line-height:1.7;">
|
|
397
|
+
The API key is used to authenticate log ingest requests via the
|
|
398
|
+
<code style="background:var(--surface3);padding:1px 5px;border-radius:3px;font-family:'JetBrains Mono',monospace;font-size:11px;">X-Api-Key</code> header.
|
|
399
|
+
Keep it secret — treat it like a password.
|
|
400
|
+
</p>
|
|
401
|
+
<div>
|
|
402
|
+
<label class="form-label">Current API Key</label>
|
|
403
|
+
<div class="copy-row">
|
|
404
|
+
<input type="password" id="apikey-input" class="form-input" value="api-key-stored-in-env" readonly
|
|
405
|
+
style="font-family:'JetBrains Mono',monospace;letter-spacing:2px;"/>
|
|
406
|
+
<button class="btn btn-secondary btn-sm" onclick="toggleApiKey()" title="Show/hide">
|
|
407
|
+
<svg id="apikey-eye" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
408
|
+
</button>
|
|
409
|
+
<button class="btn btn-secondary btn-sm" onclick="copyApiKey()">
|
|
410
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
411
|
+
</button>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
<div style="padding:10px 14px;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);border-radius:var(--radius);display:flex;gap:10px;">
|
|
415
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--yellow)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;margin-top:1px;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
416
|
+
<div style="font-size:12px;color:var(--text2);line-height:1.6;">
|
|
417
|
+
The API key is stored in your <code style="font-family:'JetBrains Mono',monospace;font-size:11px;">.env</code> file as <code style="font-family:'JetBrains Mono',monospace;font-size:11px;">API_KEY</code>.
|
|
418
|
+
To rotate it, update the <code style="font-family:'JetBrains Mono',monospace;font-size:11px;">.env</code> file and restart the server.
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
<div>
|
|
422
|
+
<label class="form-label" style="margin-bottom:6px;">Usage Example</label>
|
|
423
|
+
<pre style="font-family:'JetBrains Mono',monospace;font-size:11px;background:var(--surface3);padding:12px;border-radius:6px;color:var(--text2);overflow-x:auto;line-height:1.8;border:1px solid var(--border);">curl -X POST http://localhost:9900/api/logs \
|
|
424
|
+
-H "X-Api-Key: <your-key>" \
|
|
425
|
+
-H "Content-Type: application/json" \
|
|
426
|
+
-d '{"appName":"myapp","logs":["..."]}'</pre>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
<% } %>
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
<!-- ═══════════════════════════════ IP WHITELIST ══ -->
|
|
434
|
+
<% if (isAdmin) { %>
|
|
435
|
+
<div class="card settings-section" id="ipwhitelist">
|
|
436
|
+
<div class="section-header">
|
|
437
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
438
|
+
IP Whitelist
|
|
439
|
+
<span style="font-size:10px;font-weight:600;background:rgba(245,158,11,.12);color:var(--yellow);border:1px solid rgba(245,158,11,.25);padding:1px 6px;border-radius:4px;">Admin</span>
|
|
440
|
+
</div>
|
|
441
|
+
<div style="max-width:500px;display:flex;flex-direction:column;gap:12px;">
|
|
442
|
+
<div style="font-size:12px;color:var(--text2);line-height:1.7;">
|
|
443
|
+
Restrict log ingest (<code style="font-family:'JetBrains Mono',monospace;background:var(--surface3);padding:1px 5px;border-radius:3px;">POST /api/logs</code>) to specific IPs.
|
|
444
|
+
Leave empty to allow all. Supports exact IPs and <code style="font-family:'JetBrains Mono',monospace;background:var(--surface3);padding:1px 5px;border-radius:3px;">/24</code> prefixes (e.g. <code style="font-family:'JetBrains Mono',monospace;background:var(--surface3);padding:1px 5px;border-radius:3px;">192.168.1.0/24</code>).
|
|
445
|
+
</div>
|
|
446
|
+
<div>
|
|
447
|
+
<label class="form-label">Allowed IPs (one per line)</label>
|
|
448
|
+
<textarea id="ip-whitelist" class="form-input" rows="5"
|
|
449
|
+
placeholder="192.168.1.100 10.0.0.0/24 (empty = allow all)"
|
|
450
|
+
style="font-family:'JetBrains Mono',monospace;font-size:12px;resize:vertical;"><%= (settings.ipWhitelist||[]).join('\n') %></textarea>
|
|
451
|
+
</div>
|
|
452
|
+
<div>
|
|
453
|
+
<button class="btn btn-primary btn-sm" onclick="saveIPWhitelist()">Save Whitelist</button>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
<% } %>
|
|
458
|
+
|
|
459
|
+
<!-- ═══════════════════════════════ BRANDING ═══ -->
|
|
460
|
+
<% if (isAdmin) { %>
|
|
461
|
+
<div class="card settings-section" id="branding">
|
|
462
|
+
<div class="section-header">
|
|
463
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/></svg>
|
|
464
|
+
Branding & App Name
|
|
465
|
+
</div>
|
|
466
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px;">
|
|
467
|
+
<div class="form-group" style="margin:0">
|
|
468
|
+
<label class="form-label">App Name</label>
|
|
469
|
+
<input type="text" id="brand-name" class="form-input" value="<%= settings.appName || 'LogBoard' %>" maxlength="64" placeholder="LogBoard"/>
|
|
470
|
+
</div>
|
|
471
|
+
<div class="form-group" style="margin:0">
|
|
472
|
+
<label class="form-label">Logo URL <span style="color:var(--text3);font-weight:400">(PNG/SVG)</span></label>
|
|
473
|
+
<input type="text" id="brand-logo" class="form-input" value="<%= settings.appLogoUrl !== '/public/logo.png' ? settings.appLogoUrl : '' %>" placeholder="/public/logo.png or https://…"/>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
<!-- Favicon URL -->
|
|
477
|
+
<div class="form-group" style="margin:8px 0 0;">
|
|
478
|
+
<label class="form-label">Favicon URL <span style="color:var(--text3);font-weight:400">(leave blank for default)</span></label>
|
|
479
|
+
<div style="display:flex;gap:8px;align-items:center;">
|
|
480
|
+
<input type="text" id="brand-favicon" class="form-input" style="flex:1"
|
|
481
|
+
value="<%= settings.faviconUrl || '' %>"
|
|
482
|
+
placeholder="https://yoursite.com/favicon.ico"
|
|
483
|
+
oninput="previewFavicon(this.value)"/>
|
|
484
|
+
<img id="favicon-preview" src="<%= settings.faviconUrl || '' %>"
|
|
485
|
+
style="width:28px;height:28px;border-radius:4px;object-fit:contain;background:var(--surface2);padding:3px;<%=settings.faviconUrl?'':'display:none;'%>"
|
|
486
|
+
onerror="this.style.display='none'"/>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
<div style="display:flex;gap:8px;align-items:center;margin-top:12px;">
|
|
490
|
+
<button class="btn btn-primary btn-sm" onclick="saveBranding()">Save Branding</button>
|
|
491
|
+
<span style="font-size:11px;color:var(--text3);">Changes apply on next page load</span>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
<% } %>
|
|
495
|
+
|
|
496
|
+
<!-- ═══════════════════════════════ THEME & APPEARANCE (additional settings) ═══ -->
|
|
497
|
+
<div class="card settings-section" id="appearance">
|
|
498
|
+
<div class="section-header">
|
|
499
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r="2.5"/><circle cx="6.5" cy="20" r="2.5"/><circle cx="20" cy="20" r="2.5"/><path d="M13.5 9v10.5"/></svg>
|
|
500
|
+
Theme & Appearance
|
|
501
|
+
<span style="font-size:11px;color:var(--text3);font-weight:400;margin-left:4px;">— admin sets the global accent; all users see it</span>
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
<!-- Preset circles -->
|
|
505
|
+
<div style="margin-bottom:16px;">
|
|
506
|
+
<div style="font-size:11px;font-weight:500;color:var(--text3);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;">Presets</div>
|
|
507
|
+
<div style="display:flex;gap:10px;flex-wrap:wrap;" id="preset-grid2">
|
|
508
|
+
<% Object.entries(__presets).forEach(function(kv){ var pid=kv[0], p=kv[1]; %>
|
|
509
|
+
<div
|
|
510
|
+
onclick="applyPreset('<%= pid %>',<%= p.r %>,<%= p.g %>,<%= p.b %>,'<%= p.mode %>')"
|
|
511
|
+
title="<%= p.label %>"
|
|
512
|
+
id="preset-<%= pid %>"
|
|
513
|
+
style="width:40px;height:40px;border-radius:50%;background:rgb(<%= p.r %>,<%= p.g %>,<%= p.b %>);cursor:pointer;transition:all .15s;border:3px solid <%= settings.themeId===pid ? '#fff' : 'transparent' %>;box-shadow:<%= settings.themeId===pid ? '0 0 0 3px rgb('+p.r+','+p.g+','+p.b+')' : 'none' %>;"
|
|
514
|
+
></div>
|
|
515
|
+
<% }) %>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<!-- Custom RGB -->
|
|
520
|
+
<div style="margin-bottom:16px;">
|
|
521
|
+
<div style="font-size:11px;font-weight:500;color:var(--text3);margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px;">Custom Accent Color</div>
|
|
522
|
+
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
|
|
523
|
+
<input type="color" id="accent-hex"
|
|
524
|
+
value="<%= '#' + [settings.accentR||99, settings.accentG||102, settings.accentB||241].map(v=>Number(v).toString(16).padStart(2,'0')).join('') %>"
|
|
525
|
+
style="width:48px;height:48px;border:none;background:none;cursor:pointer;border-radius:10px;padding:2px;"
|
|
526
|
+
oninput="hexToSliders(this.value)"/>
|
|
527
|
+
<div style="flex:1;min-width:220px;display:flex;flex-direction:column;gap:8px;">
|
|
528
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
529
|
+
<span style="font-size:10px;color:var(--text3);width:12px;">R</span>
|
|
530
|
+
<input type="range" id="slider-r" min="0" max="255" value="<%= settings.accentR||99 %>" style="flex:1;" oninput="updatePreview()"/>
|
|
531
|
+
<span id="r-val" style="font-size:11px;font-family:monospace;width:26px;text-align:right;"><%= settings.accentR||99 %></span>
|
|
532
|
+
</div>
|
|
533
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
534
|
+
<span style="font-size:10px;color:var(--text3);width:12px;">G</span>
|
|
535
|
+
<input type="range" id="slider-g" min="0" max="255" value="<%= settings.accentG||102 %>" style="flex:1;" oninput="updatePreview()"/>
|
|
536
|
+
<span id="g-val" style="font-size:11px;font-family:monospace;width:26px;text-align:right;"><%= settings.accentG||102 %></span>
|
|
537
|
+
</div>
|
|
538
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
539
|
+
<span style="font-size:10px;color:var(--text3);width:12px;">B</span>
|
|
540
|
+
<input type="range" id="slider-b" min="0" max="255" value="<%= settings.accentB||241 %>" style="flex:1;" oninput="updatePreview()"/>
|
|
541
|
+
<span id="b-val" style="font-size:11px;font-family:monospace;width:26px;text-align:right;"><%= settings.accentB||241 %></span>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
<!-- Live preview swatch -->
|
|
545
|
+
<div id="theme-preview2"
|
|
546
|
+
style="width:100px;height:48px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:#fff;background:rgb(<%= settings.accentR||99 %>,<%= settings.accentG||102 %>,<%= settings.accentB||241 %>);flex-shrink:0;">
|
|
547
|
+
Preview
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
<!-- Light / Dark mode -->
|
|
553
|
+
<div style="display:flex;align-items:center;gap:20px;margin-bottom:16px;">
|
|
554
|
+
<span style="font-size:12px;font-weight:500;color:var(--text2);">Default Mode:</span>
|
|
555
|
+
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
|
|
556
|
+
<input type="radio" name="themeMode2" value="dark" <%= (settings.themeMode||'dark')==='dark' ?'checked':'' %> onchange="document.getElementById('theme-mode-val').value='dark'"/> Dark
|
|
557
|
+
</label>
|
|
558
|
+
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
|
|
559
|
+
<input type="radio" name="themeMode2" value="light" <%= settings.themeMode==='light'?'checked':'' %> onchange="document.getElementById('theme-mode-val').value='light'"/> Light
|
|
560
|
+
</label>
|
|
561
|
+
<input type="hidden" id="theme-mode-val" value="<%= settings.themeMode||'dark' %>"/>
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
<div style="display:flex;gap:8px;align-items:center;">
|
|
565
|
+
<button class="btn btn-primary btn-sm" onclick="saveTheme()">Apply Theme for All Users</button>
|
|
566
|
+
<span style="font-size:11px;color:var(--text3);">Saved to settings.json — takes effect on next page load</span>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
<!-- ═══════════════════════════════ HEALTH ALERTS ══ -->
|
|
571
|
+
<% if (isAdmin) { %>
|
|
572
|
+
<div class="card settings-section" id="health-alerts">
|
|
573
|
+
<div class="section-header">
|
|
574
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
|
575
|
+
Server Health Alerts
|
|
576
|
+
<span style="font-size:11px;color:var(--text3);font-weight:400;">— fires Slack/Discord/email when thresholds exceeded</span>
|
|
577
|
+
</div>
|
|
578
|
+
<div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7;">
|
|
579
|
+
Alerts fire when the <strong>LogBoard server's own</strong> RAM, CPU, or disk exceeds the threshold. Uses a 15-minute cooldown to avoid spam.
|
|
580
|
+
Per-service alerts are configured in <a href="/alerts" style="color:var(--accent-l);">Alert Rules</a>.
|
|
581
|
+
</div>
|
|
582
|
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:14px;">
|
|
583
|
+
<div class="form-group" style="margin:0">
|
|
584
|
+
<label class="form-label">RAM Threshold (%)</label>
|
|
585
|
+
<input type="number" id="alert-ram" class="form-input" value="<%= settings.alertRamPct || 85 %>" min="10" max="99"/>
|
|
586
|
+
</div>
|
|
587
|
+
<div class="form-group" style="margin:0">
|
|
588
|
+
<label class="form-label">CPU Threshold (%)</label>
|
|
589
|
+
<input type="number" id="alert-cpu" class="form-input" value="<%= settings.alertCpuPct || 90 %>" min="10" max="99"/>
|
|
590
|
+
</div>
|
|
591
|
+
<div class="form-group" style="margin:0">
|
|
592
|
+
<label class="form-label">Disk Threshold (%)</label>
|
|
593
|
+
<input type="number" id="alert-disk" class="form-input" value="<%= settings.alertDiskPct || 90 %>" min="10" max="99"/>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
|
597
|
+
<label class="toggle"><input type="checkbox" id="alerts-enabled" <%= settings.alertsEnabled!==false?'checked':'' %>/><span class="toggle-slider"></span></label>
|
|
598
|
+
<span style="font-size:12px;color:var(--text2);">Enable health alerts</span>
|
|
599
|
+
<button class="btn btn-primary btn-sm" style="margin-left:auto;" onclick="saveHealthAlerts()">Save Thresholds</button>
|
|
600
|
+
<button class="btn btn-secondary btn-sm" onclick="testWebhook()">Test Slack</button>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
<% } %>
|
|
604
|
+
|
|
605
|
+
<!-- ═══════════════════════════════ EMAIL & REPORTS ══ -->
|
|
606
|
+
<% if (isAdmin) { %>
|
|
607
|
+
<div class="card settings-section" id="email-settings">
|
|
608
|
+
<div class="section-header">
|
|
609
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
|
610
|
+
Email Alerts & Scheduled Reports
|
|
611
|
+
</div>
|
|
612
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
|
|
613
|
+
<div class="form-group" style="margin:0"><label class="form-label">SMTP Host</label><input type="text" id="smtp-host" class="form-input" value="<%= settings.smtpHost||'' %>" placeholder="smtp.gmail.com"/></div>
|
|
614
|
+
<div class="form-group" style="margin:0"><label class="form-label">SMTP Port</label><input type="number" id="smtp-port" class="form-input" value="<%= settings.smtpPort||587 %>" placeholder="587"/></div>
|
|
615
|
+
<div class="form-group" style="margin:0"><label class="form-label">SMTP Username</label><input type="text" id="smtp-user" class="form-input" value="<%= settings.smtpUser||'' %>" placeholder="user@gmail.com"/></div>
|
|
616
|
+
<div class="form-group" style="margin:0"><label class="form-label">SMTP Password</label><input type="password" id="smtp-pass" class="form-input" placeholder="App password"/></div>
|
|
617
|
+
<div class="form-group" style="margin:0"><label class="form-label">From Address</label><input type="email" id="smtp-from" class="form-input" value="<%= settings.smtpFrom||'' %>" placeholder="logboard@example.com"/></div>
|
|
618
|
+
<div class="form-group" style="margin:0"><label class="form-label">Report Recipient</label><input type="email" id="report-email" class="form-input" value="<%= settings.reportEmail||'' %>" placeholder="team@company.com"/></div>
|
|
619
|
+
</div>
|
|
620
|
+
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px;flex-wrap:wrap;">
|
|
621
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;"><input type="checkbox" id="email-enabled" <%= settings.emailEnabled?'checked':'' %>/> Enable email alerts on rules</label>
|
|
622
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;"><input type="checkbox" id="report-enabled" <%= settings.reportEnabled?'checked':'' %>/> Enable scheduled reports</label>
|
|
623
|
+
<select id="report-schedule" class="form-select" style="width:110px;">
|
|
624
|
+
<option value="daily" <%= settings.reportSchedule==='daily' ?'selected':'' %>>Daily</option>
|
|
625
|
+
<option value="weekly" <%= settings.reportSchedule==='weekly'?'selected':'' %>>Weekly (Mon)</option>
|
|
626
|
+
</select>
|
|
627
|
+
</div>
|
|
628
|
+
<div style="display:flex;gap:8px;">
|
|
629
|
+
<button class="btn btn-primary btn-sm" onclick="saveEmailSettings()">Save Email Settings</button>
|
|
630
|
+
<button class="btn btn-secondary btn-sm" onclick="testEmailNow()">Send Test Report Now</button>
|
|
631
|
+
<% if(settings.lastReportAt){ %>
|
|
632
|
+
<span style="font-size:11px;color:var(--green);margin-left:8px;">✓ Last sent: <%=new Date(settings.lastReportAt).toLocaleString()%></span>
|
|
633
|
+
<% } else { %>
|
|
634
|
+
<span style="font-size:11px;color:var(--text3);margin-left:8px;">Not sent yet</span>
|
|
635
|
+
<% } %>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
<% } %>
|
|
639
|
+
|
|
640
|
+
<!-- ═══════════════════════════════ INTEGRATIONS (Discord & PagerDuty) ══ -->
|
|
641
|
+
<% if (isAdmin) { %>
|
|
642
|
+
<div class="card settings-section" id="integrations-section">
|
|
643
|
+
<div class="section-header">
|
|
644
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
645
|
+
Integrations — Discord & PagerDuty
|
|
646
|
+
</div>
|
|
647
|
+
<div style="display:grid;grid-template-columns:1fr;gap:12px;margin-bottom:12px;">
|
|
648
|
+
<div class="form-group" style="margin:0">
|
|
649
|
+
<label class="form-label">Discord Webhook URL <span style="color:var(--text3);font-weight:400">(global — override per alert rule)</span></label>
|
|
650
|
+
<input type="url" id="discord-url" class="form-input" value="<%= settings.discordUrl||'' %>" placeholder="https://discord.com/api/webhooks/…"/>
|
|
651
|
+
</div>
|
|
652
|
+
<div class="form-group" style="margin:0">
|
|
653
|
+
<label class="form-label">PagerDuty Routing Key <span style="color:var(--text3);font-weight:400">(Events API v2)</span></label>
|
|
654
|
+
<input type="text" id="pd-key" class="form-input" value="<%= settings.pagerdutyKey||'' %>" placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"/>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px;">
|
|
658
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;"><input type="checkbox" id="pd-enabled" <%= settings.pagerdutyEnabled?'checked':'' %>/> Enable PagerDuty alerts</label>
|
|
659
|
+
</div>
|
|
660
|
+
<div style="display:flex;gap:8px;">
|
|
661
|
+
<button class="btn btn-primary btn-sm" onclick="saveIntegrations()">Save</button>
|
|
662
|
+
<button class="btn btn-secondary btn-sm" onclick="testWebhook()">Test Slack Webhook</button>
|
|
663
|
+
<button class="btn btn-secondary btn-sm" onclick="testDiscord()">Test Discord</button>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
<% } %>
|
|
667
|
+
|
|
668
|
+
</div><!-- end settings-content -->
|
|
669
|
+
</div><!-- end settings-layout -->
|
|
670
|
+
</div><!-- end page-content -->
|
|
671
|
+
</div><!-- end main-area -->
|
|
672
|
+
</div><!-- end app-shell -->
|
|
673
|
+
|
|
674
|
+
<!-- 2FA Modal -->
|
|
675
|
+
<div id="twofa-modal" class="modal-overlay">
|
|
676
|
+
<div class="modal-box">
|
|
677
|
+
<div class="modal-title">Enable Two-Factor Authentication</div>
|
|
678
|
+
<div class="modal-sub">Scan the QR code with Google Authenticator or similar app.</div>
|
|
679
|
+
<div id="modal-loading" style="text-align:center;padding:20px;">Loading QR…</div>
|
|
680
|
+
<div id="modal-content" style="display:none;">
|
|
681
|
+
<div class="qr-wrap"><img id="qr-img" alt="QR code" style="width:180px;height:180px;"></div>
|
|
682
|
+
<div>Secret (backup):</div>
|
|
683
|
+
<div class="copy-row" style="margin:8px 0 12px;">
|
|
684
|
+
<input type="text" id="totp-secret-display" class="form-input" readonly>
|
|
685
|
+
<button class="btn btn-secondary btn-sm" onclick="copySecret()">Copy</button>
|
|
686
|
+
</div>
|
|
687
|
+
<div>Enter code:</div>
|
|
688
|
+
<input type="text" id="totp-verify-input" class="form-input" placeholder="6-digit code" maxlength="6" style="margin:6px 0 12px;">
|
|
689
|
+
<div id="modal-error" style="color:var(--red);font-size:12px;margin-bottom:12px;display:none;"></div>
|
|
690
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
|
691
|
+
<button class="btn btn-secondary btn-sm" onclick="close2faModal()">Cancel</button>
|
|
692
|
+
<button class="btn btn-primary btn-sm" id="modal-confirm-btn" onclick="verifyAndEnable2fa()">Enable</button>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
|
|
698
|
+
<script>
|
|
699
|
+
// Duplicate theme preview functions for second appearance (to avoid conflicts)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
function applyPreset(id,r,g,b,mode){
|
|
703
|
+
document.getElementById('slider-r').value=r;
|
|
704
|
+
document.getElementById('slider-g').value=g;
|
|
705
|
+
document.getElementById('slider-b').value=b;
|
|
706
|
+
document.getElementById('theme-mode-val').value=mode;
|
|
707
|
+
document.querySelectorAll('input[name="themeMode"]').forEach(el => { el.checked=(el.value===mode); });
|
|
708
|
+
document.querySelectorAll('[id^="preset-"]').forEach(el => { el.style.border='2px solid transparent';el.style.boxShadow='none';el.dataset.selected='0'; });
|
|
709
|
+
const el=document.getElementById('preset-'+id);
|
|
710
|
+
if(el){el.style.border='2px solid #fff';el.style.boxShadow='0 0 0 2px var(--accent)';el.dataset.selected='1';}
|
|
711
|
+
updatePreview();
|
|
712
|
+
// Also update second section
|
|
713
|
+
document.getElementById('slider-r').value=r;
|
|
714
|
+
document.getElementById('slider-g').value=g;
|
|
715
|
+
document.getElementById('slider-b').value=b;
|
|
716
|
+
document.getElementById('theme-mode-val').value=mode;
|
|
717
|
+
document.querySelectorAll('input[name="themeMode2"]').forEach(el => { el.checked=(el.value===mode); });
|
|
718
|
+
document.querySelectorAll('[id^="preset-"]').forEach(el => { el.style.border='3px solid transparent';el.style.boxShadow='none';el.dataset.selected2='0'; });
|
|
719
|
+
const el2=document.getElementById('preset-'+id);
|
|
720
|
+
if(el2){el2.style.border='3px solid #fff';el2.style.boxShadow='0 0 0 3px rgb('+r+','+g+','+b+')';el2.dataset.selected2='1';}
|
|
721
|
+
updatePreview();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
// ── Theme Picker (first) ──────────────────────────────────────────────────────────────
|
|
726
|
+
function updatePreview() {
|
|
727
|
+
const r=+document.getElementById('slider-r').value;
|
|
728
|
+
const g=+document.getElementById('slider-g').value;
|
|
729
|
+
const b=+document.getElementById('slider-b').value;
|
|
730
|
+
document.getElementById('r-val').textContent=r;
|
|
731
|
+
document.getElementById('g-val').textContent=g;
|
|
732
|
+
document.getElementById('b-val').textContent=b;
|
|
733
|
+
const hex='#'+[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('');
|
|
734
|
+
document.getElementById('accent-hex').value=hex;
|
|
735
|
+
document.getElementById('theme-preview').style.background='rgb('+r+','+g+','+b+')';
|
|
736
|
+
// Live CSS preview — immediate apply
|
|
737
|
+
const lr=Math.min(255,r+55),lg=Math.min(255,g+55),lb=Math.min(255,b+55);
|
|
738
|
+
const root=document.documentElement;
|
|
739
|
+
root.style.setProperty('--accent', 'rgb('+r+','+g+','+b+')');
|
|
740
|
+
root.style.setProperty('--accent-l', 'rgb('+lr+','+lg+','+lb+')');
|
|
741
|
+
root.style.setProperty('--accent-dim', 'rgba('+r+','+g+','+b+',.15)');
|
|
742
|
+
}
|
|
743
|
+
function hexToSliders(hex){
|
|
744
|
+
if(!/^#[0-9a-f]{6}$/i.test(hex)) return;
|
|
745
|
+
const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
|
|
746
|
+
document.getElementById('slider-r').value=r;
|
|
747
|
+
document.getElementById('slider-g').value=g;
|
|
748
|
+
document.getElementById('slider-b').value=b;
|
|
749
|
+
updatePreview();
|
|
750
|
+
}
|
|
751
|
+
async function saveTheme(){
|
|
752
|
+
const r = +document.getElementById('slider-r').value;
|
|
753
|
+
const g = +document.getElementById('slider-g').value;
|
|
754
|
+
const b = +document.getElementById('slider-b').value;
|
|
755
|
+
const mode = document.getElementById('theme-mode-val').value;
|
|
756
|
+
// Get current themeId from selected preset (or 'custom')
|
|
757
|
+
const selectedPreset = document.querySelector('[id^="preset-"][data-selected="1"]');
|
|
758
|
+
const themeId = selectedPreset ? selectedPreset.id.replace('preset-','') : 'custom';
|
|
759
|
+
try {
|
|
760
|
+
const res = await fetch('/api/settings', {
|
|
761
|
+
method: 'POST',
|
|
762
|
+
headers: {'Content-Type':'application/json'},
|
|
763
|
+
body: JSON.stringify({ accentR:r, accentG:g, accentB:b, themeMode:mode, themeId })
|
|
764
|
+
});
|
|
765
|
+
const d = await res.json();
|
|
766
|
+
if (!res.ok) { toast(d.error||'Save failed','error'); return; }
|
|
767
|
+
toast('Theme saved! Reloading…','success');
|
|
768
|
+
// Apply locally immediately before reload
|
|
769
|
+
document.documentElement.setAttribute('data-theme', mode);
|
|
770
|
+
localStorage.setItem('theme', mode);
|
|
771
|
+
// Apply CSS vars immediately
|
|
772
|
+
const lr=Math.min(255,r+55), lg=Math.min(255,g+55), lb=Math.min(255,b+55);
|
|
773
|
+
document.documentElement.style.setProperty('--accent', `rgb(${r},${g},${b})`);
|
|
774
|
+
document.documentElement.style.setProperty('--accent-l', `rgb(${lr},${lg},${lb})`);
|
|
775
|
+
document.documentElement.style.setProperty('--accent-dim',`rgba(${r},${g},${b},.15)`);
|
|
776
|
+
setTimeout(() => location.reload(), 800);
|
|
777
|
+
} catch(e) { toast('Network error','error'); }
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ── Nav highlight on scroll ────────────────────────────────────────────
|
|
781
|
+
function navClick(el) {
|
|
782
|
+
document.querySelectorAll('.settings-nav-item').forEach(i => i.classList.remove('active'));
|
|
783
|
+
el.classList.add('active');
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const sections = document.querySelectorAll('.settings-section');
|
|
787
|
+
const navItems = document.querySelectorAll('.settings-nav-item');
|
|
788
|
+
window.addEventListener('scroll', () => {
|
|
789
|
+
let current = '';
|
|
790
|
+
sections.forEach(s => { if (window.scrollY >= s.offsetTop - 80) current = s.id; });
|
|
791
|
+
navItems.forEach(n => {
|
|
792
|
+
n.classList.toggle('active', n.getAttribute('href') === '#' + current);
|
|
793
|
+
});
|
|
794
|
+
}, { passive: true });
|
|
795
|
+
|
|
796
|
+
// ── Password strength ──────────────────────────────────────────────────
|
|
797
|
+
function updateStrength(v) {
|
|
798
|
+
const fill = document.getElementById('strength-fill');
|
|
799
|
+
const label = document.getElementById('strength-label');
|
|
800
|
+
if (!v) { fill.style.width='0'; label.textContent=''; return; }
|
|
801
|
+
let score = 0;
|
|
802
|
+
if (v.length >= 8) score++;
|
|
803
|
+
if (v.length >= 12) score++;
|
|
804
|
+
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++;
|
|
805
|
+
if (/[0-9]/.test(v)) score++;
|
|
806
|
+
if (/[^A-Za-z0-9]/.test(v)) score++;
|
|
807
|
+
const levels = [
|
|
808
|
+
{ w:'20%', c:'var(--red)', t:'Very weak' },
|
|
809
|
+
{ w:'40%', c:'#f97316', t:'Weak' },
|
|
810
|
+
{ w:'60%', c:'var(--yellow)',t:'Fair' },
|
|
811
|
+
{ w:'80%', c:'#84cc16', t:'Strong' },
|
|
812
|
+
{ w:'100%',c:'var(--green)', t:'Very strong' },
|
|
813
|
+
];
|
|
814
|
+
const l = levels[Math.min(score, 4)];
|
|
815
|
+
fill.style.width = l.w;
|
|
816
|
+
fill.style.background = l.c;
|
|
817
|
+
label.textContent = l.t;
|
|
818
|
+
label.style.color = l.c;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ── Change password ────────────────────────────────────────────────────
|
|
822
|
+
async function changePassword() {
|
|
823
|
+
const current = document.getElementById('pw-current').value.trim();
|
|
824
|
+
const next = document.getElementById('pw-new').value;
|
|
825
|
+
const confirm = document.getElementById('pw-confirm').value;
|
|
826
|
+
if (!current || !next) return toast('All fields required', 'error');
|
|
827
|
+
if (next.length < 8) return toast('New password must be ≥8 characters', 'error');
|
|
828
|
+
if (next !== confirm) return toast('Passwords do not match', 'error');
|
|
829
|
+
const btn = document.getElementById('pw-btn');
|
|
830
|
+
btn.disabled = true; btn.textContent = 'Saving…';
|
|
831
|
+
try {
|
|
832
|
+
const r = await fetch('/api/auth/change-password', {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
headers: { 'Content-Type': 'application/json' },
|
|
835
|
+
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
|
836
|
+
});
|
|
837
|
+
const d = await r.json();
|
|
838
|
+
if (!r.ok) throw new Error(d.error);
|
|
839
|
+
toast('Password updated successfully', 'success');
|
|
840
|
+
document.getElementById('pw-current').value = '';
|
|
841
|
+
document.getElementById('pw-new').value = '';
|
|
842
|
+
document.getElementById('pw-confirm').value = '';
|
|
843
|
+
document.getElementById('strength-fill').style.width = '0';
|
|
844
|
+
document.getElementById('strength-label').textContent = '';
|
|
845
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
846
|
+
finally { btn.disabled = false; btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg> Update Password'; }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ── 2FA ────────────────────────────────────────────────────────────────
|
|
850
|
+
let _totpSecret = '';
|
|
851
|
+
|
|
852
|
+
async function open2faModal() {
|
|
853
|
+
document.getElementById('twofa-modal').classList.add('open');
|
|
854
|
+
document.getElementById('modal-loading').style.display = '';
|
|
855
|
+
document.getElementById('modal-content').style.display = 'none';
|
|
856
|
+
document.getElementById('modal-error').style.display = 'none';
|
|
857
|
+
document.getElementById('totp-verify-input').value = '';
|
|
858
|
+
try {
|
|
859
|
+
const r = await fetch('/api/auth/2fa/setup');
|
|
860
|
+
const d = await r.json();
|
|
861
|
+
if (!r.ok) throw new Error(d.error);
|
|
862
|
+
_totpSecret = d.secret;
|
|
863
|
+
document.getElementById('qr-img').src = d.qr;
|
|
864
|
+
document.getElementById('totp-secret-display').value = d.secret;
|
|
865
|
+
document.getElementById('modal-loading').style.display = 'none';
|
|
866
|
+
document.getElementById('modal-content').style.display = '';
|
|
867
|
+
} catch (e) {
|
|
868
|
+
document.getElementById('modal-loading').textContent = 'Error: ' + e.message;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function close2faModal() {
|
|
873
|
+
document.getElementById('twofa-modal').classList.remove('open');
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function copySecret() {
|
|
877
|
+
navigator.clipboard.writeText(_totpSecret).then(() => toast('Secret copied!', 'success'));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function verifyAndEnable2fa() {
|
|
881
|
+
const token = document.getElementById('totp-verify-input').value.trim();
|
|
882
|
+
if (token.length !== 6) return;
|
|
883
|
+
const btn = document.getElementById('modal-confirm-btn');
|
|
884
|
+
btn.disabled = true;
|
|
885
|
+
const errEl = document.getElementById('modal-error');
|
|
886
|
+
errEl.style.display = 'none';
|
|
887
|
+
try {
|
|
888
|
+
const r = await fetch('/api/auth/2fa/enable', {
|
|
889
|
+
method: 'POST',
|
|
890
|
+
headers: { 'Content-Type': 'application/json' },
|
|
891
|
+
body: JSON.stringify({ secret: _totpSecret, token }),
|
|
892
|
+
});
|
|
893
|
+
const d = await r.json();
|
|
894
|
+
if (!r.ok) throw new Error(d.error);
|
|
895
|
+
close2faModal();
|
|
896
|
+
toast('Two-factor authentication enabled!', 'success');
|
|
897
|
+
// Update UI
|
|
898
|
+
document.getElementById('totp-dot').className = 'dot dot-green dot-pulse';
|
|
899
|
+
document.getElementById('totp-status-text').textContent = 'Enabled';
|
|
900
|
+
document.getElementById('totp-badge').className = 'badge badge-green';
|
|
901
|
+
document.getElementById('totp-badge').textContent = 'active';
|
|
902
|
+
document.getElementById('totp-actions').innerHTML = `
|
|
903
|
+
<button class="btn btn-danger btn-sm" onclick="disable2fa()">
|
|
904
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
905
|
+
Disable 2FA
|
|
906
|
+
</button>`;
|
|
907
|
+
} catch (e) {
|
|
908
|
+
errEl.textContent = e.message;
|
|
909
|
+
errEl.style.display = '';
|
|
910
|
+
}
|
|
911
|
+
finally { btn.disabled = false; }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function disable2fa() {
|
|
915
|
+
if (!confirm('Disable two-factor authentication? Your account will be less secure.')) return;
|
|
916
|
+
try {
|
|
917
|
+
const r = await fetch('/api/auth/2fa/disable', { method: 'POST' });
|
|
918
|
+
const d = await r.json();
|
|
919
|
+
if (!r.ok) throw new Error(d.error);
|
|
920
|
+
toast('Two-factor authentication disabled', 'info');
|
|
921
|
+
document.getElementById('totp-dot').className = 'dot dot-gray';
|
|
922
|
+
document.getElementById('totp-status-text').textContent = 'Disabled';
|
|
923
|
+
document.getElementById('totp-badge').className = 'badge badge-debug';
|
|
924
|
+
document.getElementById('totp-badge').textContent = 'off';
|
|
925
|
+
document.getElementById('totp-actions').innerHTML = `
|
|
926
|
+
<button class="btn btn-primary btn-sm" onclick="open2faModal()">
|
|
927
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
928
|
+
Enable 2FA
|
|
929
|
+
</button>`;
|
|
930
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── Retention ──────────────────────────────────────────────────────────
|
|
934
|
+
function syncRetentionSlider(v) {
|
|
935
|
+
const n = parseInt(v, 10);
|
|
936
|
+
if (!isNaN(n) && n >= 1 && n <= 90) {
|
|
937
|
+
document.getElementById('retention-range').value = n;
|
|
938
|
+
document.getElementById('retention-val').textContent = n + ' days';
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function saveRetention() {
|
|
943
|
+
const val = parseInt(document.getElementById('retention-num').value, 10);
|
|
944
|
+
if (isNaN(val) || val < 1 || val > 365) return toast('Enter 1–365 days', 'error');
|
|
945
|
+
try {
|
|
946
|
+
const r = await fetch('/api/settings', {
|
|
947
|
+
method: 'POST',
|
|
948
|
+
headers: { 'Content-Type': 'application/json' },
|
|
949
|
+
body: JSON.stringify({ retentionDays: val }),
|
|
950
|
+
});
|
|
951
|
+
const d = await r.json();
|
|
952
|
+
if (!r.ok) throw new Error(d.error);
|
|
953
|
+
toast(`Retention set to ${val} days`, 'success');
|
|
954
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function runCleanup() {
|
|
958
|
+
const btn = document.getElementById('cleanup-btn');
|
|
959
|
+
btn.disabled = true; btn.textContent = 'Running…';
|
|
960
|
+
try {
|
|
961
|
+
const r = await fetch('/api/settings/cleanup', { method: 'POST' });
|
|
962
|
+
const d = await r.json();
|
|
963
|
+
if (!r.ok) throw new Error(d.error);
|
|
964
|
+
toast('Cleanup complete — old log files deleted', 'success');
|
|
965
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
966
|
+
finally { btn.disabled = false; btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/></svg> Run Cleanup Now'; }
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ── Webhook ────────────────────────────────────────────────────────────
|
|
970
|
+
async function saveWebhook() {
|
|
971
|
+
const url = document.getElementById('webhook-url').value.trim();
|
|
972
|
+
try {
|
|
973
|
+
const r = await fetch('/api/settings', {
|
|
974
|
+
method: 'POST',
|
|
975
|
+
headers: { 'Content-Type': 'application/json' },
|
|
976
|
+
body: JSON.stringify({ webhookUrl: url }),
|
|
977
|
+
});
|
|
978
|
+
const d = await r.json();
|
|
979
|
+
if (!r.ok) throw new Error(d.error);
|
|
980
|
+
const dot = document.getElementById('webhook-dot');
|
|
981
|
+
const text = document.getElementById('webhook-status-text');
|
|
982
|
+
if (url) {
|
|
983
|
+
dot.className = 'dot dot-green';
|
|
984
|
+
text.textContent = 'Webhook active — alerts are firing';
|
|
985
|
+
} else {
|
|
986
|
+
dot.className = 'dot dot-gray';
|
|
987
|
+
text.textContent = 'No webhook configured — alerts are off';
|
|
988
|
+
}
|
|
989
|
+
toast('Webhook ' + (url ? 'saved' : 'cleared'), 'success');
|
|
990
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ── Stream toggle ──────────────────────────────────────────────────────
|
|
994
|
+
async function saveStream(enabled) {
|
|
995
|
+
try {
|
|
996
|
+
const r = await fetch('/api/settings', {
|
|
997
|
+
method: 'POST',
|
|
998
|
+
headers: { 'Content-Type': 'application/json' },
|
|
999
|
+
body: JSON.stringify({ enableStream: enabled }),
|
|
1000
|
+
});
|
|
1001
|
+
const d = await r.json();
|
|
1002
|
+
if (!r.ok) throw new Error(d.error);
|
|
1003
|
+
toast('Stream ' + (enabled ? 'enabled' : 'disabled'), 'success');
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
toast(e.message, 'error');
|
|
1006
|
+
document.getElementById('stream-toggle').checked = !enabled;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ── API Key ────────────────────────────────────────────────────────────
|
|
1011
|
+
function toggleApiKey() {
|
|
1012
|
+
const inp = document.getElementById('apikey-input');
|
|
1013
|
+
if (inp.type === 'password') {
|
|
1014
|
+
inp.type = 'text';
|
|
1015
|
+
inp.value = '(stored in .env — not exposed via API)';
|
|
1016
|
+
} else {
|
|
1017
|
+
inp.type = 'password';
|
|
1018
|
+
inp.value = 'api-key-stored-in-env';
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function copyApiKey() {
|
|
1023
|
+
navigator.clipboard.writeText(document.getElementById('apikey-input').value)
|
|
1024
|
+
.then(() => toast('API key copied', 'success'));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ── Close modal on overlay click ───────────────────────────────────────
|
|
1028
|
+
document.getElementById('twofa-modal').addEventListener('click', function(e) {
|
|
1029
|
+
if (e.target === this) close2faModal();
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// ── Keyboard shortcut ESC for modal ───────────────────────────────────
|
|
1033
|
+
document.addEventListener('keydown', e => {
|
|
1034
|
+
if (e.key === 'Escape') close2faModal();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// Additional admin functions (IP whitelist, branding, health alerts, etc.)
|
|
1038
|
+
async function saveIPWhitelist() {
|
|
1039
|
+
const raw = document.getElementById('ip-whitelist')?.value || '';
|
|
1040
|
+
const ipWhitelist = raw.split('\n').map(s=>s.trim()).filter(Boolean);
|
|
1041
|
+
try {
|
|
1042
|
+
const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ipWhitelist}) });
|
|
1043
|
+
const d = await r.json();
|
|
1044
|
+
if (!r.ok) throw new Error(d.error);
|
|
1045
|
+
toast('IP whitelist saved', 'success');
|
|
1046
|
+
} catch(e) { toast(e.message, 'error'); }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function saveBranding() {
|
|
1050
|
+
const appName = document.getElementById('brand-name').value.trim();
|
|
1051
|
+
const appLogoUrl = document.getElementById('brand-logo').value.trim() || '/public/logo.png';
|
|
1052
|
+
const faviconUrl = document.getElementById('brand-favicon').value.trim();
|
|
1053
|
+
try {
|
|
1054
|
+
const r = await fetch('/api/settings', {
|
|
1055
|
+
method: 'POST',
|
|
1056
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1057
|
+
body: JSON.stringify({ appName, appLogoUrl, faviconUrl })
|
|
1058
|
+
});
|
|
1059
|
+
const d = await r.json();
|
|
1060
|
+
if (!r.ok) { toast(d.error || 'Save failed', 'error'); return; }
|
|
1061
|
+
toast('Branding saved! Reloading…', 'success');
|
|
1062
|
+
setTimeout(() => location.reload(), 800);
|
|
1063
|
+
} catch(e) { toast('Network error', 'error'); }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function previewFavicon(url) {
|
|
1067
|
+
const img = document.getElementById('favicon-preview');
|
|
1068
|
+
if (!img) return;
|
|
1069
|
+
if (!url) { img.style.display = 'none'; return; }
|
|
1070
|
+
img.src = url;
|
|
1071
|
+
img.style.display = '';
|
|
1072
|
+
img.onerror = function() { this.style.display = 'none'; };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function saveHealthAlerts() {
|
|
1076
|
+
const alertRamPct = Number(document.getElementById('alert-ram').value);
|
|
1077
|
+
const alertCpuPct = Number(document.getElementById('alert-cpu').value);
|
|
1078
|
+
const alertDiskPct = Number(document.getElementById('alert-disk').value);
|
|
1079
|
+
const alertsEnabled = document.getElementById('alerts-enabled').checked;
|
|
1080
|
+
try {
|
|
1081
|
+
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ alertRamPct, alertCpuPct, alertDiskPct, alertsEnabled }) });
|
|
1082
|
+
const d = await r.json();
|
|
1083
|
+
if (!r.ok) { toast(d.error || 'Save failed', 'error'); return; }
|
|
1084
|
+
toast('Health alert thresholds saved', 'success');
|
|
1085
|
+
} catch(e) { toast('Network error', 'error'); }
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function testWebhook() {
|
|
1089
|
+
const url = document.getElementById('webhook-url')?.value?.trim();
|
|
1090
|
+
if (!url) { toast('Enter a webhook URL first', 'error'); return; }
|
|
1091
|
+
try {
|
|
1092
|
+
const r = await fetch('/api/settings/test-webhook', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ webhookUrl: url }) });
|
|
1093
|
+
const d = await r.json();
|
|
1094
|
+
if (!r.ok) { toast(d.error || 'Test failed', 'error'); return; }
|
|
1095
|
+
toast('Test message sent to webhook!', 'success');
|
|
1096
|
+
} catch(e) { toast('Network error', 'error'); }
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async function saveEmailSettings(){
|
|
1100
|
+
const body={
|
|
1101
|
+
smtpHost: document.getElementById('smtp-host').value.trim(),
|
|
1102
|
+
smtpPort: Number(document.getElementById('smtp-port').value)||587,
|
|
1103
|
+
smtpUser: document.getElementById('smtp-user').value.trim(),
|
|
1104
|
+
smtpFrom: document.getElementById('smtp-from').value.trim(),
|
|
1105
|
+
reportEmail: document.getElementById('report-email').value.trim(),
|
|
1106
|
+
emailEnabled: document.getElementById('email-enabled').checked,
|
|
1107
|
+
reportEnabled: document.getElementById('report-enabled').checked,
|
|
1108
|
+
reportSchedule:document.getElementById('report-schedule').value,
|
|
1109
|
+
};
|
|
1110
|
+
const smtpPass=document.getElementById('smtp-pass').value;
|
|
1111
|
+
if(smtpPass) body.smtpPass=smtpPass;
|
|
1112
|
+
try{const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){toast(d.error||'Save failed','error');return;}toast('Email settings saved','success');}catch(e){toast('Network error','error');}
|
|
1113
|
+
}
|
|
1114
|
+
async function testEmailNow(){
|
|
1115
|
+
try{const r=await fetch('/api/settings/send-report',{method:'POST'});const d=await r.json();if(!r.ok){toast(d.error||'Failed','error');return;}toast('Test report queued','success');}catch(e){toast('Network error','error');}
|
|
1116
|
+
}
|
|
1117
|
+
async function saveIntegrations(){
|
|
1118
|
+
const body={
|
|
1119
|
+
discordUrl: document.getElementById('discord-url').value.trim(),
|
|
1120
|
+
pagerdutyKey: document.getElementById('pd-key').value.trim(),
|
|
1121
|
+
pagerdutyEnabled: document.getElementById('pd-enabled').checked,
|
|
1122
|
+
};
|
|
1123
|
+
try{const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){toast(d.error||'Save failed','error');return;}toast('Integrations saved','success');}catch(e){toast('Network error','error');}
|
|
1124
|
+
}
|
|
1125
|
+
async function testDiscord(){
|
|
1126
|
+
const url=document.getElementById('discord-url').value.trim();
|
|
1127
|
+
if(!url){toast('Enter a Discord webhook URL first','error');return;}
|
|
1128
|
+
try{
|
|
1129
|
+
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'LogBoard',embeds:[{title:'✅ LogBoard Test',description:'Discord webhook is working!',color:0x22c55e}]})});
|
|
1130
|
+
toast(r.ok?'Test message sent to Discord!':'Discord returned '+r.status,r.ok?'success':'error');
|
|
1131
|
+
}catch(e){toast('Request failed: '+e.message,'error');}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Toast helper (simple)
|
|
1135
|
+
function toast(msg, type = 'info') {
|
|
1136
|
+
const toastDiv = document.createElement('div');
|
|
1137
|
+
toastDiv.className = `toast toast-${type}`;
|
|
1138
|
+
toastDiv.textContent = msg;
|
|
1139
|
+
toastDiv.style.position = 'fixed';
|
|
1140
|
+
toastDiv.style.bottom = '20px';
|
|
1141
|
+
toastDiv.style.right = '20px';
|
|
1142
|
+
toastDiv.style.zIndex = '9999';
|
|
1143
|
+
toastDiv.style.backgroundColor = type === 'error' ? '#ef4444' : (type === 'success' ? '#22c55e' : '#3b82f6');
|
|
1144
|
+
toastDiv.style.color = 'white';
|
|
1145
|
+
toastDiv.style.padding = '10px 20px';
|
|
1146
|
+
toastDiv.style.borderRadius = '8px';
|
|
1147
|
+
toastDiv.style.fontSize = '13px';
|
|
1148
|
+
toastDiv.style.fontWeight = '500';
|
|
1149
|
+
toastDiv.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
|
|
1150
|
+
document.body.appendChild(toastDiv);
|
|
1151
|
+
setTimeout(() => toastDiv.remove(), 3000);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function doLogout() {
|
|
1155
|
+
fetch('/api/auth/logout', { method: 'POST' }).then(() => window.location.href = '/login');
|
|
1156
|
+
}
|
|
1157
|
+
</script>
|
|
1158
|
+
</body>
|
|
1159
|
+
</html>
|