lobsterboard 0.2.2 → 0.2.3
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/app.html +57 -0
- package/css/builder.css +63 -0
- package/dist/lobsterboard.css +1 -1
- package/dist/lobsterboard.esm.js +1 -1
- package/dist/lobsterboard.esm.min.js +1 -1
- package/dist/lobsterboard.umd.js +1 -1
- package/dist/lobsterboard.umd.min.js +1 -1
- package/js/builder.js +328 -6
- package/js/widgets.js +5 -0
- package/package.json +1 -1
- package/server.cjs +243 -8
package/app.html
CHANGED
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
<input type="number" id="custom-height" placeholder="Height" style="display:none; width:80px;">
|
|
66
66
|
</div>
|
|
67
67
|
<div class="header-right">
|
|
68
|
+
<button class="btn btn-secondary" id="btn-security">🔒 Security</button>
|
|
68
69
|
<button class="btn btn-secondary" id="btn-templates">📋 Templates</button>
|
|
69
70
|
<button class="btn btn-secondary" id="btn-export-template">📦 Export Template</button>
|
|
70
71
|
<button class="btn btn-secondary" id="btn-clear">Clear All</button>
|
|
@@ -775,6 +776,62 @@
|
|
|
775
776
|
</div>
|
|
776
777
|
</div>
|
|
777
778
|
|
|
779
|
+
<!-- PIN Modal -->
|
|
780
|
+
<div id="pin-modal" class="pin-modal-overlay" style="display:none;">
|
|
781
|
+
<div class="pin-modal">
|
|
782
|
+
<h3 id="pin-modal-title">🔒 Enter PIN</h3>
|
|
783
|
+
<div id="pin-current-group" class="pin-group" style="display:none;">
|
|
784
|
+
<label>Current PIN</label>
|
|
785
|
+
<input type="password" id="pin-input-current" maxlength="6" placeholder="••••" inputmode="numeric" pattern="[0-9]*" class="pin-input">
|
|
786
|
+
</div>
|
|
787
|
+
<div class="pin-group">
|
|
788
|
+
<label>PIN (4-6 digits)</label>
|
|
789
|
+
<input type="password" id="pin-input" maxlength="6" placeholder="••••" inputmode="numeric" pattern="[0-9]*" class="pin-input">
|
|
790
|
+
</div>
|
|
791
|
+
<div id="pin-confirm-group" class="pin-group" style="display:none;">
|
|
792
|
+
<label>Confirm PIN</label>
|
|
793
|
+
<input type="password" id="pin-input-confirm" maxlength="6" placeholder="••••" inputmode="numeric" pattern="[0-9]*" class="pin-input">
|
|
794
|
+
</div>
|
|
795
|
+
<div id="pin-error" class="pin-error"></div>
|
|
796
|
+
<div class="pin-actions">
|
|
797
|
+
<button class="btn btn-primary" id="pin-submit">Submit</button>
|
|
798
|
+
<button class="btn btn-secondary" id="pin-cancel">Cancel</button>
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
<!-- Security Settings Modal -->
|
|
804
|
+
<div id="security-modal" class="pin-modal-overlay" style="display:none;">
|
|
805
|
+
<div class="pin-modal" style="max-width:400px;">
|
|
806
|
+
<h3>🔒 Security Settings</h3>
|
|
807
|
+
<div class="security-option">
|
|
808
|
+
<div class="security-option-header">
|
|
809
|
+
<strong>Edit PIN</strong>
|
|
810
|
+
<span id="pin-status" class="security-badge">Not Set</span>
|
|
811
|
+
</div>
|
|
812
|
+
<p style="color:#8b949e;font-size:12px;margin:4px 0 8px;">Require a PIN to enter edit mode</p>
|
|
813
|
+
<div class="security-buttons">
|
|
814
|
+
<button class="btn btn-secondary btn-sm" id="sec-set-pin">Set PIN</button>
|
|
815
|
+
<button class="btn btn-secondary btn-sm" id="sec-change-pin" style="display:none;">Change PIN</button>
|
|
816
|
+
<button class="btn btn-danger btn-sm" id="sec-remove-pin" style="display:none;">Remove PIN</button>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
<div class="security-option" style="margin-top:16px;">
|
|
820
|
+
<div class="security-option-header">
|
|
821
|
+
<strong>Public Mode</strong>
|
|
822
|
+
<label class="toggle-switch">
|
|
823
|
+
<input type="checkbox" id="public-mode-toggle">
|
|
824
|
+
<span class="toggle-slider"></span>
|
|
825
|
+
</label>
|
|
826
|
+
</div>
|
|
827
|
+
<p style="color:#8b949e;font-size:12px;margin:4px 0 0;">Hide edit button and block config APIs. Ideal for publicly-exposed dashboards.</p>
|
|
828
|
+
</div>
|
|
829
|
+
<div style="margin-top:20px;text-align:right;">
|
|
830
|
+
<button class="btn btn-secondary" id="sec-close">Close</button>
|
|
831
|
+
</div>
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
|
|
778
835
|
<script src="js/templates.js"></script>
|
|
779
836
|
</body>
|
|
780
837
|
</html>
|
package/css/builder.css
CHANGED
|
@@ -1298,3 +1298,66 @@ body[data-mode="edit"] .edit-layout-btn {
|
|
|
1298
1298
|
.tpl-export-success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
|
|
1299
1299
|
.tpl-export-error { background: #2d1215; border: 1px solid #da3633; color: #f85149; }
|
|
1300
1300
|
.tpl-export-result code { background: #30363d; padding: 2px 6px; border-radius: 4px; }
|
|
1301
|
+
|
|
1302
|
+
/* ─────────────────────────────────────────────
|
|
1303
|
+
PIN Modal & Security Settings
|
|
1304
|
+
───────────────────────────────────────────── */
|
|
1305
|
+
.pin-modal-overlay {
|
|
1306
|
+
position: fixed; inset: 0; z-index: 9999;
|
|
1307
|
+
background: rgba(0,0,0,0.7); backdrop-filter: blur(4px);
|
|
1308
|
+
display: flex; align-items: center; justify-content: center;
|
|
1309
|
+
}
|
|
1310
|
+
.pin-modal {
|
|
1311
|
+
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
|
|
1312
|
+
padding: 24px; min-width: 320px; max-width: 360px;
|
|
1313
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
|
1314
|
+
}
|
|
1315
|
+
.pin-modal h3 { margin: 0 0 16px; color: #e6edf3; font-size: 18px; }
|
|
1316
|
+
.pin-group { margin-bottom: 12px; }
|
|
1317
|
+
.pin-group label { display: block; color: #8b949e; font-size: 12px; margin-bottom: 4px; }
|
|
1318
|
+
.pin-input {
|
|
1319
|
+
width: 100%; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d;
|
|
1320
|
+
border-radius: 6px; color: #e6edf3; font-size: 20px; letter-spacing: 8px;
|
|
1321
|
+
text-align: center; box-sizing: border-box;
|
|
1322
|
+
}
|
|
1323
|
+
.pin-input:focus { border-color: #58a6ff; outline: none; }
|
|
1324
|
+
.pin-error { color: #f85149; font-size: 12px; min-height: 18px; margin: 8px 0; }
|
|
1325
|
+
.pin-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
1326
|
+
.security-option {
|
|
1327
|
+
padding: 12px; background: #0d1117; border-radius: 8px; border: 1px solid #21262d;
|
|
1328
|
+
}
|
|
1329
|
+
.security-option-header {
|
|
1330
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
1331
|
+
}
|
|
1332
|
+
.security-badge {
|
|
1333
|
+
font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
|
1334
|
+
background: #21262d; color: #8b949e;
|
|
1335
|
+
}
|
|
1336
|
+
.security-badge.active { background: #0d2818; color: #3fb950; }
|
|
1337
|
+
.security-buttons { display: flex; gap: 6px; }
|
|
1338
|
+
|
|
1339
|
+
/* Toggle switch */
|
|
1340
|
+
.toggle-switch { position: relative; display: inline-block; width: 42px; height: 22px; }
|
|
1341
|
+
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
|
1342
|
+
.toggle-slider {
|
|
1343
|
+
position: absolute; cursor: pointer; inset: 0;
|
|
1344
|
+
background: #21262d; border-radius: 22px; transition: 0.3s;
|
|
1345
|
+
}
|
|
1346
|
+
.toggle-slider:before {
|
|
1347
|
+
content: ""; position: absolute; height: 16px; width: 16px;
|
|
1348
|
+
left: 3px; bottom: 3px; background: #8b949e;
|
|
1349
|
+
border-radius: 50%; transition: 0.3s;
|
|
1350
|
+
}
|
|
1351
|
+
.toggle-switch input:checked + .toggle-slider { background: #238636; }
|
|
1352
|
+
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); background: #fff; }
|
|
1353
|
+
|
|
1354
|
+
/* Masked secret field in properties */
|
|
1355
|
+
.secret-field-wrapper {
|
|
1356
|
+
display: flex; align-items: center; gap: 6px;
|
|
1357
|
+
}
|
|
1358
|
+
.secret-field-wrapper input { flex: 1; }
|
|
1359
|
+
.secret-replace-btn {
|
|
1360
|
+
padding: 4px 8px; background: #21262d; border: 1px solid #30363d;
|
|
1361
|
+
border-radius: 4px; color: #8b949e; cursor: pointer; font-size: 11px; white-space: nowrap;
|
|
1362
|
+
}
|
|
1363
|
+
.secret-replace-btn:hover { background: #30363d; color: #e6edf3; }
|
package/dist/lobsterboard.css
CHANGED
package/dist/lobsterboard.esm.js
CHANGED
package/dist/lobsterboard.umd.js
CHANGED
package/js/builder.js
CHANGED
|
@@ -27,7 +27,10 @@ const state = {
|
|
|
27
27
|
draggedWidget: null,
|
|
28
28
|
idCounter: 0,
|
|
29
29
|
fontScale: 1,
|
|
30
|
-
editMode: false // New: Track edit mode state
|
|
30
|
+
editMode: false, // New: Track edit mode state
|
|
31
|
+
pinVerified: false, // Track if PIN has been verified this session
|
|
32
|
+
hasPin: false, // Whether a PIN is configured
|
|
33
|
+
publicMode: false // Whether public mode is enabled
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
// ─────────────────────────────────────────────
|
|
@@ -53,6 +56,218 @@ function getScrollableCanvasHeight() {
|
|
|
53
56
|
// EDIT MODE
|
|
54
57
|
// ─────────────────────────────────────────────
|
|
55
58
|
|
|
59
|
+
// ─────────────────────────────────────────────
|
|
60
|
+
// PIN & PUBLIC MODE
|
|
61
|
+
// ─────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function addPublicUnlockButton() {
|
|
64
|
+
let unlock = document.getElementById('public-unlock');
|
|
65
|
+
if (unlock) return; // already exists
|
|
66
|
+
unlock = document.createElement('button');
|
|
67
|
+
unlock.id = 'public-unlock';
|
|
68
|
+
unlock.textContent = '🔒';
|
|
69
|
+
unlock.title = 'Admin';
|
|
70
|
+
unlock.style.cssText = 'position:fixed;bottom:8px;right:8px;z-index:9999;background:transparent;border:none;color:#6e7681;font-size:12px;cursor:pointer;opacity:0.3;transition:opacity .2s;padding:4px;';
|
|
71
|
+
unlock.addEventListener('mouseenter', () => unlock.style.opacity = '0.8');
|
|
72
|
+
unlock.addEventListener('mouseleave', () => unlock.style.opacity = '0.3');
|
|
73
|
+
unlock.addEventListener('click', () => {
|
|
74
|
+
if (state.hasPin) {
|
|
75
|
+
showPinModal('verify');
|
|
76
|
+
} else {
|
|
77
|
+
openSecurityModal();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
document.body.appendChild(unlock);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function checkAuthStatus() {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch('/api/auth/status');
|
|
86
|
+
const data = await res.json();
|
|
87
|
+
state.hasPin = data.hasPin;
|
|
88
|
+
state.publicMode = data.publicMode;
|
|
89
|
+
if (state.publicMode) {
|
|
90
|
+
const editBtn = document.getElementById('btn-edit-layout');
|
|
91
|
+
if (editBtn) editBtn.style.display = 'none';
|
|
92
|
+
addPublicUnlockButton();
|
|
93
|
+
}
|
|
94
|
+
} catch (e) { console.error('Auth status check failed:', e); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function showPinModal(mode) {
|
|
98
|
+
// mode: 'verify', 'set', 'change', 'remove'
|
|
99
|
+
const modal = document.getElementById('pin-modal');
|
|
100
|
+
const title = document.getElementById('pin-modal-title');
|
|
101
|
+
const input = document.getElementById('pin-input');
|
|
102
|
+
const input2 = document.getElementById('pin-input-confirm');
|
|
103
|
+
const currentInput = document.getElementById('pin-input-current');
|
|
104
|
+
const error = document.getElementById('pin-error');
|
|
105
|
+
const confirmGroup = document.getElementById('pin-confirm-group');
|
|
106
|
+
const currentGroup = document.getElementById('pin-current-group');
|
|
107
|
+
|
|
108
|
+
error.textContent = '';
|
|
109
|
+
input.value = '';
|
|
110
|
+
input2.value = '';
|
|
111
|
+
currentInput.value = '';
|
|
112
|
+
|
|
113
|
+
if (mode === 'verify') {
|
|
114
|
+
title.textContent = '🔒 Enter PIN to Edit';
|
|
115
|
+
confirmGroup.style.display = 'none';
|
|
116
|
+
currentGroup.style.display = 'none';
|
|
117
|
+
} else if (mode === 'set') {
|
|
118
|
+
title.textContent = '🔐 Set Edit PIN';
|
|
119
|
+
confirmGroup.style.display = 'block';
|
|
120
|
+
currentGroup.style.display = 'none';
|
|
121
|
+
} else if (mode === 'change') {
|
|
122
|
+
title.textContent = '🔄 Change PIN';
|
|
123
|
+
confirmGroup.style.display = 'block';
|
|
124
|
+
currentGroup.style.display = 'block';
|
|
125
|
+
} else if (mode === 'remove') {
|
|
126
|
+
title.textContent = '🗑️ Remove PIN';
|
|
127
|
+
confirmGroup.style.display = 'none';
|
|
128
|
+
currentGroup.style.display = 'block';
|
|
129
|
+
input.parentElement.style.display = 'none';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
modal.style.display = 'flex';
|
|
133
|
+
modal.dataset.mode = mode;
|
|
134
|
+
setTimeout(() => (mode === 'change' || mode === 'remove' ? currentInput : input).focus(), 100);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function closePinModal() {
|
|
138
|
+
const modal = document.getElementById('pin-modal');
|
|
139
|
+
modal.style.display = 'none';
|
|
140
|
+
// Restore visibility of new PIN input
|
|
141
|
+
document.getElementById('pin-input').parentElement.style.display = '';
|
|
142
|
+
// Clear any pending public mode callback
|
|
143
|
+
if (state._publicModeCallback) {
|
|
144
|
+
state._publicModeCallback = null;
|
|
145
|
+
const toggle = document.getElementById('public-mode-toggle');
|
|
146
|
+
if (toggle) toggle.checked = state.publicMode;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function submitPin() {
|
|
151
|
+
const modal = document.getElementById('pin-modal');
|
|
152
|
+
const mode = modal.dataset.mode;
|
|
153
|
+
const pin = document.getElementById('pin-input').value;
|
|
154
|
+
const pin2 = document.getElementById('pin-input-confirm').value;
|
|
155
|
+
const currentPin = document.getElementById('pin-input-current').value;
|
|
156
|
+
const error = document.getElementById('pin-error');
|
|
157
|
+
error.textContent = '';
|
|
158
|
+
|
|
159
|
+
if (mode === 'verify') {
|
|
160
|
+
const res = await fetch('/api/auth/verify-pin', {
|
|
161
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify({ pin })
|
|
163
|
+
});
|
|
164
|
+
const data = await res.json();
|
|
165
|
+
if (data.valid) {
|
|
166
|
+
state.pinVerified = true;
|
|
167
|
+
// If there's a pending public mode toggle, handle that instead of entering edit mode
|
|
168
|
+
if (state._publicModeCallback) {
|
|
169
|
+
const callback = state._publicModeCallback;
|
|
170
|
+
state._publicModeCallback = null; // clear before closePinModal tries to
|
|
171
|
+
closePinModal();
|
|
172
|
+
await callback(pin);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// If in public mode (unlock button clicked), disable it and restore edit UI
|
|
176
|
+
if (state.publicMode) {
|
|
177
|
+
state.publicMode = false;
|
|
178
|
+
await fetch('/api/mode', {
|
|
179
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
180
|
+
body: JSON.stringify({ publicMode: false, pin })
|
|
181
|
+
});
|
|
182
|
+
const editBtn = document.getElementById('btn-edit-layout');
|
|
183
|
+
if (editBtn) editBtn.style.display = '';
|
|
184
|
+
const unlock = document.getElementById('public-unlock');
|
|
185
|
+
if (unlock) unlock.remove();
|
|
186
|
+
const pubToggle = document.getElementById('public-mode-toggle');
|
|
187
|
+
if (pubToggle) pubToggle.checked = false;
|
|
188
|
+
}
|
|
189
|
+
closePinModal();
|
|
190
|
+
setEditMode(true);
|
|
191
|
+
} else {
|
|
192
|
+
error.textContent = 'Incorrect PIN';
|
|
193
|
+
}
|
|
194
|
+
} else if (mode === 'set') {
|
|
195
|
+
if (pin !== pin2) { error.textContent = 'PINs do not match'; return; }
|
|
196
|
+
if (pin.length < 4 || pin.length > 6 || !/^\d+$/.test(pin)) { error.textContent = 'PIN must be 4-6 digits'; return; }
|
|
197
|
+
const res = await fetch('/api/auth/set-pin', {
|
|
198
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ pin })
|
|
200
|
+
});
|
|
201
|
+
const data = await res.json();
|
|
202
|
+
if (data.status === 'ok') {
|
|
203
|
+
state.hasPin = true;
|
|
204
|
+
state.pinVerified = true;
|
|
205
|
+
closePinModal();
|
|
206
|
+
setEditMode(true);
|
|
207
|
+
} else { error.textContent = data.error || 'Failed to set PIN'; }
|
|
208
|
+
} else if (mode === 'change') {
|
|
209
|
+
if (pin !== pin2) { error.textContent = 'New PINs do not match'; return; }
|
|
210
|
+
if (pin.length < 4 || pin.length > 6 || !/^\d+$/.test(pin)) { error.textContent = 'PIN must be 4-6 digits'; return; }
|
|
211
|
+
const res = await fetch('/api/auth/set-pin', {
|
|
212
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({ pin, currentPin })
|
|
214
|
+
});
|
|
215
|
+
const data = await res.json();
|
|
216
|
+
if (data.status === 'ok') {
|
|
217
|
+
closePinModal();
|
|
218
|
+
alert('PIN changed successfully');
|
|
219
|
+
} else { error.textContent = data.error || 'Failed to change PIN'; }
|
|
220
|
+
} else if (mode === 'remove') {
|
|
221
|
+
const res = await fetch('/api/auth/remove-pin', {
|
|
222
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify({ pin: currentPin })
|
|
224
|
+
});
|
|
225
|
+
const data = await res.json();
|
|
226
|
+
if (data.status === 'ok') {
|
|
227
|
+
state.hasPin = false;
|
|
228
|
+
closePinModal();
|
|
229
|
+
alert('PIN removed');
|
|
230
|
+
} else { error.textContent = data.error || 'Failed to remove PIN'; }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function requestEditMode() {
|
|
235
|
+
if (state.publicMode) { alert('Dashboard is in public mode. Editing is disabled.'); return; }
|
|
236
|
+
if (state.hasPin && !state.pinVerified) {
|
|
237
|
+
showPinModal('verify');
|
|
238
|
+
} else if (!state.hasPin) {
|
|
239
|
+
// No PIN set — offer to set one, or go straight to edit
|
|
240
|
+
setEditMode(true);
|
|
241
|
+
} else {
|
|
242
|
+
setEditMode(true);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function openSecurityModal() {
|
|
247
|
+
const modal = document.getElementById('security-modal');
|
|
248
|
+
const pinStatus = document.getElementById('pin-status');
|
|
249
|
+
const setBtn = document.getElementById('sec-set-pin');
|
|
250
|
+
const changeBtn = document.getElementById('sec-change-pin');
|
|
251
|
+
const removeBtn = document.getElementById('sec-remove-pin');
|
|
252
|
+
const publicToggle = document.getElementById('public-mode-toggle');
|
|
253
|
+
|
|
254
|
+
if (state.hasPin) {
|
|
255
|
+
pinStatus.textContent = 'Active';
|
|
256
|
+
pinStatus.className = 'security-badge active';
|
|
257
|
+
setBtn.style.display = 'none';
|
|
258
|
+
changeBtn.style.display = '';
|
|
259
|
+
removeBtn.style.display = '';
|
|
260
|
+
} else {
|
|
261
|
+
pinStatus.textContent = 'Not Set';
|
|
262
|
+
pinStatus.className = 'security-badge';
|
|
263
|
+
setBtn.style.display = '';
|
|
264
|
+
changeBtn.style.display = 'none';
|
|
265
|
+
removeBtn.style.display = 'none';
|
|
266
|
+
}
|
|
267
|
+
publicToggle.checked = state.publicMode;
|
|
268
|
+
modal.style.display = 'flex';
|
|
269
|
+
}
|
|
270
|
+
|
|
56
271
|
function setEditMode(enable) {
|
|
57
272
|
state.editMode = enable;
|
|
58
273
|
document.body.dataset.mode = enable ? 'edit' : 'view';
|
|
@@ -79,7 +294,7 @@ function setEditMode(enable) {
|
|
|
79
294
|
document.querySelector('.canvas-grid').style.display = 'block'; // Show grid
|
|
80
295
|
document.querySelector('.drop-hint').style.display = 'flex'; // Show drop hint
|
|
81
296
|
} else {
|
|
82
|
-
editLayoutBtn.style.display = 'block';
|
|
297
|
+
editLayoutBtn.style.display = state.publicMode ? 'none' : 'block';
|
|
83
298
|
saveBtn.textContent = '📦 Export ZIP';
|
|
84
299
|
saveBtn.removeEventListener('click', saveConfig);
|
|
85
300
|
saveBtn.addEventListener('click', exportDashboard);
|
|
@@ -326,11 +541,89 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
326
541
|
// setEditMode(false) is called inside loadConfig()
|
|
327
542
|
|
|
328
543
|
// Initialize Edit Layout button
|
|
329
|
-
document.getElementById('btn-edit-layout').addEventListener('click',
|
|
544
|
+
document.getElementById('btn-edit-layout').addEventListener('click', requestEditMode);
|
|
330
545
|
document.getElementById('btn-done-editing').addEventListener('click', () => {
|
|
331
546
|
saveConfig();
|
|
332
547
|
setEditMode(false);
|
|
333
548
|
});
|
|
549
|
+
|
|
550
|
+
// PIN modal buttons
|
|
551
|
+
document.getElementById('pin-submit').addEventListener('click', submitPin);
|
|
552
|
+
document.getElementById('pin-cancel').addEventListener('click', closePinModal);
|
|
553
|
+
document.getElementById('pin-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitPin(); });
|
|
554
|
+
document.getElementById('pin-input-confirm').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitPin(); });
|
|
555
|
+
document.getElementById('pin-input-current').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitPin(); });
|
|
556
|
+
|
|
557
|
+
// Check auth status on load
|
|
558
|
+
checkAuthStatus();
|
|
559
|
+
|
|
560
|
+
// Security modal
|
|
561
|
+
document.getElementById('btn-security').addEventListener('click', openSecurityModal);
|
|
562
|
+
document.getElementById('sec-close').addEventListener('click', () => {
|
|
563
|
+
document.getElementById('security-modal').style.display = 'none';
|
|
564
|
+
});
|
|
565
|
+
document.getElementById('sec-set-pin').addEventListener('click', () => {
|
|
566
|
+
document.getElementById('security-modal').style.display = 'none';
|
|
567
|
+
showPinModal('set');
|
|
568
|
+
});
|
|
569
|
+
document.getElementById('sec-change-pin').addEventListener('click', () => {
|
|
570
|
+
document.getElementById('security-modal').style.display = 'none';
|
|
571
|
+
showPinModal('change');
|
|
572
|
+
});
|
|
573
|
+
document.getElementById('sec-remove-pin').addEventListener('click', () => {
|
|
574
|
+
document.getElementById('security-modal').style.display = 'none';
|
|
575
|
+
showPinModal('remove');
|
|
576
|
+
});
|
|
577
|
+
document.getElementById('public-mode-toggle').addEventListener('change', async (e) => {
|
|
578
|
+
const enable = e.target.checked;
|
|
579
|
+
if (enable && !confirm('Enable Public Mode? This will hide the Edit button and block config APIs.')) {
|
|
580
|
+
e.target.checked = false; return;
|
|
581
|
+
}
|
|
582
|
+
if (state.hasPin) {
|
|
583
|
+
// Use PIN modal instead of prompt() so input is masked
|
|
584
|
+
state._pendingPublicMode = enable;
|
|
585
|
+
document.getElementById('security-modal').style.display = 'none';
|
|
586
|
+
showPinModal('verify');
|
|
587
|
+
// Override the verify handler temporarily
|
|
588
|
+
state._publicModeCallback = async (pin) => {
|
|
589
|
+
const res = await fetch('/api/mode', {
|
|
590
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
591
|
+
body: JSON.stringify({ publicMode: enable, pin })
|
|
592
|
+
});
|
|
593
|
+
const data = await res.json();
|
|
594
|
+
if (data.status === 'ok') {
|
|
595
|
+
state.publicMode = data.publicMode;
|
|
596
|
+
state._publicModeCallback = null;
|
|
597
|
+
// Reload page for clean state
|
|
598
|
+
location.reload();
|
|
599
|
+
return;
|
|
600
|
+
} else {
|
|
601
|
+
e.target.checked = !enable;
|
|
602
|
+
alert(data.error || 'Failed to change mode');
|
|
603
|
+
}
|
|
604
|
+
state._publicModeCallback = null;
|
|
605
|
+
};
|
|
606
|
+
} else {
|
|
607
|
+
const res = await fetch('/api/mode', {
|
|
608
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
609
|
+
body: JSON.stringify({ publicMode: enable })
|
|
610
|
+
});
|
|
611
|
+
const data = await res.json();
|
|
612
|
+
if (data.status === 'ok') {
|
|
613
|
+
state.publicMode = data.publicMode;
|
|
614
|
+
if (data.publicMode) {
|
|
615
|
+
setEditMode(false);
|
|
616
|
+
document.getElementById('btn-edit-layout').style.display = 'none';
|
|
617
|
+
document.getElementById('security-modal').style.display = 'none';
|
|
618
|
+
} else {
|
|
619
|
+
document.getElementById('btn-edit-layout').style.display = '';
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
e.target.checked = !enable;
|
|
623
|
+
alert(data.error || 'Failed to change mode');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
334
627
|
});
|
|
335
628
|
|
|
336
629
|
function initCanvas() {
|
|
@@ -420,10 +713,23 @@ function updateCanvasInfo() {
|
|
|
420
713
|
function initDragDrop() {
|
|
421
714
|
const canvas = document.getElementById('canvas');
|
|
422
715
|
|
|
423
|
-
// Widget library items
|
|
716
|
+
// Widget library items — add privacy badges
|
|
424
717
|
document.querySelectorAll('.widget-item').forEach(item => {
|
|
425
718
|
item.addEventListener('dragstart', onDragStart);
|
|
426
719
|
item.addEventListener('dragend', onDragEnd);
|
|
720
|
+
const widgetType = item.dataset.widget;
|
|
721
|
+
const widgetDef = WIDGETS[widgetType];
|
|
722
|
+
if (widgetDef && widgetDef.privacyWarning) {
|
|
723
|
+
const nameEl = item.querySelector('.widget-name');
|
|
724
|
+
if (nameEl && !nameEl.querySelector('.privacy-badge')) {
|
|
725
|
+
const badge = document.createElement('span');
|
|
726
|
+
badge.className = 'privacy-badge';
|
|
727
|
+
badge.textContent = ' ⚠️';
|
|
728
|
+
badge.title = 'May expose sensitive data when dashboard is public';
|
|
729
|
+
badge.style.cssText = 'font-size:10px;cursor:help;';
|
|
730
|
+
nameEl.appendChild(badge);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
427
733
|
});
|
|
428
734
|
|
|
429
735
|
// Canvas drop zone
|
|
@@ -1088,6 +1394,22 @@ function showProperties(widget) {
|
|
|
1088
1394
|
} else {
|
|
1089
1395
|
document.getElementById('prop-description-group').style.display = 'none';
|
|
1090
1396
|
}
|
|
1397
|
+
|
|
1398
|
+
// Show privacy warning for sensitive widgets
|
|
1399
|
+
let privWarn = document.getElementById('prop-privacy-warning');
|
|
1400
|
+
if (!privWarn) {
|
|
1401
|
+
privWarn = document.createElement('div');
|
|
1402
|
+
privWarn.id = 'prop-privacy-warning';
|
|
1403
|
+
privWarn.style.cssText = 'background:#2d1b00;border:1px solid #d29922;border-radius:6px;padding:8px 10px;margin:8px 0;font-size:11px;color:#d29922;display:none;line-height:1.4;';
|
|
1404
|
+
const descGroup = document.getElementById('prop-description-group');
|
|
1405
|
+
descGroup.parentNode.insertBefore(privWarn, descGroup.nextSibling);
|
|
1406
|
+
}
|
|
1407
|
+
if (template.privacyWarning) {
|
|
1408
|
+
privWarn.innerHTML = '⚠️ <strong>Privacy Warning:</strong> This widget may display sensitive data (API keys, credentials, personal info) to anyone viewing your dashboard. Public Mode and PIN protection only prevent editing — they do <strong>not</strong> hide widget content.';
|
|
1409
|
+
privWarn.style.display = 'block';
|
|
1410
|
+
} else {
|
|
1411
|
+
privWarn.style.display = 'none';
|
|
1412
|
+
}
|
|
1091
1413
|
}
|
|
1092
1414
|
|
|
1093
1415
|
// Properties already handled by hardcoded UI groups
|
|
@@ -1579,7 +1901,7 @@ function initControls() {
|
|
|
1579
1901
|
});
|
|
1580
1902
|
|
|
1581
1903
|
// Edit layout button
|
|
1582
|
-
document.getElementById('btn-edit-layout').addEventListener('click',
|
|
1904
|
+
document.getElementById('btn-edit-layout').addEventListener('click', requestEditMode);
|
|
1583
1905
|
|
|
1584
1906
|
// Zoom controls - handled via inline onclick in HTML
|
|
1585
1907
|
|
|
@@ -1590,7 +1912,7 @@ function initControls() {
|
|
|
1590
1912
|
|
|
1591
1913
|
if (e.ctrlKey && e.key === 'e') { // Ctrl+E to toggle edit mode
|
|
1592
1914
|
e.preventDefault();
|
|
1593
|
-
|
|
1915
|
+
if (state.editMode) setEditMode(false); else requestEditMode();
|
|
1594
1916
|
} else if (e.key === '=' || e.key === '+') {
|
|
1595
1917
|
e.preventDefault();
|
|
1596
1918
|
zoomIn();
|
package/js/widgets.js
CHANGED
|
@@ -579,6 +579,7 @@ const WIDGETS = {
|
|
|
579
579
|
// ─────────────────────────────────────────────
|
|
580
580
|
|
|
581
581
|
'activity-list': {
|
|
582
|
+
privacyWarning: true,
|
|
582
583
|
name: 'Activity List',
|
|
583
584
|
icon: '📋',
|
|
584
585
|
category: 'large',
|
|
@@ -649,6 +650,7 @@ const WIDGETS = {
|
|
|
649
650
|
},
|
|
650
651
|
|
|
651
652
|
'cron-jobs': {
|
|
653
|
+
privacyWarning: true,
|
|
652
654
|
name: 'Cron Jobs',
|
|
653
655
|
icon: '⏰',
|
|
654
656
|
category: 'large',
|
|
@@ -723,6 +725,7 @@ const WIDGETS = {
|
|
|
723
725
|
},
|
|
724
726
|
|
|
725
727
|
'system-log': {
|
|
728
|
+
privacyWarning: true,
|
|
726
729
|
name: 'System Log',
|
|
727
730
|
icon: '🔧',
|
|
728
731
|
category: 'large',
|
|
@@ -810,6 +813,7 @@ const WIDGETS = {
|
|
|
810
813
|
},
|
|
811
814
|
|
|
812
815
|
'calendar': {
|
|
816
|
+
privacyWarning: true,
|
|
813
817
|
name: 'Calendar',
|
|
814
818
|
icon: '📅',
|
|
815
819
|
category: 'large',
|
|
@@ -1686,6 +1690,7 @@ const WIDGETS = {
|
|
|
1686
1690
|
// ─────────────────────────────────────────────
|
|
1687
1691
|
|
|
1688
1692
|
'todo-list': {
|
|
1693
|
+
privacyWarning: true,
|
|
1689
1694
|
name: 'Todo List',
|
|
1690
1695
|
icon: '✅',
|
|
1691
1696
|
category: 'large',
|
package/package.json
CHANGED
package/server.cjs
CHANGED
|
@@ -294,6 +294,82 @@ const MIME_TYPES = {
|
|
|
294
294
|
};
|
|
295
295
|
|
|
296
296
|
const CONFIG_FILE = path.join(__dirname, 'config.json');
|
|
297
|
+
const AUTH_FILE = path.join(__dirname, 'auth.json');
|
|
298
|
+
const SECRETS_FILE = path.join(__dirname, 'secrets.json');
|
|
299
|
+
|
|
300
|
+
// ─────────────────────────────────────────────
|
|
301
|
+
// Security helpers
|
|
302
|
+
// ─────────────────────────────────────────────
|
|
303
|
+
const crypto = require('crypto');
|
|
304
|
+
|
|
305
|
+
function hashPin(pin) {
|
|
306
|
+
return crypto.createHash('sha256').update(pin).digest('hex');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function readJsonFile(filepath, fallback) {
|
|
310
|
+
try { return JSON.parse(fs.readFileSync(filepath, 'utf8')); } catch (_) { return fallback; }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function writeJsonFile(filepath, data) {
|
|
314
|
+
fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getAuth() { return readJsonFile(AUTH_FILE, {}); }
|
|
318
|
+
function getSecrets() { return readJsonFile(SECRETS_FILE, {}); }
|
|
319
|
+
|
|
320
|
+
const SENSITIVE_KEYS = ['apiKey', 'api_key', 'token', 'secret', 'password', 'icalUrl'];
|
|
321
|
+
|
|
322
|
+
function isSensitiveKey(key) {
|
|
323
|
+
return SENSITIVE_KEYS.includes(key);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isPublicMode() {
|
|
327
|
+
const auth = getAuth();
|
|
328
|
+
return auth.publicMode === true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Mask sensitive fields in config before sending to browser */
|
|
332
|
+
function maskConfig(config) {
|
|
333
|
+
const secrets = getSecrets();
|
|
334
|
+
const masked = JSON.parse(JSON.stringify(config));
|
|
335
|
+
if (masked.widgets) {
|
|
336
|
+
masked.widgets.forEach(w => {
|
|
337
|
+
if (!w.properties) return;
|
|
338
|
+
const widgetSecrets = secrets[w.id] || {};
|
|
339
|
+
for (const key of Object.keys(w.properties)) {
|
|
340
|
+
if (isSensitiveKey(key) && (w.properties[key] === '__SECRET__' || widgetSecrets[key])) {
|
|
341
|
+
w.properties[key] = '••••••••';
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return masked;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** On save: extract sensitive values into secrets.json, replace with __SECRET__ in config */
|
|
350
|
+
function extractSecrets(config) {
|
|
351
|
+
const secrets = getSecrets();
|
|
352
|
+
if (config.widgets) {
|
|
353
|
+
config.widgets.forEach(w => {
|
|
354
|
+
if (!w.properties) return;
|
|
355
|
+
for (const key of Object.keys(w.properties)) {
|
|
356
|
+
if (isSensitiveKey(key)) {
|
|
357
|
+
const val = w.properties[key];
|
|
358
|
+
if (val && val !== '__SECRET__' && val !== '••••••••') {
|
|
359
|
+
if (!secrets[w.id]) secrets[w.id] = {};
|
|
360
|
+
secrets[w.id][key] = val;
|
|
361
|
+
w.properties[key] = '__SECRET__';
|
|
362
|
+
} else if (val === '••••••••') {
|
|
363
|
+
// User didn't change it — keep existing secret, restore placeholder
|
|
364
|
+
w.properties[key] = '__SECRET__';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
writeJsonFile(SECRETS_FILE, secrets);
|
|
371
|
+
return config;
|
|
372
|
+
}
|
|
297
373
|
|
|
298
374
|
// Scan templates directory for meta.json files
|
|
299
375
|
function scanTemplates(templatesDir) {
|
|
@@ -399,7 +475,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
399
475
|
}
|
|
400
476
|
try {
|
|
401
477
|
const config = JSON.parse(data);
|
|
402
|
-
sendJson(res, 200, config);
|
|
478
|
+
sendJson(res, 200, maskConfig(config));
|
|
403
479
|
} catch (parseErr) {
|
|
404
480
|
sendError(res, `Failed to parse config file: ${parseErr.message}`);
|
|
405
481
|
}
|
|
@@ -419,7 +495,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
419
495
|
req.on('end', () => {
|
|
420
496
|
if (overflow) { sendError(res, 'Request body too large', 413); return; }
|
|
421
497
|
try {
|
|
422
|
-
|
|
498
|
+
let config = JSON.parse(body);
|
|
499
|
+
config = extractSecrets(config);
|
|
423
500
|
fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8', (err) => {
|
|
424
501
|
if (err) {
|
|
425
502
|
sendError(res, `Failed to write config file: ${err.message}`);
|
|
@@ -445,6 +522,140 @@ const server = http.createServer(async (req, res) => {
|
|
|
445
522
|
return;
|
|
446
523
|
}
|
|
447
524
|
|
|
525
|
+
// ── Security: PIN auth endpoints ──
|
|
526
|
+
if (req.method === 'GET' && pathname === '/api/auth/status') {
|
|
527
|
+
const auth = getAuth();
|
|
528
|
+
sendJson(res, 200, { hasPin: !!auth.pinHash, publicMode: !!auth.publicMode });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (req.method === 'POST' && pathname === '/api/auth/set-pin') {
|
|
533
|
+
let body = '';
|
|
534
|
+
req.on('data', c => body += c);
|
|
535
|
+
req.on('end', () => {
|
|
536
|
+
try {
|
|
537
|
+
const { pin, currentPin } = JSON.parse(body);
|
|
538
|
+
if (!pin || pin.length < 4 || pin.length > 6 || !/^\d+$/.test(pin)) {
|
|
539
|
+
sendJson(res, 400, { error: 'PIN must be 4-6 digits' }); return;
|
|
540
|
+
}
|
|
541
|
+
const auth = getAuth();
|
|
542
|
+
// If PIN already set, require current PIN
|
|
543
|
+
if (auth.pinHash && (!currentPin || hashPin(currentPin) !== auth.pinHash)) {
|
|
544
|
+
sendJson(res, 403, { error: 'Current PIN is incorrect' }); return;
|
|
545
|
+
}
|
|
546
|
+
auth.pinHash = hashPin(pin);
|
|
547
|
+
writeJsonFile(AUTH_FILE, auth);
|
|
548
|
+
sendJson(res, 200, { status: 'ok' });
|
|
549
|
+
} catch (e) { sendError(res, e.message, 400); }
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (req.method === 'POST' && pathname === '/api/auth/verify-pin') {
|
|
555
|
+
let body = '';
|
|
556
|
+
req.on('data', c => body += c);
|
|
557
|
+
req.on('end', () => {
|
|
558
|
+
try {
|
|
559
|
+
const { pin } = JSON.parse(body);
|
|
560
|
+
const auth = getAuth();
|
|
561
|
+
if (!auth.pinHash) { sendJson(res, 200, { valid: true }); return; }
|
|
562
|
+
const valid = hashPin(pin) === auth.pinHash;
|
|
563
|
+
sendJson(res, 200, { valid });
|
|
564
|
+
} catch (e) { sendError(res, e.message, 400); }
|
|
565
|
+
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (req.method === 'POST' && pathname === '/api/auth/remove-pin') {
|
|
570
|
+
let body = '';
|
|
571
|
+
req.on('data', c => body += c);
|
|
572
|
+
req.on('end', () => {
|
|
573
|
+
try {
|
|
574
|
+
const { pin } = JSON.parse(body);
|
|
575
|
+
const auth = getAuth();
|
|
576
|
+
if (auth.pinHash && hashPin(pin) !== auth.pinHash) {
|
|
577
|
+
sendJson(res, 403, { error: 'PIN is incorrect' }); return;
|
|
578
|
+
}
|
|
579
|
+
delete auth.pinHash;
|
|
580
|
+
writeJsonFile(AUTH_FILE, auth);
|
|
581
|
+
sendJson(res, 200, { status: 'ok' });
|
|
582
|
+
} catch (e) { sendError(res, e.message, 400); }
|
|
583
|
+
});
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Security: Public mode ──
|
|
588
|
+
if (req.method === 'GET' && pathname === '/api/mode') {
|
|
589
|
+
const auth = getAuth();
|
|
590
|
+
sendJson(res, 200, { publicMode: !!auth.publicMode });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (req.method === 'POST' && pathname === '/api/mode') {
|
|
595
|
+
let body = '';
|
|
596
|
+
req.on('data', c => body += c);
|
|
597
|
+
req.on('end', () => {
|
|
598
|
+
try {
|
|
599
|
+
const { publicMode, pin } = JSON.parse(body);
|
|
600
|
+
const auth = getAuth();
|
|
601
|
+
// Require PIN to toggle mode if PIN is set
|
|
602
|
+
if (auth.pinHash && (!pin || hashPin(pin) !== auth.pinHash)) {
|
|
603
|
+
sendJson(res, 403, { error: 'PIN required' }); return;
|
|
604
|
+
}
|
|
605
|
+
auth.publicMode = !!publicMode;
|
|
606
|
+
writeJsonFile(AUTH_FILE, auth);
|
|
607
|
+
sendJson(res, 200, { status: 'ok', publicMode: auth.publicMode });
|
|
608
|
+
} catch (e) { sendError(res, e.message, 400); }
|
|
609
|
+
});
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── Security: Secrets management ──
|
|
614
|
+
if (req.method === 'POST' && pathname.match(/^\/api\/secrets\/[^/]+$/)) {
|
|
615
|
+
if (isPublicMode()) { sendJson(res, 403, { error: 'Forbidden in public mode' }); return; }
|
|
616
|
+
const widgetId = pathname.split('/')[3];
|
|
617
|
+
let body = '';
|
|
618
|
+
req.on('data', c => body += c);
|
|
619
|
+
req.on('end', () => {
|
|
620
|
+
try {
|
|
621
|
+
const updates = JSON.parse(body);
|
|
622
|
+
const secrets = getSecrets();
|
|
623
|
+
if (!secrets[widgetId]) secrets[widgetId] = {};
|
|
624
|
+
Object.assign(secrets[widgetId], updates);
|
|
625
|
+
writeJsonFile(SECRETS_FILE, secrets);
|
|
626
|
+
sendJson(res, 200, { status: 'ok' });
|
|
627
|
+
} catch (e) { sendError(res, e.message, 400); }
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (req.method === 'DELETE' && pathname.match(/^\/api\/secrets\/[^/]+\/[^/]+$/)) {
|
|
633
|
+
if (isPublicMode()) { sendJson(res, 403, { error: 'Forbidden in public mode' }); return; }
|
|
634
|
+
const parts = pathname.split('/');
|
|
635
|
+
const widgetId = parts[3];
|
|
636
|
+
const key = parts[4];
|
|
637
|
+
const secrets = getSecrets();
|
|
638
|
+
if (secrets[widgetId]) {
|
|
639
|
+
delete secrets[widgetId][key];
|
|
640
|
+
if (Object.keys(secrets[widgetId]).length === 0) delete secrets[widgetId];
|
|
641
|
+
writeJsonFile(SECRETS_FILE, secrets);
|
|
642
|
+
}
|
|
643
|
+
sendJson(res, 200, { status: 'ok' });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ── Public mode guard: block edit-related APIs ──
|
|
648
|
+
if (isPublicMode()) {
|
|
649
|
+
const editPaths = ['/config'];
|
|
650
|
+
const isEditApi = (req.method === 'POST' && editPaths.includes(pathname)) ||
|
|
651
|
+
(req.method === 'POST' && pathname.startsWith('/api/templates/')) ||
|
|
652
|
+
(req.method === 'DELETE' && pathname.startsWith('/api/templates/'));
|
|
653
|
+
if (isEditApi) {
|
|
654
|
+
sendJson(res, 403, { error: 'Dashboard is in public mode. Editing is disabled.' });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
448
659
|
// ── Pages system routing ──
|
|
449
660
|
const pageMatch = matchPageRoute(loadedPages, req.method, pathname, parsedUrl);
|
|
450
661
|
if (pageMatch) {
|
|
@@ -848,9 +1059,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
848
1059
|
return;
|
|
849
1060
|
}
|
|
850
1061
|
|
|
851
|
-
// GET /api/rss?url=<feedUrl> - Server-side RSS proxy
|
|
1062
|
+
// GET /api/rss?url=<feedUrl>&widgetId=<id>&secretKey=<key> - Server-side RSS proxy
|
|
852
1063
|
if (req.method === 'GET' && pathname === '/api/rss') {
|
|
853
|
-
|
|
1064
|
+
let feedUrl = parsedUrl.searchParams.get('url');
|
|
1065
|
+
const rssWidgetId = parsedUrl.searchParams.get('widgetId');
|
|
1066
|
+
const rssSecretKey = parsedUrl.searchParams.get('secretKey') || 'feedUrl';
|
|
1067
|
+
if ((!feedUrl || feedUrl === '••••••••' || feedUrl === '__SECRET__') && rssWidgetId) {
|
|
1068
|
+
const secrets = getSecrets();
|
|
1069
|
+
feedUrl = secrets[rssWidgetId]?.[rssSecretKey] || null;
|
|
1070
|
+
}
|
|
854
1071
|
if (!feedUrl) { sendError(res, 'Missing url parameter', 400); return; }
|
|
855
1072
|
|
|
856
1073
|
// Validate URL: only http/https, block private/internal IPs (SSRF protection)
|
|
@@ -903,10 +1120,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
903
1120
|
return;
|
|
904
1121
|
}
|
|
905
1122
|
|
|
906
|
-
// GET /api/calendar?url=<icalUrl>&max=<maxEvents> - iCal feed proxy + parser
|
|
1123
|
+
// GET /api/calendar?url=<icalUrl>&max=<maxEvents>&widgetId=<id>&secretKey=<key> - iCal feed proxy + parser
|
|
907
1124
|
if (req.method === 'GET' && pathname === '/api/calendar') {
|
|
908
|
-
|
|
1125
|
+
let icalUrl = parsedUrl.searchParams.get('url');
|
|
909
1126
|
const maxEvents = Math.min(parseInt(parsedUrl.searchParams.get('max')) || 10, 50);
|
|
1127
|
+
const widgetId = parsedUrl.searchParams.get('widgetId');
|
|
1128
|
+
const secretKey = parsedUrl.searchParams.get('secretKey') || 'icalUrl';
|
|
1129
|
+
// If url is masked/placeholder, resolve from secrets
|
|
1130
|
+
if ((!icalUrl || icalUrl === '••••••••' || icalUrl === '__SECRET__') && widgetId) {
|
|
1131
|
+
const secrets = getSecrets();
|
|
1132
|
+
icalUrl = secrets[widgetId]?.[secretKey] || null;
|
|
1133
|
+
}
|
|
910
1134
|
if (!icalUrl) { sendError(res, 'Missing url parameter', 400); return; }
|
|
911
1135
|
|
|
912
1136
|
// Validate URL: only http/https, block private/internal IPs
|
|
@@ -981,7 +1205,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
981
1205
|
try {
|
|
982
1206
|
const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
983
1207
|
const w = (cfg.widgets || []).find(w => w.type === 'ai-usage-claude');
|
|
984
|
-
if (w && w.properties && w.properties.apiKey
|
|
1208
|
+
if (w && w.properties && w.properties.apiKey && w.properties.apiKey !== '__SECRET__') {
|
|
1209
|
+
apiKey = w.properties.apiKey;
|
|
1210
|
+
} else if (w) {
|
|
1211
|
+
// Check secrets store
|
|
1212
|
+
const secrets = getSecrets();
|
|
1213
|
+
apiKey = secrets[w.id]?.apiKey || null;
|
|
1214
|
+
}
|
|
985
1215
|
} catch(e) {}
|
|
986
1216
|
}
|
|
987
1217
|
if (!apiKey) { sendJson(res, 200, { error: 'No API key configured. Add your Anthropic Admin key in the widget properties.', tokens: 0, cost: 0, models: [] }); return; }
|
|
@@ -1051,7 +1281,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1051
1281
|
try {
|
|
1052
1282
|
const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
1053
1283
|
const w = (cfg.widgets || []).find(w => w.type === 'ai-usage-openai');
|
|
1054
|
-
if (w && w.properties && w.properties.apiKey
|
|
1284
|
+
if (w && w.properties && w.properties.apiKey && w.properties.apiKey !== '__SECRET__') {
|
|
1285
|
+
apiKey = w.properties.apiKey;
|
|
1286
|
+
} else if (w) {
|
|
1287
|
+
const secrets = getSecrets();
|
|
1288
|
+
apiKey = secrets[w.id]?.apiKey || null;
|
|
1289
|
+
}
|
|
1055
1290
|
} catch(e) {}
|
|
1056
1291
|
}
|
|
1057
1292
|
if (!apiKey) { sendJson(res, 200, { error: 'No API key configured. Add your OpenAI key in the widget properties.', tokens: 0, cost: 0, models: [] }); return; }
|