icebear icebear0828 Claude Opus 4.6 commited on
Commit
1e8f135
Β·
unverified Β·
1 Parent(s): 86fc406

feat: dashboard multi-account management UI with OAuth login (#3)

Browse files

Add Accounts card to dashboard with:
- Account list showing email, status, plan, usage stats, added time
- OAuth-based "Add Account" via Codex CLI (GET /auth/accounts/login)
- Delete account button per entry
- Pool summary in header (e.g. "2 active / 3 total")

Backend: add GET /auth/accounts/login endpoint that triggers
OAuth flow and auto-adds the account to the pool on completion.

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

.claude/settings.local.json CHANGED
@@ -35,7 +35,22 @@
35
  "WebFetch(domain:github.com)",
36
  "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d[''accounts''][0][''accountId'']\\)\" curl -s -o /dev/null -w \"%{http_code}\" -H \"Authorization: Bearer $TOKEN\" -H \"ChatGPT-Account-Id: $ACCT_ID\" -H \"originator: Codex Desktop\" -H \"User-Agent: Codex Desktop/260202.0859 \\(darwin; arm64\\)\" -H \"Accept: application/json\" \"https://chatgpt.com/backend-api/codex/usage\")",
37
  "Bash(git init:*)",
38
- "Bash(git add:*)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  ]
40
  }
41
  }
 
35
  "WebFetch(domain:github.com)",
36
  "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d[''accounts''][0][''accountId'']\\)\" curl -s -o /dev/null -w \"%{http_code}\" -H \"Authorization: Bearer $TOKEN\" -H \"ChatGPT-Account-Id: $ACCT_ID\" -H \"originator: Codex Desktop\" -H \"User-Agent: Codex Desktop/260202.0859 \\(darwin; arm64\\)\" -H \"Accept: application/json\" \"https://chatgpt.com/backend-api/codex/usage\")",
37
  "Bash(git init:*)",
38
+ "Bash(git add:*)",
39
+ "Bash(git commit:*)",
40
+ "Bash(gh repo create:*)",
41
+ "Bash(git push:*)",
42
+ "Bash(git checkout:*)",
43
+ "Bash(python3:*)",
44
+ "Bash(gh pr create:*)",
45
+ "Bash(gh pr merge:*)",
46
+ "Bash(git pull:*)",
47
+ "Bash(git branch:*)",
48
+ "Bash(wc:*)",
49
+ "Bash(git status:*)",
50
+ "Bash(rsync:*)",
51
+ "Bash(tar:*)",
52
+ "Bash(/usr/bin/scp:*)",
53
+ "WebFetch(domain:auth.openai.com)"
54
  ]
55
  }
56
  }
public/dashboard.html CHANGED
@@ -131,7 +131,124 @@
131
  font-weight: 500;
132
  }
133
  .status-ok { background: #238636; color: #fff; }
 
 
 
 
134
  .loading { color: #8b949e; font-style: italic; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </style>
136
  </head>
137
  <body>
@@ -144,6 +261,17 @@
144
  <button class="btn-logout" onclick="logout()">Logout</button>
145
  </header>
146
 
 
 
 
 
 
 
 
 
 
 
 
147
  <div class="card">
148
  <h2>API Configuration</h2>
149
  <div class="field">
@@ -203,6 +331,187 @@ Loading...
203
  <script>
204
  let authData = null;
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  async function loadStatus() {
207
  try {
208
  const resp = await fetch('/auth/status');
@@ -213,10 +522,12 @@ Loading...
213
  return;
214
  }
215
 
216
- // User info
217
- const user = authData.user || {};
 
 
218
  document.getElementById('userInfo').textContent =
219
- `${user.email || 'Unknown'} (${user.planType || 'unknown plan'})`;
220
 
221
  // API config
222
  const baseUrl = `${window.location.origin}/v1`;
@@ -315,6 +626,13 @@ for await (const chunk of stream) {
315
  setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
316
  }
317
 
 
 
 
 
 
 
 
318
  function copyCode(id) {
319
  const el = document.getElementById(id);
320
  const code = el.textContent.replace('Copy', '').trim();
@@ -327,6 +645,7 @@ for await (const chunk of stream) {
327
  }
328
 
329
  loadStatus();
 
330
  </script>
331
  </body>
332
  </html>
 
131
  font-weight: 500;
132
  }
133
  .status-ok { background: #238636; color: #fff; }
134
+ .status-expired { background: #da3633; color: #fff; }
135
+ .status-rate-limited { background: #d29922; color: #fff; }
136
+ .status-refreshing { background: #58a6ff; color: #fff; }
137
+ .status-disabled { background: #484f58; color: #c9d1d9; }
138
  .loading { color: #8b949e; font-style: italic; }
139
+
140
+ /* Account list styles */
141
+ .account-item {
142
+ background: #0d1117;
143
+ border: 1px solid #30363d;
144
+ border-radius: 8px;
145
+ padding: 1rem;
146
+ margin-bottom: 0.75rem;
147
+ }
148
+ .account-header {
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: space-between;
152
+ margin-bottom: 0.5rem;
153
+ }
154
+ .account-email {
155
+ font-weight: 600;
156
+ font-size: 0.9rem;
157
+ color: #f0f6fc;
158
+ }
159
+ .account-meta {
160
+ display: flex;
161
+ flex-wrap: wrap;
162
+ gap: 0.5rem 1.5rem;
163
+ font-size: 0.8rem;
164
+ color: #8b949e;
165
+ margin-bottom: 0.5rem;
166
+ }
167
+ .account-api-key {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 0.5rem;
171
+ margin-top: 0.5rem;
172
+ }
173
+ .account-api-key .key-value {
174
+ flex: 1;
175
+ background: #161b22;
176
+ padding: 4px 8px;
177
+ border-radius: 4px;
178
+ border: 1px solid #30363d;
179
+ font-family: monospace;
180
+ font-size: 0.75rem;
181
+ color: #8b949e;
182
+ word-break: break-all;
183
+ }
184
+ .btn-delete {
185
+ padding: 4px 10px;
186
+ background: #21262d;
187
+ border: 1px solid #da3633;
188
+ border-radius: 6px;
189
+ color: #da3633;
190
+ cursor: pointer;
191
+ font-size: 0.75rem;
192
+ }
193
+ .btn-delete:hover { background: #da3633; color: #fff; }
194
+ .btn-small {
195
+ padding: 3px 8px;
196
+ background: #21262d;
197
+ border: 1px solid #30363d;
198
+ border-radius: 4px;
199
+ color: #c9d1d9;
200
+ cursor: pointer;
201
+ font-size: 0.7rem;
202
+ }
203
+ .btn-small:hover { background: #30363d; }
204
+
205
+ /* Add account */
206
+ .add-account-section {
207
+ margin-top: 1rem;
208
+ padding-top: 1rem;
209
+ border-top: 1px solid #30363d;
210
+ }
211
+ .btn-login {
212
+ display: inline-flex;
213
+ align-items: center;
214
+ gap: 0.5rem;
215
+ padding: 10px 20px;
216
+ background: #238636;
217
+ border: 1px solid #238636;
218
+ border-radius: 8px;
219
+ color: #fff;
220
+ cursor: pointer;
221
+ font-size: 0.9rem;
222
+ font-weight: 500;
223
+ }
224
+ .btn-login:hover { background: #2ea043; }
225
+ .btn-login:disabled { opacity: 0.5; cursor: not-allowed; }
226
+ .login-info {
227
+ color: #58a6ff;
228
+ font-size: 0.8rem;
229
+ margin-top: 0.5rem;
230
+ }
231
+ .login-error {
232
+ color: #da3633;
233
+ font-size: 0.8rem;
234
+ margin-top: 0.5rem;
235
+ }
236
+ .spinner {
237
+ display: inline-block;
238
+ width: 14px;
239
+ height: 14px;
240
+ border: 2px solid rgba(255,255,255,0.3);
241
+ border-top-color: #fff;
242
+ border-radius: 50%;
243
+ animation: spin 0.8s linear infinite;
244
+ }
245
+ @keyframes spin { to { transform: rotate(360deg); } }
246
+ .empty-state {
247
+ text-align: center;
248
+ color: #484f58;
249
+ padding: 1.5rem;
250
+ font-size: 0.85rem;
251
+ }
252
  </style>
253
  </head>
254
  <body>
 
261
  <button class="btn-logout" onclick="logout()">Logout</button>
262
  </header>
263
 
264
+ <!-- Accounts Card -->
265
+ <div class="card">
266
+ <h2>Accounts</h2>
267
+ <div id="accountList" class="loading">Loading accounts...</div>
268
+ <div class="add-account-section">
269
+ <button class="btn-login" id="loginBtn" onclick="startLogin()">Add Account via ChatGPT Login</button>
270
+ <div class="login-info" id="loginInfo" style="display:none"></div>
271
+ <div class="login-error" id="loginError" style="display:none"></div>
272
+ </div>
273
+ </div>
274
+
275
  <div class="card">
276
  <h2>API Configuration</h2>
277
  <div class="field">
 
331
  <script>
332
  let authData = null;
333
 
334
+ function statusClass(status) {
335
+ const map = {
336
+ active: 'status-ok',
337
+ expired: 'status-expired',
338
+ rate_limited: 'status-rate-limited',
339
+ refreshing: 'status-refreshing',
340
+ disabled: 'status-disabled',
341
+ };
342
+ return map[status] || 'status-disabled';
343
+ }
344
+
345
+ function formatTime(iso) {
346
+ if (!iso) return '-';
347
+ const d = new Date(iso);
348
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
349
+ }
350
+
351
+ function escapeHtml(str) {
352
+ const div = document.createElement('div');
353
+ div.textContent = str;
354
+ return div.innerHTML;
355
+ }
356
+
357
+ async function loadAccounts() {
358
+ try {
359
+ const resp = await fetch('/auth/accounts');
360
+ const data = await resp.json();
361
+ const accounts = data.accounts || [];
362
+ renderAccounts(accounts);
363
+ } catch (err) {
364
+ document.getElementById('accountList').innerHTML =
365
+ '<div class="empty-state">Failed to load accounts: ' + escapeHtml(err.message) + '</div>';
366
+ }
367
+ }
368
+
369
+ function renderAccounts(accounts) {
370
+ const container = document.getElementById('accountList');
371
+
372
+ if (accounts.length === 0) {
373
+ container.innerHTML = '<div class="empty-state">No accounts added. Paste a token below to get started.</div>';
374
+ return;
375
+ }
376
+
377
+ let html = '';
378
+ for (const acct of accounts) {
379
+ const usage = acct.usage || {};
380
+ html += `<div class="account-item">
381
+ <div class="account-header">
382
+ <span class="account-email">${escapeHtml(acct.email || 'Unknown email')}</span>
383
+ <div style="display:flex;gap:0.5rem;align-items:center">
384
+ <span class="status-badge ${statusClass(acct.status)}">${escapeHtml(acct.status)}</span>
385
+ <button class="btn-delete" onclick="deleteAccount('${escapeHtml(acct.id)}')">Delete</button>
386
+ </div>
387
+ </div>
388
+ <div class="account-meta">
389
+ <span>Plan: ${escapeHtml(acct.planType || 'unknown')}</span>
390
+ <span>Requests: ${usage.request_count ?? 0}</span>
391
+ <span>Tokens: ${(usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)}</span>
392
+ <span>Added: ${formatTime(acct.addedAt)}</span>
393
+ ${acct.expiresAt ? `<span>Expires: ${formatTime(acct.expiresAt)}</span>` : ''}
394
+ ${usage.last_used ? `<span>Last used: ${formatTime(usage.last_used)}</span>` : ''}
395
+ </div>
396
+ <div class="account-api-key">
397
+ <span style="font-size:0.75rem;color:#8b949e;">ID:</span>
398
+ <span class="key-value" id="key-${escapeHtml(acct.id)}">${escapeHtml(acct.id)}</span>
399
+ <button class="btn-small" onclick="copyElText('key-${escapeHtml(acct.id)}', this)">Copy</button>
400
+ </div>
401
+ </div>`;
402
+ }
403
+ container.innerHTML = html;
404
+ }
405
+
406
+ let loginPolling = false;
407
+ let knownAccountIds = new Set();
408
+
409
+ async function startLogin() {
410
+ const btn = document.getElementById('loginBtn');
411
+ const infoEl = document.getElementById('loginInfo');
412
+ const errorEl = document.getElementById('loginError');
413
+
414
+ btn.disabled = true;
415
+ btn.innerHTML = '<span class="spinner"></span> Connecting to Codex CLI...';
416
+ infoEl.style.display = 'none';
417
+ errorEl.style.display = 'none';
418
+
419
+ // Snapshot current account IDs so we can detect the new one
420
+ try {
421
+ const resp = await fetch('/auth/accounts');
422
+ const data = await resp.json();
423
+ knownAccountIds = new Set((data.accounts || []).map(a => a.id));
424
+ } catch {}
425
+
426
+ try {
427
+ const resp = await fetch('/auth/accounts/login');
428
+ const data = await resp.json();
429
+
430
+ if (data.error) {
431
+ errorEl.textContent = data.error;
432
+ errorEl.style.display = 'block';
433
+ btn.disabled = false;
434
+ btn.innerHTML = 'Add Account via ChatGPT Login';
435
+ return;
436
+ }
437
+
438
+ if (data.authUrl) {
439
+ window.open(data.authUrl, '_blank');
440
+ btn.innerHTML = '<span class="spinner"></span> Waiting for login...';
441
+ infoEl.textContent = 'A new tab has been opened. Complete the ChatGPT login there.';
442
+ infoEl.style.display = 'block';
443
+ startLoginPolling();
444
+ }
445
+ } catch (err) {
446
+ errorEl.textContent = 'Network error: ' + err.message;
447
+ errorEl.style.display = 'block';
448
+ btn.disabled = false;
449
+ btn.innerHTML = 'Add Account via ChatGPT Login';
450
+ }
451
+ }
452
+
453
+ function startLoginPolling() {
454
+ if (loginPolling) return;
455
+ loginPolling = true;
456
+
457
+ const poll = async () => {
458
+ if (!loginPolling) return;
459
+ try {
460
+ const resp = await fetch('/auth/accounts');
461
+ const data = await resp.json();
462
+ const accounts = data.accounts || [];
463
+ const newAccount = accounts.find(a => !knownAccountIds.has(a.id));
464
+
465
+ if (newAccount) {
466
+ loginPolling = false;
467
+ const btn = document.getElementById('loginBtn');
468
+ const infoEl = document.getElementById('loginInfo');
469
+ btn.disabled = false;
470
+ btn.innerHTML = 'Add Account via ChatGPT Login';
471
+ infoEl.textContent = 'Account added: ' + (newAccount.email || newAccount.id);
472
+ infoEl.style.display = 'block';
473
+ await loadAccounts();
474
+ await loadStatus();
475
+ setTimeout(() => { infoEl.style.display = 'none'; }, 4000);
476
+ return;
477
+ }
478
+ } catch {}
479
+ if (loginPolling) setTimeout(poll, 2000);
480
+ };
481
+
482
+ poll();
483
+
484
+ // Timeout after 5 minutes
485
+ setTimeout(() => {
486
+ if (loginPolling) {
487
+ loginPolling = false;
488
+ const btn = document.getElementById('loginBtn');
489
+ btn.disabled = false;
490
+ btn.innerHTML = 'Add Account via ChatGPT Login';
491
+ document.getElementById('loginInfo').style.display = 'none';
492
+ document.getElementById('loginError').textContent = 'Login timed out. Please try again.';
493
+ document.getElementById('loginError').style.display = 'block';
494
+ }
495
+ }, 5 * 60 * 1000);
496
+ }
497
+
498
+ async function deleteAccount(id) {
499
+ if (!confirm('Remove this account?')) return;
500
+
501
+ try {
502
+ const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
503
+ if (!resp.ok) {
504
+ const data = await resp.json();
505
+ alert(data.error || 'Failed to delete account.');
506
+ return;
507
+ }
508
+ await loadAccounts();
509
+ await loadStatus();
510
+ } catch (err) {
511
+ alert('Network error: ' + err.message);
512
+ }
513
+ }
514
+
515
  async function loadStatus() {
516
  try {
517
  const resp = await fetch('/auth/status');
 
522
  return;
523
  }
524
 
525
+ // Header: pool summary
526
+ const pool = authData.pool || {};
527
+ const total = pool.total || 0;
528
+ const active = pool.active || 0;
529
  document.getElementById('userInfo').textContent =
530
+ `${active} active / ${total} total account${total !== 1 ? 's' : ''}`;
531
 
532
  // API config
533
  const baseUrl = `${window.location.origin}/v1`;
 
626
  setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
627
  }
628
 
629
+ function copyElText(id, btn) {
630
+ const text = document.getElementById(id).textContent;
631
+ navigator.clipboard.writeText(text);
632
+ btn.textContent = 'Copied!';
633
+ setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
634
+ }
635
+
636
  function copyCode(id) {
637
  const el = document.getElementById(id);
638
  const code = el.textContent.replace('Copy', '').trim();
 
645
  }
646
 
647
  loadStatus();
648
+ loadAccounts();
649
  </script>
650
  </body>
651
  </html>
src/routes/accounts.ts CHANGED
@@ -10,12 +10,13 @@
10
  * GET /auth/accounts/:id/cookies β€” view stored cookies
11
  * POST /auth/accounts/:id/cookies β€” set cookies (for Cloudflare bypass)
12
  * DELETE /auth/accounts/:id/cookies β€” clear cookies
 
13
  */
14
 
15
  import { Hono } from "hono";
16
  import type { AccountPool } from "../auth/account-pool.js";
17
  import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
18
- import { validateManualToken } from "../auth/chatgpt-oauth.js";
19
  import { CodexApi } from "../proxy/codex-api.js";
20
  import type { CodexUsageResponse } from "../proxy/codex-api.js";
21
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
@@ -55,6 +56,38 @@ export function createAccountRoutes(
55
  return new CodexApi(token, accountId, cookieJar, entryId);
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  // List all accounts (with optional ?quota=true)
59
  app.get("/auth/accounts", async (c) => {
60
  const accounts = pool.getAccounts();
 
10
  * GET /auth/accounts/:id/cookies β€” view stored cookies
11
  * POST /auth/accounts/:id/cookies β€” set cookies (for Cloudflare bypass)
12
  * DELETE /auth/accounts/:id/cookies β€” clear cookies
13
+ * GET /auth/accounts/login β€” start OAuth to add a new account
14
  */
15
 
16
  import { Hono } from "hono";
17
  import type { AccountPool } from "../auth/account-pool.js";
18
  import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
19
+ import { validateManualToken, isCodexCliAvailable, loginViaCli } from "../auth/chatgpt-oauth.js";
20
  import { CodexApi } from "../proxy/codex-api.js";
21
  import type { CodexUsageResponse } from "../proxy/codex-api.js";
22
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
 
56
  return new CodexApi(token, accountId, cookieJar, entryId);
57
  }
58
 
59
+ // Start OAuth flow to add a new account
60
+ app.get("/auth/accounts/login", async (c) => {
61
+ const cliAvailable = await isCodexCliAvailable();
62
+ if (!cliAvailable) {
63
+ return c.json(
64
+ { error: "Codex CLI not available. Please use manual token entry." },
65
+ 503,
66
+ );
67
+ }
68
+
69
+ try {
70
+ const session = await loginViaCli();
71
+
72
+ // Background: wait for OAuth to complete, then add account to pool
73
+ session.waitForCompletion().then((result) => {
74
+ if (result.success && result.token) {
75
+ const entryId = pool.addAccount(result.token);
76
+ scheduler.scheduleOne(entryId, result.token);
77
+ console.log("[Accounts] OAuth login completed β€” new account added:", entryId);
78
+ } else {
79
+ console.error("[Accounts] OAuth login failed:", result.error);
80
+ }
81
+ });
82
+
83
+ return c.json({ authUrl: session.authUrl });
84
+ } catch (err) {
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ console.error("[Accounts] CLI OAuth failed:", msg);
87
+ return c.json({ error: msg }, 500);
88
+ }
89
+ });
90
+
91
  // List all accounts (with optional ?quota=true)
92
  app.get("/auth/accounts", async (c) => {
93
  const accounts = pool.getAccounts();