claude-code-limiter-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1650 @@
1
+ /**
2
+ * app.js — Main SPA logic for the Claude Code Limiter dashboard.
3
+ * Hash-based routing, API client, page renderers, event delegation.
4
+ */
5
+ (function () {
6
+ 'use strict';
7
+
8
+ /* ================================================================
9
+ STATE
10
+ ================================================================ */
11
+ var state = {
12
+ token: localStorage.getItem('clm_token') || null,
13
+ team: null,
14
+ users: [],
15
+ events: [], // live feed events
16
+ refreshTimer: null,
17
+ currentRoute: null,
18
+ };
19
+
20
+ /* ================================================================
21
+ API CLIENT
22
+ ================================================================ */
23
+ var API = {
24
+ /** @param {string} path @param {object} [opts] @returns {Promise<object>} */
25
+ _fetch: function (path, opts) {
26
+ opts = opts || {};
27
+ var headers = { 'Content-Type': 'application/json' };
28
+ if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
29
+ return fetch(path, {
30
+ method: opts.method || 'GET',
31
+ headers: headers,
32
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
33
+ }).then(function (res) {
34
+ if (res.status === 401) {
35
+ state.token = null;
36
+ localStorage.removeItem('clm_token');
37
+ navigate('login');
38
+ return Promise.reject(new Error('Session expired'));
39
+ }
40
+ return res.json().then(function (data) {
41
+ if (!res.ok) return Promise.reject(new Error(data.error || 'Request failed'));
42
+ return data;
43
+ });
44
+ });
45
+ },
46
+
47
+ login: function (password) {
48
+ return API._fetch('/api/admin/login', { method: 'POST', body: { password: password } });
49
+ },
50
+
51
+ getUsers: function () {
52
+ return API._fetch('/api/admin/users');
53
+ },
54
+
55
+ getUser: function (id) {
56
+ return API._fetch('/api/admin/users').then(function (data) {
57
+ var user = null;
58
+ for (var i = 0; i < data.users.length; i++) {
59
+ if (data.users[i].id === id) { user = data.users[i]; break; }
60
+ }
61
+ if (!user) return Promise.reject(new Error('User not found'));
62
+ return user;
63
+ });
64
+ },
65
+
66
+ createUser: function (body) {
67
+ return API._fetch('/api/admin/users', { method: 'POST', body: body });
68
+ },
69
+
70
+ updateUser: function (id, body) {
71
+ return API._fetch('/api/admin/users/' + id, { method: 'PUT', body: body });
72
+ },
73
+
74
+ deleteUser: function (id) {
75
+ return API._fetch('/api/admin/users/' + id, { method: 'DELETE' });
76
+ },
77
+
78
+ getUsage: function (params) {
79
+ var qs = '';
80
+ if (params) {
81
+ var parts = [];
82
+ for (var k in params) {
83
+ if (params[k] != null) parts.push(k + '=' + encodeURIComponent(params[k]));
84
+ }
85
+ qs = '?' + parts.join('&');
86
+ }
87
+ return API._fetch('/api/admin/usage' + qs);
88
+ },
89
+
90
+ getEvents: function (params) {
91
+ var qs = '';
92
+ if (params) {
93
+ var parts = [];
94
+ for (var k in params) {
95
+ if (params[k] != null) parts.push(k + '=' + encodeURIComponent(params[k]));
96
+ }
97
+ qs = '?' + parts.join('&');
98
+ }
99
+ return API._fetch('/api/admin/events' + qs);
100
+ },
101
+
102
+ updateSettings: function (body) {
103
+ return API._fetch('/api/admin/settings', { method: 'PUT', body: body });
104
+ },
105
+ };
106
+
107
+ /* ================================================================
108
+ TOAST NOTIFICATIONS
109
+ ================================================================ */
110
+ var toastCounter = 0;
111
+
112
+ /**
113
+ * Show a toast notification.
114
+ * @param {string} title
115
+ * @param {string} message
116
+ * @param {'info'|'success'|'warning'|'error'} [type]
117
+ * @param {number} [duration] - ms, default 5000
118
+ */
119
+ function showToast(title, message, type, duration) {
120
+ type = type || 'info';
121
+ duration = duration || 5000;
122
+ var id = 'toast-' + (++toastCounter);
123
+ var icons = { info: '\u2139\uFE0F', success: '\u2705', warning: '\u26A0\uFE0F', error: '\u274C' };
124
+
125
+ var container = document.getElementById('toast-container');
126
+ var el = document.createElement('div');
127
+ el.className = 'toast toast-' + type;
128
+ el.id = id;
129
+ el.innerHTML =
130
+ '<span class="toast-icon">' + (icons[type] || '') + '</span>' +
131
+ '<div class="toast-body">' +
132
+ '<div class="toast-title">' + escapeHtml(title) + '</div>' +
133
+ '<div class="toast-message">' + escapeHtml(message) + '</div>' +
134
+ '</div>' +
135
+ '<button class="toast-close" data-toast-close="' + id + '">&times;</button>';
136
+ container.appendChild(el);
137
+
138
+ var removeTimer = setTimeout(function () { removeToast(id); }, duration);
139
+
140
+ el.querySelector('.toast-close').addEventListener('click', function () {
141
+ clearTimeout(removeTimer);
142
+ removeToast(id);
143
+ });
144
+ }
145
+
146
+ function removeToast(id) {
147
+ var el = document.getElementById(id);
148
+ if (!el) return;
149
+ el.classList.add('removing');
150
+ setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 250);
151
+ }
152
+
153
+ /* ================================================================
154
+ CONFIRM DIALOG
155
+ ================================================================ */
156
+ /**
157
+ * Show a confirm dialog. Returns a Promise that resolves true/false.
158
+ * @param {string} title
159
+ * @param {string} message
160
+ * @param {string} [okLabel]
161
+ * @returns {Promise<boolean>}
162
+ */
163
+ function confirmDialog(title, message, okLabel) {
164
+ return new Promise(function (resolve) {
165
+ var overlay = document.getElementById('confirm-overlay');
166
+ document.getElementById('confirm-title').textContent = title;
167
+ document.getElementById('confirm-message').textContent = message;
168
+ var okBtn = document.getElementById('confirm-ok');
169
+ okBtn.textContent = okLabel || 'Confirm';
170
+ overlay.classList.remove('hidden');
171
+
172
+ function cleanup(result) {
173
+ overlay.classList.add('hidden');
174
+ document.getElementById('confirm-cancel').removeEventListener('click', onCancel);
175
+ okBtn.removeEventListener('click', onOk);
176
+ resolve(result);
177
+ }
178
+ function onCancel() { cleanup(false); }
179
+ function onOk() { cleanup(true); }
180
+
181
+ document.getElementById('confirm-cancel').addEventListener('click', onCancel);
182
+ okBtn.addEventListener('click', onOk);
183
+ });
184
+ }
185
+
186
+ /* ================================================================
187
+ MODAL HELPERS
188
+ ================================================================ */
189
+ function openModal(html) {
190
+ var overlay = document.getElementById('modal-overlay');
191
+ document.getElementById('modal-content').innerHTML = html;
192
+ overlay.classList.remove('hidden');
193
+ }
194
+
195
+ function closeModal() {
196
+ document.getElementById('modal-overlay').classList.add('hidden');
197
+ document.getElementById('modal-content').innerHTML = '';
198
+ }
199
+
200
+ /* ================================================================
201
+ COPY TO CLIPBOARD
202
+ ================================================================ */
203
+ function copyToClipboard(text) {
204
+ if (navigator.clipboard && navigator.clipboard.writeText) {
205
+ navigator.clipboard.writeText(text).then(function () {
206
+ showToast('Copied', 'Command copied to clipboard', 'success', 2500);
207
+ }).catch(function () {
208
+ fallbackCopy(text);
209
+ });
210
+ } else {
211
+ fallbackCopy(text);
212
+ }
213
+ }
214
+
215
+ function fallbackCopy(text) {
216
+ var ta = document.createElement('textarea');
217
+ ta.value = text;
218
+ ta.style.position = 'fixed';
219
+ ta.style.left = '-9999px';
220
+ document.body.appendChild(ta);
221
+ ta.select();
222
+ try {
223
+ document.execCommand('copy');
224
+ showToast('Copied', 'Command copied to clipboard', 'success', 2500);
225
+ } catch (e) {
226
+ showToast('Error', 'Could not copy to clipboard', 'error');
227
+ }
228
+ document.body.removeChild(ta);
229
+ }
230
+
231
+ /* ================================================================
232
+ UTILITY HELPERS
233
+ ================================================================ */
234
+ function escapeHtml(str) {
235
+ if (!str) return '';
236
+ return String(str)
237
+ .replace(/&/g, '&amp;')
238
+ .replace(/</g, '&lt;')
239
+ .replace(/>/g, '&gt;')
240
+ .replace(/"/g, '&quot;');
241
+ }
242
+
243
+ function timeAgo(dateStr) {
244
+ if (!dateStr) return 'never';
245
+ var d = new Date(dateStr);
246
+ var now = Date.now();
247
+ var diff = now - d.getTime();
248
+ if (diff < 0) return 'just now';
249
+ var seconds = Math.floor(diff / 1000);
250
+ if (seconds < 60) return seconds + 's ago';
251
+ var minutes = Math.floor(seconds / 60);
252
+ if (minutes < 60) return minutes + 'm ago';
253
+ var hours = Math.floor(minutes / 60);
254
+ if (hours < 24) return hours + 'h ago';
255
+ var days = Math.floor(hours / 24);
256
+ return days + 'd ago';
257
+ }
258
+
259
+ function formatTime(dateStr) {
260
+ if (!dateStr) return '';
261
+ var d = new Date(dateStr);
262
+ var h = d.getHours();
263
+ var m = d.getMinutes();
264
+ var s = d.getSeconds();
265
+ return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
266
+ }
267
+
268
+ /** Generate a deterministic color from a string (for avatars). */
269
+ function hashColor(str) {
270
+ var hash = 0;
271
+ for (var i = 0; i < str.length; i++) {
272
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
273
+ }
274
+ var hues = [210, 150, 340, 40, 280, 180, 20, 100, 300, 60];
275
+ var hue = hues[Math.abs(hash) % hues.length];
276
+ return 'hsl(' + hue + ', 60%, 55%)';
277
+ }
278
+
279
+ function initials(name) {
280
+ if (!name) return '?';
281
+ var parts = name.trim().split(/\s+/);
282
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
283
+ return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
284
+ }
285
+
286
+ function statusBadge(status) {
287
+ var cls = 'badge badge-' + (status || 'active');
288
+ return '<span class="' + cls + '">' + escapeHtml(status || 'active') + '</span>';
289
+ }
290
+
291
+ /** Get a usage percentage for progress bars. */
292
+ function usagePct(used, limit) {
293
+ if (limit <= 0) return 0;
294
+ var p = used / limit;
295
+ return p > 1 ? 1 : p;
296
+ }
297
+
298
+ function progressClass(pct) {
299
+ if (pct >= 0.9) return 'progress-fill-danger';
300
+ if (pct >= 0.7) return 'progress-fill-warn';
301
+ return 'progress-fill-ok';
302
+ }
303
+
304
+ function renderProgressBar(label, used, limit, large) {
305
+ var pct = limit > 0 ? usagePct(used, limit) : 0;
306
+ var cls = progressClass(pct);
307
+ var barCls = large ? 'progress-bar progress-bar-lg' : 'progress-bar';
308
+ var valStr = used + (limit > 0 ? ' / ' + limit : (limit === -1 ? ' / unlimited' : ''));
309
+ return (
310
+ '<div class="progress-row">' +
311
+ '<div class="progress-label">' +
312
+ '<span class="progress-label-name">' + escapeHtml(label) + '</span>' +
313
+ '<span class="progress-label-value">' + valStr + '</span>' +
314
+ '</div>' +
315
+ '<div class="' + barCls + '">' +
316
+ '<div class="progress-fill ' + cls + '" style="width:' + (pct * 100).toFixed(1) + '%"></div>' +
317
+ '</div>' +
318
+ '</div>'
319
+ );
320
+ }
321
+
322
+ /** Extract the per-model limit value for a given model and window from a user's limits array. */
323
+ function getModelLimit(limits, model, windowType) {
324
+ windowType = windowType || 'daily';
325
+ for (var i = 0; i < limits.length; i++) {
326
+ var r = limits[i];
327
+ if (r.type === 'per_model' && r.model === model && r.window === windowType) return r.value;
328
+ }
329
+ return -1; // unlimited
330
+ }
331
+
332
+ function getCreditRule(limits) {
333
+ for (var i = 0; i < limits.length; i++) {
334
+ if (limits[i].type === 'credits') return limits[i];
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /* ================================================================
340
+ ROUTER
341
+ ================================================================ */
342
+ function getRoute() {
343
+ var hash = location.hash.replace(/^#\/?/, '');
344
+ if (!hash) return { page: 'overview', params: {} };
345
+ var parts = hash.split('/');
346
+ var page = parts[0];
347
+ var params = {};
348
+ if (page === 'user' && parts[1]) params.id = parts[1];
349
+ return { page: page, params: params };
350
+ }
351
+
352
+ function navigate(page, params) {
353
+ var hash = '#' + page;
354
+ if (params && params.id) hash += '/' + params.id;
355
+ location.hash = hash;
356
+ }
357
+
358
+ function handleRoute() {
359
+ var route = getRoute();
360
+ state.currentRoute = route;
361
+
362
+ // Auth gate
363
+ if (!state.token && route.page !== 'login') {
364
+ navigate('login');
365
+ return;
366
+ }
367
+ if (state.token && route.page === 'login') {
368
+ navigate('overview');
369
+ return;
370
+ }
371
+
372
+ // Show/hide screens
373
+ var loginScreen = document.getElementById('login-screen');
374
+ var appShell = document.getElementById('app-shell');
375
+
376
+ if (route.page === 'login') {
377
+ loginScreen.classList.remove('hidden');
378
+ appShell.classList.add('hidden');
379
+ stopAutoRefresh();
380
+ WS.disconnect();
381
+ return;
382
+ }
383
+
384
+ loginScreen.classList.add('hidden');
385
+ appShell.classList.remove('hidden');
386
+
387
+ // Connect WS if not connected
388
+ if (!WS.isConnected()) WS.connect();
389
+
390
+ // Update sidebar active state
391
+ var links = document.querySelectorAll('.nav-link');
392
+ for (var i = 0; i < links.length; i++) {
393
+ var linkPage = links[i].getAttribute('data-page');
394
+ if (linkPage === route.page || (linkPage === 'users' && route.page === 'user')) {
395
+ links[i].classList.add('active');
396
+ } else {
397
+ links[i].classList.remove('active');
398
+ }
399
+ }
400
+
401
+ // Close mobile sidebar
402
+ closeMobileSidebar();
403
+
404
+ // Render the page
405
+ renderPage(route);
406
+ }
407
+
408
+ function renderPage(route) {
409
+ var content = document.getElementById('content');
410
+ content.innerHTML = '<div class="spinner"></div>';
411
+
412
+ switch (route.page) {
413
+ case 'overview':
414
+ case 'users':
415
+ renderOverview(content);
416
+ break;
417
+ case 'user':
418
+ renderUserDetail(content, route.params.id);
419
+ break;
420
+ case 'settings':
421
+ renderSettings(content);
422
+ break;
423
+ default:
424
+ content.innerHTML = '<div class="empty-state"><p>Page not found</p></div>';
425
+ }
426
+ }
427
+
428
+ /* ================================================================
429
+ PAGE: OVERVIEW / USERS
430
+ ================================================================ */
431
+ function renderOverview(container) {
432
+ Promise.all([API.getUsers(), API.getEvents({ limit: 30 })])
433
+ .then(function (results) {
434
+ var usersData = results[0];
435
+ var eventsData = results[1];
436
+ state.users = usersData.users;
437
+
438
+ var users = usersData.users;
439
+ var events = eventsData.events || [];
440
+
441
+ // Stats
442
+ var totalUsers = users.length;
443
+ var activeUsers = users.filter(function (u) { return u.status === 'active'; }).length;
444
+ var pausedUsers = users.filter(function (u) { return u.status === 'paused'; }).length;
445
+ var killedUsers = users.filter(function (u) { return u.status === 'killed'; }).length;
446
+ var totalCreditsUsed = 0;
447
+ var totalCreditBudget = 0;
448
+ for (var ci = 0; ci < users.length; ci++) {
449
+ if (users[ci].credit_budget && users[ci].credit_budget > 0) {
450
+ totalCreditsUsed += (users[ci].credit_budget - (users[ci].credit_balance || 0));
451
+ totalCreditBudget += users[ci].credit_budget;
452
+ }
453
+ }
454
+
455
+ var html = '';
456
+ html += '<div class="page-header">';
457
+ html += ' <h2>Overview</h2>';
458
+ html += ' <div class="page-header-actions">';
459
+ html += ' <button class="btn btn-primary" data-action="add-user">+ Add User</button>';
460
+ html += ' </div>';
461
+ html += '</div>';
462
+
463
+ // Stats row
464
+ html += '<div class="stats-row">';
465
+ html += statCard('Total Users', totalUsers, '');
466
+ html += statCard('Active', activeUsers, '', 'text-green');
467
+ html += statCard('Paused', pausedUsers, '', 'text-yellow');
468
+ html += statCard('Killed', killedUsers, '', 'text-red');
469
+ html += '</div>';
470
+
471
+ // Two column: user cards + live feed
472
+ html += '<div class="two-col">';
473
+
474
+ // User cards
475
+ html += '<div>';
476
+ html += '<div class="card mb-2"><div class="card-header"><h3>Users</h3></div>';
477
+ if (users.length === 0) {
478
+ html += '<div class="card-body"><div class="empty-state"><p>No users yet. Click "Add User" to get started.</p></div></div>';
479
+ } else {
480
+ html += '<div class="card-body"><div class="user-grid" style="margin-bottom:0">';
481
+ for (var i = 0; i < users.length; i++) {
482
+ html += renderUserCard(users[i]);
483
+ }
484
+ html += '</div></div>';
485
+ }
486
+ html += '</div></div>';
487
+
488
+ // Live feed
489
+ html += '<div>';
490
+ html += '<div class="card"><div class="card-header"><h3>Live Feed</h3></div>';
491
+ html += '<div class="card-body-flush"><div class="live-feed" id="live-feed">';
492
+ if (events.length === 0) {
493
+ html += '<div class="feed-empty">No events yet. Activity will appear here in real time.</div>';
494
+ } else {
495
+ for (var j = 0; j < events.length; j++) {
496
+ html += renderFeedItem(eventToFeed(events[j]));
497
+ }
498
+ }
499
+ html += '</div></div></div>';
500
+ html += '</div>';
501
+
502
+ html += '</div>'; // .two-col
503
+
504
+ container.innerHTML = html;
505
+ startAutoRefresh();
506
+ })
507
+ .catch(function (err) {
508
+ container.innerHTML = '<div class="empty-state"><p>Failed to load data: ' + escapeHtml(err.message) + '</p></div>';
509
+ });
510
+ }
511
+
512
+ function statCard(label, value, sub, colorClass) {
513
+ return (
514
+ '<div class="stat-card">' +
515
+ '<div class="stat-label">' + escapeHtml(label) + '</div>' +
516
+ '<div class="stat-value ' + (colorClass || '') + '">' + escapeHtml(String(value)) + '</div>' +
517
+ (sub ? '<div class="stat-sub">' + escapeHtml(sub) + '</div>' : '') +
518
+ '</div>'
519
+ );
520
+ }
521
+
522
+ function renderUserCard(user) {
523
+ var limits = user.limits || [];
524
+ var usage = user.usage || {};
525
+ var dailyUsage = (usage.daily && usage.daily.counts) ? usage.daily.counts : {};
526
+
527
+ // Primary progress: credit budget or total daily prompts
528
+ var progressHtml = '';
529
+ var creditRule = getCreditRule(limits);
530
+ if (creditRule && user.credit_budget > 0) {
531
+ var creditUsed = user.credit_budget - (user.credit_balance || 0);
532
+ progressHtml += renderProgressBar('Credits', creditUsed, user.credit_budget, false);
533
+ }
534
+
535
+ // Per-model bars
536
+ var models = ['opus', 'sonnet', 'haiku'];
537
+ for (var mi = 0; mi < models.length; mi++) {
538
+ var m = models[mi];
539
+ var mLimit = getModelLimit(limits, m);
540
+ var mUsed = dailyUsage[m] || 0;
541
+ if (mLimit > 0 || mUsed > 0) {
542
+ progressHtml += renderProgressBar(m, mUsed, mLimit > 0 ? mLimit : 0, false);
543
+ }
544
+ }
545
+ if (!progressHtml) {
546
+ progressHtml = '<div class="text-muted text-sm">No limits configured</div>';
547
+ }
548
+
549
+ var color = hashColor(user.name || user.slug);
550
+
551
+ return (
552
+ '<div class="user-card" data-action="view-user" data-user-id="' + user.id + '">' +
553
+ '<div class="user-card-head">' +
554
+ '<div class="user-card-info">' +
555
+ '<div class="user-avatar" style="background:' + color + '">' + initials(user.name) + '</div>' +
556
+ '<div>' +
557
+ '<div class="user-card-name">' + escapeHtml(user.name) + '</div>' +
558
+ '<div class="user-card-slug">@' + escapeHtml(user.slug) + '</div>' +
559
+ '</div>' +
560
+ '</div>' +
561
+ statusBadge(user.status) +
562
+ '</div>' +
563
+ '<div class="user-card-usage">' + progressHtml + '</div>' +
564
+ '<div class="user-card-actions">' +
565
+ (user.status === 'active'
566
+ ? '<button class="btn btn-sm btn-warning" data-action="pause-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Pause</button>'
567
+ + '<button class="btn btn-sm btn-danger" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill</button>'
568
+ : user.status === 'paused'
569
+ ? '<button class="btn btn-sm btn-success" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>'
570
+ + '<button class="btn btn-sm btn-danger" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill</button>'
571
+ : '<button class="btn btn-sm btn-success" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>'
572
+ ) +
573
+ '</div>' +
574
+ '</div>'
575
+ );
576
+ }
577
+
578
+ /* ================================================================
579
+ LIVE FEED HELPERS
580
+ ================================================================ */
581
+ /** Convert a usage_event DB row into a feed display object. */
582
+ function eventToFeed(evt) {
583
+ return {
584
+ type: 'counted',
585
+ user: evt.user_name || evt.userName || 'Unknown',
586
+ detail: evt.model + ' (+' + evt.credit_cost + ' credits)',
587
+ time: evt.timestamp,
588
+ };
589
+ }
590
+
591
+ /** Convert a WebSocket event into a feed display object. */
592
+ function wsEventToFeed(evt) {
593
+ switch (evt.type) {
594
+ case 'user_check':
595
+ return { type: 'check', user: evt.userName, detail: 'Checking ' + (evt.model || ''), time: evt.timestamp };
596
+ case 'user_blocked':
597
+ return { type: 'blocked', user: evt.userName, detail: 'Blocked on ' + (evt.model || '') + (evt.reason ? ': ' + evt.reason.split('\n')[0] : ''), time: evt.timestamp };
598
+ case 'user_counted':
599
+ return { type: 'counted', user: evt.userName, detail: (evt.model || '') + ' (+' + (evt.creditCost || 0) + ' credits)', time: evt.timestamp };
600
+ case 'user_status_change':
601
+ case 'user_killed':
602
+ return { type: 'status', user: evt.userName, detail: (evt.oldStatus || '?') + ' -> ' + (evt.newStatus || '?'), time: evt.timestamp };
603
+ case 'user_status':
604
+ return { type: 'check', user: evt.userName, detail: 'Session start (' + (evt.model || '') + ')', time: evt.timestamp };
605
+ default:
606
+ return null;
607
+ }
608
+ }
609
+
610
+ function renderFeedItem(feed) {
611
+ if (!feed) return '';
612
+ var dotCls = 'feed-dot feed-dot-' + (feed.type || 'system');
613
+ return (
614
+ '<div class="feed-item">' +
615
+ '<span class="' + dotCls + '"></span>' +
616
+ '<div class="feed-content">' +
617
+ '<span class="feed-user">' + escapeHtml(feed.user) + '</span> ' +
618
+ '<span class="feed-detail">' + escapeHtml(feed.detail) + '</span>' +
619
+ '</div>' +
620
+ '<span class="feed-time">' + formatTime(feed.time) + '</span>' +
621
+ '</div>'
622
+ );
623
+ }
624
+
625
+ function addLiveFeedItem(feed) {
626
+ var feedEl = document.getElementById('live-feed');
627
+ if (!feedEl) return;
628
+
629
+ // Remove empty state message if present
630
+ var empty = feedEl.querySelector('.feed-empty');
631
+ if (empty) empty.remove();
632
+
633
+ var div = document.createElement('div');
634
+ div.innerHTML = renderFeedItem(feed);
635
+ var newItem = div.firstElementChild;
636
+ if (newItem) {
637
+ feedEl.insertBefore(newItem, feedEl.firstChild);
638
+ // Keep feed to 50 items max
639
+ while (feedEl.children.length > 50) {
640
+ feedEl.removeChild(feedEl.lastChild);
641
+ }
642
+ }
643
+ }
644
+
645
+ /* ================================================================
646
+ PAGE: USER DETAIL
647
+ ================================================================ */
648
+ function renderUserDetail(container, userId) {
649
+ Promise.all([API.getUser(userId), API.getUsage({ user_id: userId, days: 30 }), API.getEvents({ user_id: userId, limit: 20 })])
650
+ .then(function (results) {
651
+ var user = results[0];
652
+ var usageData = results[1];
653
+ var eventsData = results[2];
654
+ var limits = user.limits || [];
655
+ var usage = user.usage || {};
656
+ var dailyUsage = (usage.daily && usage.daily.counts) ? usage.daily.counts : {};
657
+ var color = hashColor(user.name || user.slug);
658
+
659
+ var html = '';
660
+ // Breadcrumb
661
+ html += '<div class="breadcrumb"><a href="#overview">Overview</a> / <span>' + escapeHtml(user.name) + '</span></div>';
662
+ html += '<div class="page-header"><h2>' + escapeHtml(user.name) + '</h2>';
663
+ html += '<div class="page-header-actions">';
664
+ html += '<button class="btn btn-sm" data-action="edit-limits" data-user-id="' + user.id + '">Edit Limits</button>';
665
+ html += '<button class="btn btn-sm btn-danger" data-action="delete-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Delete User</button>';
666
+ html += '</div></div>';
667
+
668
+ html += '<div class="detail-grid">';
669
+
670
+ // Left sidebar card
671
+ html += '<div class="card"><div class="card-body detail-sidebar-card">';
672
+ html += '<div class="detail-avatar" style="background:' + color + '">' + initials(user.name) + '</div>';
673
+ html += '<div class="detail-name">' + escapeHtml(user.name) + '</div>';
674
+ html += '<div class="detail-meta">@' + escapeHtml(user.slug) + '</div>';
675
+ html += statusBadge(user.status);
676
+ html += '<div class="detail-meta">Last seen: ' + timeAgo(user.last_seen) + '</div>';
677
+ html += '<div class="detail-meta">Created: ' + (user.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A') + '</div>';
678
+ html += '<div class="detail-actions">';
679
+ if (user.status === 'active') {
680
+ html += '<button class="btn btn-sm btn-warning btn-block" data-action="pause-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Pause User</button>';
681
+ html += '<button class="btn btn-sm btn-danger btn-block" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill User</button>';
682
+ } else if (user.status === 'paused') {
683
+ html += '<button class="btn btn-sm btn-success btn-block" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>';
684
+ html += '<button class="btn btn-sm btn-danger btn-block" data-action="kill-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Kill User</button>';
685
+ } else {
686
+ html += '<button class="btn btn-sm btn-success btn-block" data-action="reinstate-user" data-user-id="' + user.id + '" data-user-name="' + escapeHtml(user.name) + '">Reinstate</button>';
687
+ }
688
+ html += '</div>';
689
+
690
+ // Credit gauge
691
+ var creditRule = getCreditRule(limits);
692
+ if (creditRule && user.credit_budget > 0) {
693
+ html += '<div class="gauge-wrap"><canvas id="credit-gauge" width="180" height="180"></canvas></div>';
694
+ }
695
+
696
+ html += '</div></div>'; // card
697
+
698
+ // Right content area
699
+ html += '<div>';
700
+
701
+ // Per-model usage bars
702
+ html += '<div class="card mb-2"><div class="card-header"><h3>Per-Model Usage (Daily)</h3></div>';
703
+ html += '<div class="card-body"><div class="chart-container"><canvas id="model-bars"></canvas></div></div>';
704
+ html += '</div>';
705
+
706
+ // Active limits
707
+ html += '<div class="card mb-2"><div class="card-header"><h3>Active Limits</h3>';
708
+ html += '<button class="btn btn-sm" data-action="edit-limits" data-user-id="' + user.id + '">Edit</button>';
709
+ html += '</div>';
710
+ html += '<div class="card-body">';
711
+ if (limits.length === 0) {
712
+ html += '<div class="text-muted text-sm">No limits configured. This user has unlimited access.</div>';
713
+ } else {
714
+ html += '<ul class="limits-list">';
715
+ for (var li = 0; li < limits.length; li++) {
716
+ html += renderLimitItem(limits[li]);
717
+ }
718
+ html += '</ul>';
719
+ }
720
+ html += '</div></div>';
721
+
722
+ // 30-day trend
723
+ html += '<div class="card mb-2"><div class="card-header"><h3>30-Day Usage Trend</h3></div>';
724
+ html += '<div class="card-body"><div class="chart-container"><canvas id="trend-chart"></canvas></div></div>';
725
+ html += '</div>';
726
+
727
+ // Recent events
728
+ html += '<div class="card"><div class="card-header"><h3>Recent Activity</h3></div>';
729
+ html += '<div class="card-body-flush"><div class="table-wrap"><table>';
730
+ html += '<thead><tr><th>Time</th><th>Model</th><th>Credits</th></tr></thead><tbody>';
731
+ var events = eventsData.events || [];
732
+ if (events.length === 0) {
733
+ html += '<tr><td colspan="3" class="text-center text-muted">No recent activity</td></tr>';
734
+ } else {
735
+ for (var ei = 0; ei < events.length; ei++) {
736
+ var ev = events[ei];
737
+ html += '<tr>';
738
+ html += '<td class="text-mono text-sm">' + timeAgo(ev.timestamp) + '</td>';
739
+ html += '<td><span class="badge badge-model">' + escapeHtml(ev.model) + '</span></td>';
740
+ html += '<td class="text-mono">' + ev.credit_cost + '</td>';
741
+ html += '</tr>';
742
+ }
743
+ }
744
+ html += '</tbody></table></div></div></div>';
745
+
746
+ html += '</div>'; // right column
747
+ html += '</div>'; // detail-grid
748
+
749
+ container.innerHTML = html;
750
+
751
+ // Render charts after DOM is ready
752
+ requestAnimationFrame(function () {
753
+ // Credit gauge
754
+ if (creditRule && user.credit_budget > 0) {
755
+ var gaugeCanvas = document.getElementById('credit-gauge');
756
+ if (gaugeCanvas) {
757
+ var creditUsed = user.credit_budget - (user.credit_balance || 0);
758
+ Charts.creditGauge(gaugeCanvas, creditUsed, user.credit_budget);
759
+ }
760
+ }
761
+
762
+ // Horizontal bars
763
+ var barsCanvas = document.getElementById('model-bars');
764
+ if (barsCanvas) {
765
+ var barData = [];
766
+ var models = ['opus', 'sonnet', 'haiku'];
767
+ for (var b = 0; b < models.length; b++) {
768
+ var mdl = models[b];
769
+ var used = dailyUsage[mdl] || 0;
770
+ var lim = getModelLimit(limits, mdl);
771
+ barData.push({ label: mdl.charAt(0).toUpperCase() + mdl.slice(1), value: used, limit: lim > 0 ? lim : 0 });
772
+ }
773
+ Charts.horizontalBar(barsCanvas, barData);
774
+ }
775
+
776
+ // Trend line
777
+ var trendCanvas = document.getElementById('trend-chart');
778
+ if (trendCanvas && usageData.daily) {
779
+ var dayMap = {};
780
+ for (var di = 0; di < usageData.daily.length; di++) {
781
+ var row = usageData.daily[di];
782
+ dayMap[row.day] = (dayMap[row.day] || 0) + row.count;
783
+ }
784
+ var trendPts = [];
785
+ var today = new Date();
786
+ for (var td = 29; td >= 0; td--) {
787
+ var d = new Date(today);
788
+ d.setDate(d.getDate() - td);
789
+ var key = d.toISOString().split('T')[0];
790
+ trendPts.push({ day: key, value: dayMap[key] || 0 });
791
+ }
792
+ Charts.trendLine(trendCanvas, trendPts);
793
+ }
794
+ });
795
+
796
+ startAutoRefresh();
797
+ })
798
+ .catch(function (err) {
799
+ container.innerHTML = '<div class="empty-state"><p>Failed to load user: ' + escapeHtml(err.message) + '</p><a href="#overview">Back to overview</a></div>';
800
+ });
801
+ }
802
+
803
+ function renderLimitItem(rule) {
804
+ var desc = '';
805
+ if (rule.type === 'credits') {
806
+ desc = 'Credit Budget (' + (rule.window || 'daily') + ')';
807
+ } else if (rule.type === 'per_model') {
808
+ desc = (rule.model || 'all models') + ' (' + (rule.window || 'daily') + ')';
809
+ } else if (rule.type === 'time_of_day') {
810
+ desc = (rule.model || 'all') + ' time restriction';
811
+ }
812
+
813
+ var valStr = '';
814
+ if (rule.type === 'time_of_day') {
815
+ valStr = (rule.schedule_start || '?') + ' - ' + (rule.schedule_end || '?') + ' ' + (rule.schedule_tz || '');
816
+ } else {
817
+ valStr = rule.value === -1 ? 'unlimited' : (rule.value === 0 ? 'blocked' : String(rule.value));
818
+ }
819
+
820
+ return (
821
+ '<li class="limit-item">' +
822
+ '<div class="limit-type">' +
823
+ '<span class="badge badge-model">' + escapeHtml(rule.type) + '</span>' +
824
+ '<span>' + escapeHtml(desc) + '</span>' +
825
+ '</div>' +
826
+ '<span class="limit-value">' + escapeHtml(valStr) + '</span>' +
827
+ '</li>'
828
+ );
829
+ }
830
+
831
+ /* ================================================================
832
+ PAGE: SETTINGS
833
+ ================================================================ */
834
+ function renderSettings(container) {
835
+ // We need team info. Fetch users to get the team context, or read from state.
836
+ // The login response stores team info in state.team.
837
+ if (!state.team) {
838
+ container.innerHTML = '<div class="spinner"></div>';
839
+ // Try getting users to refresh state
840
+ API.getUsers().then(function () {
841
+ renderSettingsInner(container);
842
+ }).catch(function (err) {
843
+ container.innerHTML = '<div class="empty-state"><p>Failed to load settings: ' + escapeHtml(err.message) + '</p></div>';
844
+ });
845
+ } else {
846
+ renderSettingsInner(container);
847
+ }
848
+ }
849
+
850
+ function renderSettingsInner(container) {
851
+ var team = state.team || { name: 'Team', credit_weights: { opus: 10, sonnet: 3, haiku: 1 } };
852
+ var cw = team.credit_weights || { opus: 10, sonnet: 3, haiku: 1 };
853
+
854
+ var html = '';
855
+ html += '<div class="page-header"><h2>Settings</h2></div>';
856
+
857
+ // Team name
858
+ html += '<div class="card mb-2"><div class="card-body">';
859
+ html += '<div class="settings-section">';
860
+ html += '<h3>Team Name</h3>';
861
+ html += '<div class="form-group">';
862
+ html += '<input type="text" id="settings-team-name" value="' + escapeHtml(team.name) + '" placeholder="Team name">';
863
+ html += '</div>';
864
+ html += '<button class="btn btn-primary btn-sm" data-action="save-team-name">Save Name</button>';
865
+ html += '</div>';
866
+ html += '</div></div>';
867
+
868
+ // Credit Weights
869
+ html += '<div class="card mb-2"><div class="card-body">';
870
+ html += '<div class="settings-section">';
871
+ html += '<h3>Credit Weights</h3>';
872
+ html += '<p class="text-muted text-sm mb-2">Define how many credits each model costs per turn. Higher weight = more expensive.</p>';
873
+ html += '<div class="weight-grid">';
874
+ var models = ['opus', 'sonnet', 'haiku'];
875
+ for (var i = 0; i < models.length; i++) {
876
+ var m = models[i];
877
+ html += '<div class="weight-card">';
878
+ html += '<div class="weight-model">' + m + '</div>';
879
+ html += '<input type="number" id="weight-' + m + '" min="0" value="' + (cw[m] || 0) + '">';
880
+ html += '</div>';
881
+ }
882
+ html += '</div>';
883
+ html += '<div class="mt-2"><button class="btn btn-primary btn-sm" data-action="save-weights">Save Weights</button></div>';
884
+ html += '</div>';
885
+ html += '</div></div>';
886
+
887
+ // Change Password
888
+ html += '<div class="card mb-2"><div class="card-body">';
889
+ html += '<div class="settings-section">';
890
+ html += '<h3>Change Admin Password</h3>';
891
+ html += '<div class="form-row">';
892
+ html += '<div class="form-group"><label for="settings-new-pw">New Password</label>';
893
+ html += '<input type="password" id="settings-new-pw" placeholder="New password"></div>';
894
+ html += '<div class="form-group"><label for="settings-confirm-pw">Confirm Password</label>';
895
+ html += '<input type="password" id="settings-confirm-pw" placeholder="Confirm password"></div>';
896
+ html += '</div>';
897
+ html += '<button class="btn btn-danger btn-sm" data-action="change-password">Change Password</button>';
898
+ html += '</div>';
899
+ html += '</div></div>';
900
+
901
+ container.innerHTML = html;
902
+ }
903
+
904
+ /* ================================================================
905
+ MODAL: ADD USER
906
+ ================================================================ */
907
+ function openAddUserModal() {
908
+ var html = '';
909
+ html += '<div class="modal-header"><h3>Add User</h3><button class="modal-close" data-action="close-modal">&times;</button></div>';
910
+ html += '<div class="modal-body">';
911
+
912
+ html += '<div class="form-group"><label for="new-user-name">Name</label>';
913
+ html += '<input type="text" id="new-user-name" placeholder="e.g. Alice, Dev Team, Intern"></div>';
914
+
915
+ html += '<div class="form-group"><label for="new-user-slug">Slug (username)</label>';
916
+ html += '<input type="text" id="new-user-slug" placeholder="e.g. alice, dev-team, intern">';
917
+ html += '<p class="form-hint">Lowercase, no spaces. Used in install commands and logs.</p></div>';
918
+
919
+ html += '<hr class="section-divider">';
920
+ html += '<h4 class="mb-1">Limits Preset</h4>';
921
+ html += '<div class="presets-row">';
922
+ html += '<button class="preset-btn selected" data-preset="light">Light</button>';
923
+ html += '<button class="preset-btn" data-preset="medium">Medium</button>';
924
+ html += '<button class="preset-btn" data-preset="heavy">Heavy</button>';
925
+ html += '<button class="preset-btn" data-preset="unlimited">Unlimited</button>';
926
+ html += '<button class="preset-btn" data-preset="custom">Custom</button>';
927
+ html += '</div>';
928
+
929
+ html += '<div id="preset-description" class="text-muted text-sm mb-2">50 credits/day. Opus: 3, Sonnet: 10, Haiku: 30.</div>';
930
+
931
+ // Custom limits form (hidden by default)
932
+ html += '<div id="custom-limits-form" class="hidden">';
933
+ html += '<div class="form-group"><label>Credit Budget (daily)</label>';
934
+ html += '<input type="number" id="custom-credits" min="-1" value="100" placeholder="-1 for unlimited"></div>';
935
+ html += '<div class="form-row-3">';
936
+ html += '<div class="form-group"><label>Opus (daily)</label>';
937
+ html += '<input type="number" id="custom-opus" min="-1" value="5"></div>';
938
+ html += '<div class="form-group"><label>Sonnet (daily)</label>';
939
+ html += '<input type="number" id="custom-sonnet" min="-1" value="20"></div>';
940
+ html += '<div class="form-group"><label>Haiku (daily)</label>';
941
+ html += '<input type="number" id="custom-haiku" min="-1" value="50"></div>';
942
+ html += '</div>';
943
+ html += '</div>';
944
+
945
+ html += '</div>'; // modal-body
946
+ html += '<div class="modal-footer">';
947
+ html += '<button class="btn btn-ghost" data-action="close-modal">Cancel</button>';
948
+ html += '<button class="btn btn-primary" data-action="submit-add-user">Create User</button>';
949
+ html += '</div>';
950
+
951
+ openModal(html);
952
+
953
+ // Auto-generate slug from name
954
+ var nameInput = document.getElementById('new-user-name');
955
+ var slugInput = document.getElementById('new-user-slug');
956
+ if (nameInput && slugInput) {
957
+ nameInput.addEventListener('input', function () {
958
+ slugInput.value = nameInput.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
959
+ });
960
+ }
961
+ }
962
+
963
+ var PRESETS = {
964
+ light: { credits: 50, opus: 3, sonnet: 10, haiku: 30, desc: '50 credits/day. Opus: 3, Sonnet: 10, Haiku: 30.' },
965
+ medium: { credits: 100, opus: 5, sonnet: 20, haiku: 50, desc: '100 credits/day. Opus: 5, Sonnet: 20, Haiku: 50.' },
966
+ heavy: { credits: 200, opus: 10, sonnet: 40, haiku: 100, desc: '200 credits/day. Opus: 10, Sonnet: 40, Haiku: 100.' },
967
+ unlimited: { credits: -1, opus: -1, sonnet: -1, haiku: -1, desc: 'No limits. User has full unrestricted access.' },
968
+ };
969
+
970
+ var selectedPreset = 'light';
971
+
972
+ function selectPreset(preset) {
973
+ selectedPreset = preset;
974
+
975
+ // Update button states
976
+ var btns = document.querySelectorAll('.preset-btn');
977
+ for (var i = 0; i < btns.length; i++) {
978
+ btns[i].classList.toggle('selected', btns[i].getAttribute('data-preset') === preset);
979
+ }
980
+
981
+ var descEl = document.getElementById('preset-description');
982
+ var customForm = document.getElementById('custom-limits-form');
983
+
984
+ if (preset === 'custom') {
985
+ if (descEl) descEl.textContent = 'Set your own limits below.';
986
+ if (customForm) customForm.classList.remove('hidden');
987
+ } else {
988
+ var p = PRESETS[preset];
989
+ if (descEl) descEl.textContent = p.desc;
990
+ if (customForm) customForm.classList.add('hidden');
991
+ }
992
+ }
993
+
994
+ function buildLimitsFromPreset() {
995
+ var p;
996
+ if (selectedPreset === 'custom') {
997
+ p = {
998
+ credits: parseInt(document.getElementById('custom-credits').value, 10),
999
+ opus: parseInt(document.getElementById('custom-opus').value, 10),
1000
+ sonnet: parseInt(document.getElementById('custom-sonnet').value, 10),
1001
+ haiku: parseInt(document.getElementById('custom-haiku').value, 10),
1002
+ };
1003
+ } else {
1004
+ p = PRESETS[selectedPreset];
1005
+ }
1006
+
1007
+ var limits = [];
1008
+ if (p.credits !== -1) {
1009
+ limits.push({ type: 'credits', window: 'daily', value: p.credits });
1010
+ }
1011
+ if (p.opus !== -1) {
1012
+ limits.push({ type: 'per_model', model: 'opus', window: 'daily', value: p.opus });
1013
+ }
1014
+ if (p.sonnet !== -1) {
1015
+ limits.push({ type: 'per_model', model: 'sonnet', window: 'daily', value: p.sonnet });
1016
+ }
1017
+ if (p.haiku !== -1) {
1018
+ limits.push({ type: 'per_model', model: 'haiku', window: 'daily', value: p.haiku });
1019
+ }
1020
+ return limits;
1021
+ }
1022
+
1023
+ function submitAddUser() {
1024
+ var name = (document.getElementById('new-user-name').value || '').trim();
1025
+ var slug = (document.getElementById('new-user-slug').value || '').trim();
1026
+
1027
+ if (!name) { showToast('Error', 'Name is required', 'error'); return; }
1028
+ if (!slug) { showToast('Error', 'Slug is required', 'error'); return; }
1029
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { showToast('Error', 'Slug must be lowercase letters, numbers, and hyphens', 'error'); return; }
1030
+
1031
+ var limits = buildLimitsFromPreset();
1032
+
1033
+ API.createUser({ name: name, slug: slug, limits: limits })
1034
+ .then(function (result) {
1035
+ closeModal();
1036
+ showToast('User Created', name + ' has been added', 'success');
1037
+
1038
+ // Show install command modal
1039
+ var installCode = result.install_code;
1040
+ var serverUrl = location.protocol + '//' + location.host;
1041
+ var installCmd = 'sudo npx claude-code-limiter setup --code ' + installCode + ' --server ' + serverUrl;
1042
+
1043
+ var installHtml = '';
1044
+ installHtml += '<div class="modal-header"><h3>Install Command</h3><button class="modal-close" data-action="close-modal">&times;</button></div>';
1045
+ installHtml += '<div class="modal-body">';
1046
+ installHtml += '<p class="mb-2">Run this command on <strong>' + escapeHtml(name) + '</strong>\'s machine to set up rate limiting:</p>';
1047
+ installHtml += '<div class="install-box">';
1048
+ installHtml += '<code>' + escapeHtml(installCmd) + '</code>';
1049
+ installHtml += '<button class="btn btn-sm btn-primary" data-action="copy-install" data-command="' + escapeHtml(installCmd) + '">Copy</button>';
1050
+ installHtml += '</div>';
1051
+ installHtml += '<p class="form-hint mt-2">This code can only be used once. Generate a new one from the user detail page if needed.</p>';
1052
+ installHtml += '</div>';
1053
+ installHtml += '<div class="modal-footer"><button class="btn btn-primary" data-action="close-modal">Done</button></div>';
1054
+
1055
+ openModal(installHtml);
1056
+
1057
+ // Refresh overview
1058
+ var route = getRoute();
1059
+ if (route.page === 'overview' || route.page === 'users') {
1060
+ renderPage(route);
1061
+ }
1062
+ })
1063
+ .catch(function (err) {
1064
+ showToast('Error', err.message, 'error');
1065
+ });
1066
+ }
1067
+
1068
+ /* ================================================================
1069
+ MODAL: EDIT LIMITS
1070
+ ================================================================ */
1071
+ function openEditLimitsModal(userId) {
1072
+ API.getUser(userId).then(function (user) {
1073
+ var limits = user.limits || [];
1074
+ var creditRule = getCreditRule(limits);
1075
+ var creditVal = creditRule ? creditRule.value : '';
1076
+ var creditWindow = creditRule ? creditRule.window : 'daily';
1077
+
1078
+ var html = '';
1079
+ html += '<div class="modal-header"><h3>Edit Limits: ' + escapeHtml(user.name) + '</h3><button class="modal-close" data-action="close-modal">&times;</button></div>';
1080
+ html += '<div class="modal-body">';
1081
+
1082
+ // Credit budget
1083
+ html += '<div class="form-group"><label>Credit Budget</label>';
1084
+ html += '<div class="form-row">';
1085
+ html += '<div class="form-group"><label class="text-sm">Value (-1 = unlimited, 0 = blocked)</label>';
1086
+ html += '<input type="number" id="edit-credits" min="-1" value="' + (creditVal !== '' ? creditVal : '-1') + '"></div>';
1087
+ html += '<div class="form-group"><label class="text-sm">Window</label>';
1088
+ html += '<select id="edit-credits-window">';
1089
+ var windows = ['daily', 'weekly', 'monthly', 'sliding_24h'];
1090
+ for (var wi = 0; wi < windows.length; wi++) {
1091
+ html += '<option value="' + windows[wi] + '"' + (creditWindow === windows[wi] ? ' selected' : '') + '>' + windows[wi] + '</option>';
1092
+ }
1093
+ html += '</select></div>';
1094
+ html += '</div></div>';
1095
+
1096
+ html += '<hr class="section-divider">';
1097
+
1098
+ // Per-model limits
1099
+ html += '<h4 class="mb-1">Per-Model Limits (Daily)</h4>';
1100
+ html += '<p class="form-hint mb-2">-1 = unlimited, 0 = blocked</p>';
1101
+ var models = ['opus', 'sonnet', 'haiku'];
1102
+ html += '<div class="form-row-3">';
1103
+ for (var mi = 0; mi < models.length; mi++) {
1104
+ var m = models[mi];
1105
+ var mVal = getModelLimit(limits, m, 'daily');
1106
+ html += '<div class="form-group"><label>' + m.charAt(0).toUpperCase() + m.slice(1) + '</label>';
1107
+ html += '<input type="number" id="edit-' + m + '" min="-1" value="' + mVal + '"></div>';
1108
+ }
1109
+ html += '</div>';
1110
+
1111
+ // Time-of-day rules
1112
+ html += '<hr class="section-divider">';
1113
+ html += '<h4 class="mb-1">Time-of-Day Restrictions</h4>';
1114
+ var timeRules = limits.filter(function (r) { return r.type === 'time_of_day'; });
1115
+ html += '<div id="time-rules-list">';
1116
+ if (timeRules.length > 0) {
1117
+ for (var ti = 0; ti < timeRules.length; ti++) {
1118
+ html += renderTimeRuleRow(ti, timeRules[ti]);
1119
+ }
1120
+ }
1121
+ html += '</div>';
1122
+ html += '<button class="btn btn-sm mt-1" data-action="add-time-rule">+ Add Time Rule</button>';
1123
+
1124
+ html += '</div>'; // modal-body
1125
+ html += '<div class="modal-footer">';
1126
+ html += '<button class="btn btn-ghost" data-action="close-modal">Cancel</button>';
1127
+ html += '<button class="btn btn-primary" data-action="submit-edit-limits" data-user-id="' + userId + '">Save Limits</button>';
1128
+ html += '</div>';
1129
+
1130
+ openModal(html);
1131
+ }).catch(function (err) {
1132
+ showToast('Error', err.message, 'error');
1133
+ });
1134
+ }
1135
+
1136
+ var timeRuleIndex = 0;
1137
+
1138
+ function renderTimeRuleRow(idx, rule) {
1139
+ rule = rule || {};
1140
+ return (
1141
+ '<div class="form-row-3 mb-1" id="time-rule-' + idx + '">' +
1142
+ '<div class="form-group"><label class="text-sm">Model</label>' +
1143
+ '<select class="time-rule-model">' +
1144
+ '<option value="opus"' + (rule.model === 'opus' ? ' selected' : '') + '>Opus</option>' +
1145
+ '<option value="sonnet"' + (rule.model === 'sonnet' ? ' selected' : '') + '>Sonnet</option>' +
1146
+ '<option value="haiku"' + (rule.model === 'haiku' ? ' selected' : '') + '>Haiku</option>' +
1147
+ '</select></div>' +
1148
+ '<div class="form-group"><label class="text-sm">Start - End</label>' +
1149
+ '<div style="display:flex;gap:0.3rem;align-items:center">' +
1150
+ '<input type="time" class="time-rule-start" value="' + (rule.schedule_start || '09:00') + '" style="flex:1">' +
1151
+ '<span class="text-muted">-</span>' +
1152
+ '<input type="time" class="time-rule-end" value="' + (rule.schedule_end || '18:00') + '" style="flex:1">' +
1153
+ '</div></div>' +
1154
+ '<div class="form-group"><label class="text-sm">Timezone</label>' +
1155
+ '<input type="text" class="time-rule-tz" value="' + escapeHtml(rule.schedule_tz || Intl.DateTimeFormat().resolvedOptions().timeZone) + '" placeholder="America/New_York"></div>' +
1156
+ '</div>'
1157
+ );
1158
+ }
1159
+
1160
+ function addTimeRuleRow() {
1161
+ var list = document.getElementById('time-rules-list');
1162
+ if (!list) return;
1163
+ timeRuleIndex++;
1164
+ var div = document.createElement('div');
1165
+ div.innerHTML = renderTimeRuleRow(timeRuleIndex, {});
1166
+ list.appendChild(div.firstElementChild);
1167
+ }
1168
+
1169
+ function submitEditLimits(userId) {
1170
+ var limits = [];
1171
+
1172
+ // Credits
1173
+ var creditsVal = parseInt(document.getElementById('edit-credits').value, 10);
1174
+ var creditsWindow = document.getElementById('edit-credits-window').value;
1175
+ if (!isNaN(creditsVal) && creditsVal !== -1) {
1176
+ limits.push({ type: 'credits', window: creditsWindow, value: creditsVal });
1177
+ }
1178
+
1179
+ // Per-model
1180
+ var models = ['opus', 'sonnet', 'haiku'];
1181
+ for (var i = 0; i < models.length; i++) {
1182
+ var val = parseInt(document.getElementById('edit-' + models[i]).value, 10);
1183
+ if (!isNaN(val) && val !== -1) {
1184
+ limits.push({ type: 'per_model', model: models[i], window: 'daily', value: val });
1185
+ }
1186
+ }
1187
+
1188
+ // Time-of-day rules
1189
+ var modelEls = document.querySelectorAll('.time-rule-model');
1190
+ var startEls = document.querySelectorAll('.time-rule-start');
1191
+ var endEls = document.querySelectorAll('.time-rule-end');
1192
+ var tzEls = document.querySelectorAll('.time-rule-tz');
1193
+ for (var t = 0; t < modelEls.length; t++) {
1194
+ limits.push({
1195
+ type: 'time_of_day',
1196
+ model: modelEls[t].value,
1197
+ schedule_start: startEls[t].value,
1198
+ schedule_end: endEls[t].value,
1199
+ schedule_tz: tzEls[t].value,
1200
+ });
1201
+ }
1202
+
1203
+ API.updateUser(userId, { limits: limits })
1204
+ .then(function () {
1205
+ closeModal();
1206
+ showToast('Limits Updated', 'Limits have been saved', 'success');
1207
+ renderPage(getRoute());
1208
+ })
1209
+ .catch(function (err) {
1210
+ showToast('Error', err.message, 'error');
1211
+ });
1212
+ }
1213
+
1214
+ /* ================================================================
1215
+ USER ACTIONS
1216
+ ================================================================ */
1217
+ function pauseUser(userId, userName) {
1218
+ confirmDialog('Pause User', 'Pause ' + userName + '? They will not be able to use Claude Code until reinstated.', 'Pause')
1219
+ .then(function (ok) {
1220
+ if (!ok) return;
1221
+ return API.updateUser(userId, { status: 'paused' }).then(function () {
1222
+ showToast('User Paused', userName + ' has been paused', 'warning');
1223
+ renderPage(getRoute());
1224
+ });
1225
+ })
1226
+ .catch(function (err) { if (err) showToast('Error', err.message, 'error'); });
1227
+ }
1228
+
1229
+ function killUser(userId, userName) {
1230
+ confirmDialog('Kill User', 'Kill ' + userName + '? This will revoke their Claude Code access and log them out. They will need manual re-setup to restore access.', 'Kill')
1231
+ .then(function (ok) {
1232
+ if (!ok) return;
1233
+ return API.updateUser(userId, { status: 'killed' }).then(function () {
1234
+ showToast('User Killed', userName + ' has been killed. Their session will be revoked.', 'error');
1235
+ renderPage(getRoute());
1236
+ });
1237
+ })
1238
+ .catch(function (err) { if (err) showToast('Error', err.message, 'error'); });
1239
+ }
1240
+
1241
+ function reinstateUser(userId, userName) {
1242
+ API.updateUser(userId, { status: 'active' })
1243
+ .then(function () {
1244
+ showToast('User Reinstated', userName + ' is now active', 'success');
1245
+ renderPage(getRoute());
1246
+ })
1247
+ .catch(function (err) { showToast('Error', err.message, 'error'); });
1248
+ }
1249
+
1250
+ function deleteUser(userId, userName) {
1251
+ confirmDialog('Delete User', 'Permanently delete ' + userName + '? This removes all their usage data and cannot be undone.', 'Delete')
1252
+ .then(function (ok) {
1253
+ if (!ok) return;
1254
+ return API.deleteUser(userId).then(function () {
1255
+ showToast('User Deleted', userName + ' has been removed', 'success');
1256
+ navigate('overview');
1257
+ });
1258
+ })
1259
+ .catch(function (err) { if (err) showToast('Error', err.message, 'error'); });
1260
+ }
1261
+
1262
+ /* ================================================================
1263
+ SETTINGS ACTIONS
1264
+ ================================================================ */
1265
+ function saveTeamName() {
1266
+ var name = (document.getElementById('settings-team-name').value || '').trim();
1267
+ if (!name) { showToast('Error', 'Team name cannot be empty', 'error'); return; }
1268
+ API.updateSettings({ name: name })
1269
+ .then(function (data) {
1270
+ state.team = data.team;
1271
+ updateTeamNameDisplay();
1272
+ showToast('Saved', 'Team name updated', 'success');
1273
+ })
1274
+ .catch(function (err) { showToast('Error', err.message, 'error'); });
1275
+ }
1276
+
1277
+ function saveWeights() {
1278
+ var cw = {};
1279
+ var models = ['opus', 'sonnet', 'haiku'];
1280
+ for (var i = 0; i < models.length; i++) {
1281
+ var el = document.getElementById('weight-' + models[i]);
1282
+ cw[models[i]] = parseInt(el.value, 10) || 0;
1283
+ }
1284
+ API.updateSettings({ credit_weights: cw })
1285
+ .then(function (data) {
1286
+ state.team = data.team;
1287
+ showToast('Saved', 'Credit weights updated', 'success');
1288
+ })
1289
+ .catch(function (err) { showToast('Error', err.message, 'error'); });
1290
+ }
1291
+
1292
+ function changePassword() {
1293
+ var newPw = (document.getElementById('settings-new-pw').value || '');
1294
+ var confirmPw = (document.getElementById('settings-confirm-pw').value || '');
1295
+ if (!newPw) { showToast('Error', 'Password cannot be empty', 'error'); return; }
1296
+ if (newPw !== confirmPw) { showToast('Error', 'Passwords do not match', 'error'); return; }
1297
+ if (newPw.length < 6) { showToast('Error', 'Password must be at least 6 characters', 'error'); return; }
1298
+
1299
+ API.updateSettings({ admin_password: newPw })
1300
+ .then(function () {
1301
+ showToast('Saved', 'Admin password changed. You will need to log in again.', 'success');
1302
+ document.getElementById('settings-new-pw').value = '';
1303
+ document.getElementById('settings-confirm-pw').value = '';
1304
+ })
1305
+ .catch(function (err) { showToast('Error', err.message, 'error'); });
1306
+ }
1307
+
1308
+ /* ================================================================
1309
+ AUTO-REFRESH
1310
+ ================================================================ */
1311
+ function startAutoRefresh() {
1312
+ stopAutoRefresh();
1313
+ state.refreshTimer = setInterval(function () {
1314
+ var route = getRoute();
1315
+ if (route.page === 'overview' || route.page === 'users') {
1316
+ silentRefreshOverview();
1317
+ }
1318
+ }, 30000);
1319
+ }
1320
+
1321
+ function stopAutoRefresh() {
1322
+ if (state.refreshTimer) {
1323
+ clearInterval(state.refreshTimer);
1324
+ state.refreshTimer = null;
1325
+ }
1326
+ }
1327
+
1328
+ /** Refresh overview data without full re-render (avoids flicker). */
1329
+ function silentRefreshOverview() {
1330
+ API.getUsers().then(function (data) {
1331
+ state.users = data.users;
1332
+ // Only re-render user cards if we are on overview
1333
+ var route = getRoute();
1334
+ if (route.page === 'overview' || route.page === 'users') {
1335
+ var grid = document.querySelector('.user-grid');
1336
+ if (grid) {
1337
+ var html = '';
1338
+ for (var i = 0; i < data.users.length; i++) {
1339
+ html += renderUserCard(data.users[i]);
1340
+ }
1341
+ grid.innerHTML = html;
1342
+ }
1343
+ // Update stats
1344
+ updateOverviewStats(data.users);
1345
+ }
1346
+ }).catch(function () {
1347
+ // Silently ignore refresh errors
1348
+ });
1349
+ }
1350
+
1351
+ function updateOverviewStats(users) {
1352
+ var statCards = document.querySelectorAll('.stat-card');
1353
+ if (statCards.length < 4) return;
1354
+ var totalUsers = users.length;
1355
+ var activeUsers = users.filter(function (u) { return u.status === 'active'; }).length;
1356
+ var pausedUsers = users.filter(function (u) { return u.status === 'paused'; }).length;
1357
+ var killedUsers = users.filter(function (u) { return u.status === 'killed'; }).length;
1358
+
1359
+ var vals = statCards[0].querySelector('.stat-value');
1360
+ if (vals) vals.textContent = totalUsers;
1361
+ vals = statCards[1].querySelector('.stat-value');
1362
+ if (vals) vals.textContent = activeUsers;
1363
+ vals = statCards[2].querySelector('.stat-value');
1364
+ if (vals) vals.textContent = pausedUsers;
1365
+ vals = statCards[3].querySelector('.stat-value');
1366
+ if (vals) vals.textContent = killedUsers;
1367
+ }
1368
+
1369
+ function updateTeamNameDisplay() {
1370
+ var el = document.getElementById('sidebar-team-name');
1371
+ if (el && state.team) el.textContent = state.team.name;
1372
+ }
1373
+
1374
+ /* ================================================================
1375
+ LOGIN
1376
+ ================================================================ */
1377
+ function handleLogin(e) {
1378
+ e.preventDefault();
1379
+ var pw = document.getElementById('login-password').value;
1380
+ var errEl = document.getElementById('login-error');
1381
+ var btn = document.getElementById('login-btn');
1382
+
1383
+ if (!pw) { errEl.textContent = 'Password is required'; errEl.classList.remove('hidden'); return; }
1384
+
1385
+ btn.disabled = true;
1386
+ btn.textContent = 'Signing in...';
1387
+ errEl.classList.add('hidden');
1388
+
1389
+ API.login(pw)
1390
+ .then(function (data) {
1391
+ state.token = data.token;
1392
+ state.team = data.team;
1393
+ localStorage.setItem('clm_token', data.token);
1394
+ updateTeamNameDisplay();
1395
+ navigate('overview');
1396
+ })
1397
+ .catch(function (err) {
1398
+ errEl.textContent = err.message || 'Login failed';
1399
+ errEl.classList.remove('hidden');
1400
+ })
1401
+ .finally(function () {
1402
+ btn.disabled = false;
1403
+ btn.textContent = 'Sign In';
1404
+ });
1405
+ }
1406
+
1407
+ function handleLogout() {
1408
+ state.token = null;
1409
+ state.team = null;
1410
+ localStorage.removeItem('clm_token');
1411
+ stopAutoRefresh();
1412
+ WS.disconnect();
1413
+ navigate('login');
1414
+ }
1415
+
1416
+ /* ================================================================
1417
+ MOBILE SIDEBAR
1418
+ ================================================================ */
1419
+ function openMobileSidebar() {
1420
+ document.getElementById('sidebar').classList.add('open');
1421
+ document.getElementById('sidebar-overlay').classList.remove('hidden');
1422
+ }
1423
+
1424
+ function closeMobileSidebar() {
1425
+ document.getElementById('sidebar').classList.remove('open');
1426
+ document.getElementById('sidebar-overlay').classList.add('hidden');
1427
+ }
1428
+
1429
+ /* ================================================================
1430
+ EVENT DELEGATION
1431
+ ================================================================ */
1432
+ function setupEventDelegation() {
1433
+ document.addEventListener('click', function (e) {
1434
+ var target = e.target.closest('[data-action]');
1435
+ if (!target) return;
1436
+
1437
+ var action = target.getAttribute('data-action');
1438
+ var userId = target.getAttribute('data-user-id');
1439
+ var userName = target.getAttribute('data-user-name');
1440
+
1441
+ switch (action) {
1442
+ case 'view-user':
1443
+ e.preventDefault();
1444
+ if (userId) navigate('user', { id: userId });
1445
+ break;
1446
+
1447
+ case 'add-user':
1448
+ openAddUserModal();
1449
+ break;
1450
+
1451
+ case 'submit-add-user':
1452
+ submitAddUser();
1453
+ break;
1454
+
1455
+ case 'pause-user':
1456
+ e.stopPropagation();
1457
+ if (userId) pauseUser(userId, userName);
1458
+ break;
1459
+
1460
+ case 'kill-user':
1461
+ e.stopPropagation();
1462
+ if (userId) killUser(userId, userName);
1463
+ break;
1464
+
1465
+ case 'reinstate-user':
1466
+ e.stopPropagation();
1467
+ if (userId) reinstateUser(userId, userName);
1468
+ break;
1469
+
1470
+ case 'delete-user':
1471
+ if (userId) deleteUser(userId, userName);
1472
+ break;
1473
+
1474
+ case 'edit-limits':
1475
+ if (userId) openEditLimitsModal(userId);
1476
+ break;
1477
+
1478
+ case 'submit-edit-limits':
1479
+ submitEditLimits(userId);
1480
+ break;
1481
+
1482
+ case 'add-time-rule':
1483
+ addTimeRuleRow();
1484
+ break;
1485
+
1486
+ case 'close-modal':
1487
+ closeModal();
1488
+ break;
1489
+
1490
+ case 'copy-install':
1491
+ var cmd = target.getAttribute('data-command');
1492
+ if (cmd) copyToClipboard(cmd);
1493
+ break;
1494
+
1495
+ case 'save-team-name':
1496
+ saveTeamName();
1497
+ break;
1498
+
1499
+ case 'save-weights':
1500
+ saveWeights();
1501
+ break;
1502
+
1503
+ case 'change-password':
1504
+ changePassword();
1505
+ break;
1506
+ }
1507
+ });
1508
+
1509
+ // Preset buttons (inside modal)
1510
+ document.addEventListener('click', function (e) {
1511
+ var presetBtn = e.target.closest('.preset-btn');
1512
+ if (!presetBtn) return;
1513
+ var preset = presetBtn.getAttribute('data-preset');
1514
+ if (preset) selectPreset(preset);
1515
+ });
1516
+
1517
+ // Modal overlay click-outside to close
1518
+ document.getElementById('modal-overlay').addEventListener('click', function (e) {
1519
+ if (e.target === this) closeModal();
1520
+ });
1521
+
1522
+ document.getElementById('confirm-overlay').addEventListener('click', function (e) {
1523
+ if (e.target === this) {
1524
+ document.getElementById('confirm-cancel').click();
1525
+ }
1526
+ });
1527
+
1528
+ // Login form
1529
+ document.getElementById('login-form').addEventListener('submit', handleLogin);
1530
+
1531
+ // Logout
1532
+ document.getElementById('logout-btn').addEventListener('click', handleLogout);
1533
+
1534
+ // Hamburger
1535
+ document.getElementById('hamburger-btn').addEventListener('click', function () {
1536
+ var sidebar = document.getElementById('sidebar');
1537
+ if (sidebar.classList.contains('open')) {
1538
+ closeMobileSidebar();
1539
+ } else {
1540
+ openMobileSidebar();
1541
+ }
1542
+ });
1543
+
1544
+ // Sidebar overlay
1545
+ document.getElementById('sidebar-overlay').addEventListener('click', closeMobileSidebar);
1546
+
1547
+ // Hash change
1548
+ window.addEventListener('hashchange', handleRoute);
1549
+
1550
+ // Keyboard: Escape closes modals
1551
+ document.addEventListener('keydown', function (e) {
1552
+ if (e.key === 'Escape') {
1553
+ var modalOverlay = document.getElementById('modal-overlay');
1554
+ if (!modalOverlay.classList.contains('hidden')) {
1555
+ closeModal();
1556
+ return;
1557
+ }
1558
+ var confirmOverlay = document.getElementById('confirm-overlay');
1559
+ if (!confirmOverlay.classList.contains('hidden')) {
1560
+ document.getElementById('confirm-cancel').click();
1561
+ }
1562
+ }
1563
+ });
1564
+ }
1565
+
1566
+ /* ================================================================
1567
+ WEBSOCKET EVENT HANDLING
1568
+ ================================================================ */
1569
+ function setupWSListeners() {
1570
+ WS.onEvent(function (event) {
1571
+ if (!event || !event.type) return;
1572
+
1573
+ // Handle live feed events
1574
+ var feed = wsEventToFeed(event);
1575
+ if (feed) {
1576
+ addLiveFeedItem(feed);
1577
+ }
1578
+
1579
+ // Show toast for blocked events
1580
+ if (event.type === 'user_blocked') {
1581
+ showToast(
1582
+ 'User Blocked',
1583
+ (event.userName || 'Unknown') + ' was blocked on ' + (event.model || 'unknown'),
1584
+ 'warning',
1585
+ 8000
1586
+ );
1587
+ }
1588
+
1589
+ // Show toast for kill events
1590
+ if (event.type === 'user_killed' || (event.type === 'user_status_change' && event.newStatus === 'killed')) {
1591
+ showToast(
1592
+ 'User Killed',
1593
+ (event.userName || 'Unknown') + ' has been killed',
1594
+ 'error',
1595
+ 8000
1596
+ );
1597
+ }
1598
+
1599
+ // Refresh user cards on status changes
1600
+ if (event.type === 'user_status_change' || event.type === 'user_killed' || event.type === 'user_counted') {
1601
+ silentRefreshOverview();
1602
+ }
1603
+ });
1604
+ }
1605
+
1606
+ /* ================================================================
1607
+ INIT
1608
+ ================================================================ */
1609
+ function init() {
1610
+ setupEventDelegation();
1611
+ setupWSListeners();
1612
+
1613
+ // If we have a token, try to load team info
1614
+ if (state.token) {
1615
+ API.getUsers().then(function (data) {
1616
+ state.users = data.users;
1617
+ // Try to get team info from login endpoint's stored state
1618
+ // We do not have a dedicated /team endpoint, so we store it on login
1619
+ var storedTeam = localStorage.getItem('clm_team');
1620
+ if (storedTeam) {
1621
+ try { state.team = JSON.parse(storedTeam); } catch (e) { /* ignore */ }
1622
+ }
1623
+ updateTeamNameDisplay();
1624
+ }).catch(function () {
1625
+ // Token might be expired
1626
+ });
1627
+ }
1628
+
1629
+ // Route
1630
+ handleRoute();
1631
+ }
1632
+
1633
+ // Patch login to also store team info
1634
+ var originalLogin = API.login;
1635
+ API.login = function (password) {
1636
+ return originalLogin(password).then(function (data) {
1637
+ if (data.team) {
1638
+ localStorage.setItem('clm_team', JSON.stringify(data.team));
1639
+ }
1640
+ return data;
1641
+ });
1642
+ };
1643
+
1644
+ // Start when DOM is ready
1645
+ if (document.readyState === 'loading') {
1646
+ document.addEventListener('DOMContentLoaded', init);
1647
+ } else {
1648
+ init();
1649
+ }
1650
+ })();