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 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; }
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.2.2 - Dashboard Styles */
1
+ /* LobsterBoard v0.2.3 - Dashboard Styles */
2
2
  /* LobsterBoard Dashboard - Generated Styles */
3
3
 
4
4
  :root {
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.2.2
2
+ * LobsterBoard v0.2.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.2.2
2
+ * LobsterBoard v0.2.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.2.2
2
+ * LobsterBoard v0.2.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.2.2
2
+ * LobsterBoard v0.2.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
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', () => setEditMode(true));
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', () => setEditMode(true));
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
- setEditMode(!state.editMode);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobsterboard",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Self-hosted drag-and-drop dashboard builder with 50 widgets, template gallery, and custom pages. Works standalone or with OpenClaw.",
5
5
  "keywords": [
6
6
  "dashboard",
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
- const config = JSON.parse(body);
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
- const feedUrl = parsedUrl.searchParams.get('url');
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
- const icalUrl = parsedUrl.searchParams.get('url');
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) apiKey = 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) apiKey = 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; }