aigo 1.1.0 → 1.2.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/Makefile +5 -1
- package/README.md +34 -5
- package/lib/config.js +17 -1
- package/lib/server.js +174 -6
- package/package.json +1 -1
- package/public/index.html +0 -13
- package/public/terminal.js +83 -11
package/Makefile
CHANGED
package/README.md
CHANGED
|
@@ -228,17 +228,46 @@ aigo uses multiple layers of security:
|
|
|
228
228
|
|
|
229
229
|
1. **URL Token** - A random 4-character token in the URL path (`/t/<token>`)
|
|
230
230
|
2. **Password** - A 6-character alphanumeric password (or custom password via `-P`)
|
|
231
|
-
3. **
|
|
232
|
-
4. **
|
|
231
|
+
3. **Brute-Force Protection** - Progressive delays and lockout after failed attempts
|
|
232
|
+
4. **Session Lock** - Screen locks after inactivity (disabled by default, enable with `-T <mins>`)
|
|
233
|
+
5. **Auto Exit** - Session terminates after inactivity (disabled by default, enable with `-E <mins>`)
|
|
233
234
|
|
|
234
235
|
**Security features:**
|
|
235
236
|
- Token required in URL (403 Forbidden without it)
|
|
236
237
|
- Password required before terminal access
|
|
237
238
|
- Password uses unambiguous characters (no 0/O, 1/l/I)
|
|
239
|
+
- Brute-force protection with progressive delays (see below)
|
|
238
240
|
- Lock screen requires re-entering password after inactivity (when enabled)
|
|
239
241
|
- Session auto-terminates and kills tmux after extended inactivity (when enabled)
|
|
240
242
|
- HTTPS via tunnel (encrypted in transit)
|
|
241
243
|
|
|
244
|
+
### Brute-Force Protection
|
|
245
|
+
|
|
246
|
+
aigo automatically protects against password brute-force attacks with:
|
|
247
|
+
- **Progressive delays** after 5 failed attempts
|
|
248
|
+
- **IP-based tracking** that persists across page refreshes
|
|
249
|
+
- **15-minute lockout** after 10 failed attempts
|
|
250
|
+
|
|
251
|
+
| Attempt | Behavior |
|
|
252
|
+
|---------|----------|
|
|
253
|
+
| 1-4 | Instant response |
|
|
254
|
+
| 5 | 1 second wait (input disabled, countdown shown) |
|
|
255
|
+
| 6 | 2 second wait |
|
|
256
|
+
| 7 | 4 second wait |
|
|
257
|
+
| 8 | 8 second wait |
|
|
258
|
+
| 9 | 16 second wait |
|
|
259
|
+
| 10+ | **IP locked out for 15 minutes** |
|
|
260
|
+
|
|
261
|
+
After 5 failed attempts, the password input is disabled and a countdown timer shows the remaining wait time. After 10 failed attempts, the IP address is locked out for 15 minutes - refreshing the page won't reset the counter.
|
|
262
|
+
|
|
263
|
+
Configure in `lib/config.js`:
|
|
264
|
+
```javascript
|
|
265
|
+
maxAuthAttempts: 10, // Lock out IP after this many failures
|
|
266
|
+
authDelayThreshold: 5, // Start delays after this many failures
|
|
267
|
+
authBaseDelayMs: 1000, // Base delay (doubles each attempt)
|
|
268
|
+
authLockoutMins: 15, // Lockout duration after max attempts
|
|
269
|
+
```
|
|
270
|
+
|
|
242
271
|
**Recommendations:**
|
|
243
272
|
- Use HTTPS (both ngrok and cloudflared provide this automatically)
|
|
244
273
|
- Don't share your access URL or password publicly
|
|
@@ -278,7 +307,6 @@ When accessing aigo from a mobile device (phone or tablet), control buttons appe
|
|
|
278
307
|
| **Mode** | `Shift+Tab` | Toggle between chat/edit modes in Claude Code or switch modes in Cursor Agent |
|
|
279
308
|
| **Enter** | `Enter` | Send Enter key to confirm prompts or submit input |
|
|
280
309
|
| **Stop** | `Ctrl+C` | Interrupt the current operation (stop running commands or cancel AI responses) |
|
|
281
|
-
| **Exit** | Cleanup | Shows confirmation dialog, then kills tmux session and stops the server |
|
|
282
310
|
|
|
283
311
|
These buttons are useful because mobile keyboards don't easily support modifier key combinations like Ctrl+C or Shift+Tab.
|
|
284
312
|
|
|
@@ -523,10 +551,11 @@ npx nodemon bin/aigo.js claude
|
|
|
523
551
|
Bump the minor version and publish to npm:
|
|
524
552
|
|
|
525
553
|
```bash
|
|
526
|
-
make publish-minor
|
|
554
|
+
make publish-minor # bump minor (e.g. 1.0.1 → 1.1.0), then publish
|
|
555
|
+
make publish-patch # bump patch (e.g. 1.0.1 → 1.0.2), then publish
|
|
527
556
|
```
|
|
528
557
|
|
|
529
|
-
|
|
558
|
+
Each runs `npm version minor` or `npm version patch` (updates `package.json` and creates a git commit/tag) then `npm publish`.
|
|
530
559
|
|
|
531
560
|
## License
|
|
532
561
|
|
package/lib/config.js
CHANGED
|
@@ -74,7 +74,23 @@ export const config = {
|
|
|
74
74
|
maxReconnectAttempts: 10,
|
|
75
75
|
|
|
76
76
|
/** Base delay between reconnection attempts (ms) */
|
|
77
|
-
reconnectDelay: 2000
|
|
77
|
+
reconnectDelay: 2000,
|
|
78
|
+
|
|
79
|
+
// ============================================
|
|
80
|
+
// Brute-Force Protection
|
|
81
|
+
// ============================================
|
|
82
|
+
|
|
83
|
+
/** Maximum auth attempts before locking out the IP */
|
|
84
|
+
maxAuthAttempts: 10,
|
|
85
|
+
|
|
86
|
+
/** Start progressive delays after this many failures */
|
|
87
|
+
authDelayThreshold: 5,
|
|
88
|
+
|
|
89
|
+
/** Base delay in ms (doubles each attempt after threshold) */
|
|
90
|
+
authBaseDelayMs: 1000,
|
|
91
|
+
|
|
92
|
+
/** Lockout duration in minutes after max attempts reached */
|
|
93
|
+
authLockoutMins: 15
|
|
78
94
|
};
|
|
79
95
|
|
|
80
96
|
export default config;
|
package/lib/server.js
CHANGED
|
@@ -6,10 +6,82 @@ import { fileURLToPath } from 'url';
|
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
import pty from 'node-pty';
|
|
8
8
|
import { getTmuxPath, killSession } from './tmux.js';
|
|
9
|
+
import { config } from './config.js';
|
|
9
10
|
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
12
|
const __dirname = path.dirname(__filename);
|
|
12
13
|
|
|
14
|
+
// IP-based brute-force tracking (persists across connections)
|
|
15
|
+
// Map<ip, { attempts: number, lockedUntil: number | null }>
|
|
16
|
+
const ipAttempts = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get client IP from request, checking forwarded headers for tunnels
|
|
20
|
+
*/
|
|
21
|
+
function getClientIp(req) {
|
|
22
|
+
// Check X-Forwarded-For header (used by ngrok, cloudflared, proxies)
|
|
23
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
24
|
+
if (forwarded) {
|
|
25
|
+
// Take the first IP in the chain (original client)
|
|
26
|
+
return forwarded.split(',')[0].trim();
|
|
27
|
+
}
|
|
28
|
+
// Fall back to direct connection IP
|
|
29
|
+
return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if IP is locked out
|
|
34
|
+
*/
|
|
35
|
+
function isIpLockedOut(ip) {
|
|
36
|
+
const record = ipAttempts.get(ip);
|
|
37
|
+
if (!record) return false;
|
|
38
|
+
if (record.lockedUntil && Date.now() < record.lockedUntil) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
// Lockout expired, reset
|
|
42
|
+
if (record.lockedUntil && Date.now() >= record.lockedUntil) {
|
|
43
|
+
ipAttempts.delete(ip);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get remaining lockout time in ms
|
|
51
|
+
*/
|
|
52
|
+
function getLockoutRemaining(ip) {
|
|
53
|
+
const record = ipAttempts.get(ip);
|
|
54
|
+
if (!record || !record.lockedUntil) return 0;
|
|
55
|
+
return Math.max(0, record.lockedUntil - Date.now());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Record failed attempt for IP
|
|
60
|
+
*/
|
|
61
|
+
function recordFailedAttempt(ip) {
|
|
62
|
+
let record = ipAttempts.get(ip);
|
|
63
|
+
if (!record) {
|
|
64
|
+
record = { attempts: 0, lockedUntil: null };
|
|
65
|
+
ipAttempts.set(ip, record);
|
|
66
|
+
}
|
|
67
|
+
record.attempts++;
|
|
68
|
+
|
|
69
|
+
// Check if should lock out
|
|
70
|
+
if (record.attempts >= config.maxAuthAttempts) {
|
|
71
|
+
record.lockedUntil = Date.now() + (config.authLockoutMins * 60 * 1000);
|
|
72
|
+
console.log(`IP ${ip} locked out for ${config.authLockoutMins} minutes`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return record;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reset attempts for IP (on successful auth)
|
|
80
|
+
*/
|
|
81
|
+
function resetIpAttempts(ip) {
|
|
82
|
+
ipAttempts.delete(ip);
|
|
83
|
+
}
|
|
84
|
+
|
|
13
85
|
/**
|
|
14
86
|
* Start the web server with WebSocket support
|
|
15
87
|
*/
|
|
@@ -84,8 +156,9 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
|
|
|
84
156
|
// WebSocket server
|
|
85
157
|
const wss = new WebSocketServer({ server, path: `/ws/${token}` });
|
|
86
158
|
|
|
87
|
-
wss.on('connection', (ws) => {
|
|
88
|
-
|
|
159
|
+
wss.on('connection', (ws, req) => {
|
|
160
|
+
const clientIp = getClientIp(req);
|
|
161
|
+
console.log(`WebSocket client connected from ${clientIp}`);
|
|
89
162
|
hasActiveConnection = true;
|
|
90
163
|
updateActivity();
|
|
91
164
|
|
|
@@ -95,6 +168,24 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
|
|
|
95
168
|
let ptyProcess = null;
|
|
96
169
|
let authenticated = false;
|
|
97
170
|
|
|
171
|
+
// Check if IP is already locked out
|
|
172
|
+
if (isIpLockedOut(clientIp)) {
|
|
173
|
+
const remainingMs = getLockoutRemaining(clientIp);
|
|
174
|
+
const remainingMins = Math.ceil(remainingMs / 60000);
|
|
175
|
+
console.log(`IP ${clientIp} is locked out for ${remainingMins} more minutes`);
|
|
176
|
+
ws.send(JSON.stringify({
|
|
177
|
+
type: 'auth_locked',
|
|
178
|
+
message: `Too many failed attempts. Try again in ${remainingMins} minute${remainingMins !== 1 ? 's' : ''}.`,
|
|
179
|
+
lockoutRemainingMs: remainingMs
|
|
180
|
+
}));
|
|
181
|
+
ws.close(1008, 'IP locked out');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Get current attempt count for this IP
|
|
186
|
+
const ipRecord = ipAttempts.get(clientIp);
|
|
187
|
+
const currentAttempts = ipRecord ? ipRecord.attempts : 0;
|
|
188
|
+
|
|
98
189
|
// Send auth request
|
|
99
190
|
ws.send(JSON.stringify({ type: 'auth_required' }));
|
|
100
191
|
|
|
@@ -107,10 +198,25 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
|
|
|
107
198
|
try {
|
|
108
199
|
const parsed = JSON.parse(message);
|
|
109
200
|
if (parsed.type === 'auth' && parsed.password) {
|
|
201
|
+
// Check if IP is locked out (could have been locked by another connection)
|
|
202
|
+
if (isIpLockedOut(clientIp)) {
|
|
203
|
+
const remainingMs = getLockoutRemaining(clientIp);
|
|
204
|
+
const remainingMins = Math.ceil(remainingMs / 60000);
|
|
205
|
+
console.log(`IP ${clientIp} is locked out`);
|
|
206
|
+
ws.send(JSON.stringify({
|
|
207
|
+
type: 'auth_locked',
|
|
208
|
+
message: `Too many failed attempts. Try again in ${remainingMins} minute${remainingMins !== 1 ? 's' : ''}.`,
|
|
209
|
+
lockoutRemainingMs: remainingMs
|
|
210
|
+
}));
|
|
211
|
+
ws.close(1008, 'IP locked out');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
110
215
|
if (parsed.password === password) {
|
|
111
216
|
authenticated = true;
|
|
217
|
+
resetIpAttempts(clientIp); // Clear failed attempts on success
|
|
112
218
|
updateActivity();
|
|
113
|
-
console.log(
|
|
219
|
+
console.log(`Client ${clientIp} authenticated successfully`);
|
|
114
220
|
|
|
115
221
|
// Send auth success with config
|
|
116
222
|
ws.send(JSON.stringify({
|
|
@@ -160,8 +266,33 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
|
|
|
160
266
|
ws.close(1011, 'PTY spawn failed');
|
|
161
267
|
}
|
|
162
268
|
} else {
|
|
163
|
-
|
|
164
|
-
|
|
269
|
+
// Authentication failed - use IP-based tracking
|
|
270
|
+
const record = recordFailedAttempt(clientIp);
|
|
271
|
+
const attemptsRemaining = config.maxAuthAttempts - record.attempts;
|
|
272
|
+
console.log(`Client ${clientIp} authentication failed (attempt ${record.attempts}/${config.maxAuthAttempts})`);
|
|
273
|
+
|
|
274
|
+
// Calculate delay for client-side countdown (after threshold)
|
|
275
|
+
let delayMs = 0;
|
|
276
|
+
if (record.attempts >= config.authDelayThreshold) {
|
|
277
|
+
delayMs = config.authBaseDelayMs * Math.pow(2, record.attempts - config.authDelayThreshold);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Check if should lock out
|
|
281
|
+
if (record.attempts >= config.maxAuthAttempts) {
|
|
282
|
+
const lockoutMs = config.authLockoutMins * 60 * 1000;
|
|
283
|
+
ws.send(JSON.stringify({
|
|
284
|
+
type: 'auth_locked',
|
|
285
|
+
message: `Too many failed attempts. Try again in ${config.authLockoutMins} minutes.`,
|
|
286
|
+
lockoutRemainingMs: lockoutMs
|
|
287
|
+
}));
|
|
288
|
+
ws.close(1008, 'Too many failed attempts');
|
|
289
|
+
} else {
|
|
290
|
+
ws.send(JSON.stringify({
|
|
291
|
+
type: 'auth_failed',
|
|
292
|
+
attemptsRemaining: attemptsRemaining,
|
|
293
|
+
delayMs: delayMs // Client will show countdown and disable input
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
165
296
|
}
|
|
166
297
|
return;
|
|
167
298
|
}
|
|
@@ -192,11 +323,48 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
|
|
|
192
323
|
|
|
193
324
|
// Handle re-auth (after lock screen)
|
|
194
325
|
if (parsed.type === 're-auth' && parsed.password) {
|
|
326
|
+
// Check if IP is locked out
|
|
327
|
+
if (isIpLockedOut(clientIp)) {
|
|
328
|
+
const remainingMs = getLockoutRemaining(clientIp);
|
|
329
|
+
const remainingMins = Math.ceil(remainingMs / 60000);
|
|
330
|
+
ws.send(JSON.stringify({
|
|
331
|
+
type: 'auth_locked',
|
|
332
|
+
message: `Too many failed attempts. Try again in ${remainingMins} minute${remainingMins !== 1 ? 's' : ''}.`,
|
|
333
|
+
lockoutRemainingMs: remainingMs
|
|
334
|
+
}));
|
|
335
|
+
ws.close(1008, 'IP locked out');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
195
339
|
if (parsed.password === password) {
|
|
340
|
+
resetIpAttempts(clientIp);
|
|
196
341
|
updateActivity();
|
|
197
342
|
ws.send(JSON.stringify({ type: 'auth_success' }));
|
|
198
343
|
} else {
|
|
199
|
-
|
|
344
|
+
const record = recordFailedAttempt(clientIp);
|
|
345
|
+
const attemptsRemaining = config.maxAuthAttempts - record.attempts;
|
|
346
|
+
console.log(`Re-auth failed for ${clientIp} (attempt ${record.attempts}/${config.maxAuthAttempts})`);
|
|
347
|
+
|
|
348
|
+
let delayMs = 0;
|
|
349
|
+
if (record.attempts >= config.authDelayThreshold) {
|
|
350
|
+
delayMs = config.authBaseDelayMs * Math.pow(2, record.attempts - config.authDelayThreshold);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (record.attempts >= config.maxAuthAttempts) {
|
|
354
|
+
const lockoutMs = config.authLockoutMins * 60 * 1000;
|
|
355
|
+
ws.send(JSON.stringify({
|
|
356
|
+
type: 'auth_locked',
|
|
357
|
+
message: `Too many failed attempts. Try again in ${config.authLockoutMins} minutes.`,
|
|
358
|
+
lockoutRemainingMs: lockoutMs
|
|
359
|
+
}));
|
|
360
|
+
ws.close(1008, 'Too many failed attempts');
|
|
361
|
+
} else {
|
|
362
|
+
ws.send(JSON.stringify({
|
|
363
|
+
type: 'auth_failed',
|
|
364
|
+
attemptsRemaining: attemptsRemaining,
|
|
365
|
+
delayMs: delayMs
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
200
368
|
}
|
|
201
369
|
return;
|
|
202
370
|
}
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -467,18 +467,6 @@
|
|
|
467
467
|
color: var(--bg-primary);
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
/* Exit button */
|
|
471
|
-
#mobile-exit-btn {
|
|
472
|
-
color: #ff5555;
|
|
473
|
-
border-color: #ff5555;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
#mobile-exit-btn:hover,
|
|
477
|
-
#mobile-exit-btn:active {
|
|
478
|
-
background: #ff5555;
|
|
479
|
-
color: white;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
470
|
/* History button */
|
|
483
471
|
#mobile-history-btn {
|
|
484
472
|
color: #00ffff;
|
|
@@ -646,7 +634,6 @@
|
|
|
646
634
|
<button id="mobile-mode-toggle" class="mobile-btn" title="Switch mode (Shift+Tab)">Mode</button>
|
|
647
635
|
<button id="mobile-enter-btn" class="mobile-btn" title="Send Enter key">Enter</button>
|
|
648
636
|
<button id="mobile-stop-btn" class="mobile-btn" title="Stop (Ctrl+C)">Stop</button>
|
|
649
|
-
<button id="mobile-exit-btn" class="mobile-btn" title="Exit session">Exit</button>
|
|
650
637
|
</div>
|
|
651
638
|
|
|
652
639
|
<!-- History modal overlay -->
|
package/public/terminal.js
CHANGED
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
const mobileToggleBtn = document.getElementById('mobile-mode-toggle');
|
|
52
52
|
const mobileEnterBtn = document.getElementById('mobile-enter-btn');
|
|
53
53
|
const mobileStopBtn = document.getElementById('mobile-stop-btn');
|
|
54
|
-
const mobileExitBtn = document.getElementById('mobile-exit-btn');
|
|
55
54
|
const mobileHistoryBtn = document.getElementById('mobile-history-btn');
|
|
56
55
|
const historyOverlay = document.getElementById('history-overlay');
|
|
57
56
|
const historyCloseBtn = document.getElementById('history-close-btn');
|
|
@@ -451,14 +450,6 @@
|
|
|
451
450
|
}
|
|
452
451
|
});
|
|
453
452
|
|
|
454
|
-
// Mobile exit button handler (shows confirmation dialog)
|
|
455
|
-
mobileExitBtn.addEventListener('click', () => {
|
|
456
|
-
if (authenticated && !isLocked) {
|
|
457
|
-
mobileButtonFeedback(mobileExitBtn);
|
|
458
|
-
showExitConfirm();
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
|
|
462
453
|
// Strip ANSI escape codes from text for clean display
|
|
463
454
|
function stripAnsiCodes(text) {
|
|
464
455
|
// Remove ANSI escape sequences (colors, cursor movement, etc.)
|
|
@@ -654,12 +645,93 @@
|
|
|
654
645
|
}
|
|
655
646
|
|
|
656
647
|
if (msg.type === 'auth_failed') {
|
|
657
|
-
authError.textContent = 'Invalid password';
|
|
658
648
|
if (!isLocked) {
|
|
659
649
|
savedPassword = null;
|
|
660
650
|
}
|
|
661
651
|
passwordInput.value = '';
|
|
662
|
-
|
|
652
|
+
|
|
653
|
+
// Check if there's a delay (brute-force protection kicked in)
|
|
654
|
+
if (msg.delayMs && msg.delayMs > 0) {
|
|
655
|
+
// Disable input and show countdown
|
|
656
|
+
passwordInput.disabled = true;
|
|
657
|
+
authBtn.disabled = true;
|
|
658
|
+
|
|
659
|
+
let remainingMs = msg.delayMs;
|
|
660
|
+
const updateCountdown = () => {
|
|
661
|
+
const seconds = Math.ceil(remainingMs / 1000);
|
|
662
|
+
authError.textContent = `Too many attempts. Wait ${seconds}s (${msg.attemptsRemaining} attempts remaining)`;
|
|
663
|
+
authError.style.color = '#ffaa00';
|
|
664
|
+
};
|
|
665
|
+
updateCountdown();
|
|
666
|
+
|
|
667
|
+
const countdownInterval = setInterval(() => {
|
|
668
|
+
remainingMs -= 100;
|
|
669
|
+
if (remainingMs <= 0) {
|
|
670
|
+
clearInterval(countdownInterval);
|
|
671
|
+
passwordInput.disabled = false;
|
|
672
|
+
authBtn.disabled = false;
|
|
673
|
+
authError.textContent = `Invalid password (${msg.attemptsRemaining} attempts remaining)`;
|
|
674
|
+
authError.style.color = '#ff5555';
|
|
675
|
+
passwordInput.focus();
|
|
676
|
+
} else {
|
|
677
|
+
updateCountdown();
|
|
678
|
+
}
|
|
679
|
+
}, 100);
|
|
680
|
+
} else {
|
|
681
|
+
// No delay, just show error with attempts remaining
|
|
682
|
+
let errorMsg = 'Invalid password';
|
|
683
|
+
if (typeof msg.attemptsRemaining === 'number') {
|
|
684
|
+
errorMsg += ` (${msg.attemptsRemaining} attempts remaining)`;
|
|
685
|
+
}
|
|
686
|
+
authError.textContent = errorMsg;
|
|
687
|
+
authError.style.color = '#ff5555';
|
|
688
|
+
passwordInput.focus();
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (msg.type === 'auth_locked') {
|
|
694
|
+
// Locked out - too many attempts
|
|
695
|
+
passwordInput.disabled = true;
|
|
696
|
+
authBtn.disabled = true;
|
|
697
|
+
authBtn.textContent = 'Locked';
|
|
698
|
+
authError.style.color = '#ff5555';
|
|
699
|
+
if (!isLocked) {
|
|
700
|
+
savedPassword = null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Show countdown if lockout time is provided
|
|
704
|
+
if (msg.lockoutRemainingMs && msg.lockoutRemainingMs > 0) {
|
|
705
|
+
let remainingMs = msg.lockoutRemainingMs;
|
|
706
|
+
const updateLockoutCountdown = () => {
|
|
707
|
+
const mins = Math.floor(remainingMs / 60000);
|
|
708
|
+
const secs = Math.ceil((remainingMs % 60000) / 1000);
|
|
709
|
+
if (mins > 0) {
|
|
710
|
+
authError.textContent = `Locked out. Try again in ${mins}m ${secs}s`;
|
|
711
|
+
} else {
|
|
712
|
+
authError.textContent = `Locked out. Try again in ${secs}s`;
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
updateLockoutCountdown();
|
|
716
|
+
|
|
717
|
+
const lockoutInterval = setInterval(() => {
|
|
718
|
+
remainingMs -= 1000;
|
|
719
|
+
if (remainingMs <= 0) {
|
|
720
|
+
clearInterval(lockoutInterval);
|
|
721
|
+
// Re-enable and allow retry
|
|
722
|
+
passwordInput.disabled = false;
|
|
723
|
+
authBtn.disabled = false;
|
|
724
|
+
authBtn.textContent = 'Connect';
|
|
725
|
+
authError.textContent = 'You can try again now';
|
|
726
|
+
authError.style.color = '#00ff88';
|
|
727
|
+
passwordInput.focus();
|
|
728
|
+
} else {
|
|
729
|
+
updateLockoutCountdown();
|
|
730
|
+
}
|
|
731
|
+
}, 1000);
|
|
732
|
+
} else {
|
|
733
|
+
authError.textContent = msg.message || 'Too many failed attempts. Please reconnect.';
|
|
734
|
+
}
|
|
663
735
|
return;
|
|
664
736
|
}
|
|
665
737
|
|