@hienlh/ppm 0.7.16 → 0.7.17

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bunfig.toml +2 -0
  3. package/dist/web/assets/chat-tab-C0AcTU9S.js +7 -0
  4. package/dist/web/assets/{code-editor-DXqocnye.js → code-editor-CYt4hqge.js} +1 -1
  5. package/dist/web/assets/{database-viewer-ChX5vA56.js → database-viewer-CDto4TrW.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-8RNfSVOl.js → diff-viewer-2zSPeCzX.js} +1 -1
  7. package/dist/web/assets/git-graph-HfH98qwn.js +1 -0
  8. package/dist/web/assets/index-CTOMzCnZ.js +28 -0
  9. package/dist/web/assets/index-D6GLlwUx.css +2 -0
  10. package/dist/web/assets/keybindings-store-DrjDQzVs.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BOHSi1fK.js → markdown-renderer-dqkYhU3y.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-DRo3924t.js → postgres-viewer-kqZBNVYW.js} +1 -1
  13. package/dist/web/assets/settings-tab-jhRBFgf_.js +1 -0
  14. package/dist/web/assets/{sqlite-viewer-0iVQjCmF.js → sqlite-viewer-C2744fw1.js} +1 -1
  15. package/dist/web/assets/switch-PAf5UhcN.js +1 -0
  16. package/dist/web/assets/{terminal-tab-Cuznr8Lg.js → terminal-tab-BDqc6Dl5.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/server/routes/accounts.ts +60 -6
  21. package/src/services/account.service.ts +180 -17
  22. package/src/services/claude-usage.service.ts +9 -2
  23. package/src/services/db.service.ts +25 -3
  24. package/src/web/components/chat/chat-history-bar.tsx +2 -1
  25. package/src/web/components/chat/message-list.tsx +4 -2
  26. package/src/web/components/chat/usage-badge.tsx +118 -22
  27. package/src/web/components/settings/accounts-settings-section.tsx +268 -33
  28. package/src/web/lib/api-settings.ts +49 -0
  29. package/src/web/styles/globals.css +7 -0
  30. package/test-claude-oauth-v2.mjs +165 -0
  31. package/test-claude-oauth.mjs +175 -0
  32. package/test-verify-oat.mjs +106 -0
  33. package/dist/web/assets/ai-settings-section-BxCMGg-I.js +0 -1
  34. package/dist/web/assets/chat-tab-DtIaMWNT.js +0 -7
  35. package/dist/web/assets/git-graph-DYbWcg6M.js +0 -1
  36. package/dist/web/assets/index-BzhcIgja.js +0 -28
  37. package/dist/web/assets/index-sMxUHxFZ.css +0 -2
  38. package/dist/web/assets/keybindings-store-DBQQ_pTh.js +0 -1
  39. package/dist/web/assets/settings-tab-6ytjTMb9.js +0 -1
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useRef } from "react";
2
2
  import { Button } from "@/components/ui/button";
3
3
  import { Badge } from "@/components/ui/badge";
4
4
  import { Switch } from "@/components/ui/switch";
@@ -6,6 +6,8 @@ import { Input } from "@/components/ui/input";
6
6
  import { Label } from "@/components/ui/label";
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
8
8
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
9
+ import { Eye, Loader2, Copy, ClipboardPaste, X, MoreHorizontal, Download, Upload } from "lucide-react";
10
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
9
11
  import { getAuthToken } from "../../lib/api-client";
10
12
  import {
11
13
  getAccounts,
@@ -13,12 +15,15 @@ import {
13
15
  addAccount,
14
16
  deleteAccount,
15
17
  patchAccount,
18
+ getOAuthUrl,
19
+ exchangeOAuthCode,
16
20
  getAccountSettings,
17
21
  updateAccountSettings,
18
22
  getAllAccountUsages,
19
23
  type AccountInfo,
20
24
  type AccountSettings,
21
25
  type AccountUsageEntry,
26
+ type OAuthProfileData,
22
27
  } from "../../lib/api-settings";
23
28
 
24
29
  function detectTokenType(token: string): string {
@@ -62,6 +67,56 @@ function CompactUsageBars({ usage }: { usage: AccountUsageEntry["usage"] }) {
62
67
  );
63
68
  }
64
69
 
70
+ function subscriptionLabel(profile: OAuthProfileData): string {
71
+ const org = profile.organization;
72
+ if (!org) return "";
73
+ const type = org.organization_type?.replace(/_/g, " ") ?? "";
74
+ const tier = org.rate_limit_tier?.replace(/^default_/, "").replace(/_/g, " ") ?? "";
75
+ return [type, tier].filter(Boolean).join(" / ");
76
+ }
77
+
78
+ function ProfileDetailDialog({ profile, open, onClose }: { profile: OAuthProfileData; open: boolean; onClose: () => void }) {
79
+ const acc = profile.account;
80
+ const org = profile.organization;
81
+ return (
82
+ <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
83
+ <DialogContent className="sm:max-w-md">
84
+ <DialogHeader>
85
+ <DialogTitle className="text-sm">Account Profile</DialogTitle>
86
+ </DialogHeader>
87
+ <div className="space-y-3 text-xs">
88
+ {acc && (
89
+ <div className="space-y-1">
90
+ <p className="text-[11px] font-medium text-muted-foreground">User</p>
91
+ <div className="grid grid-cols-[80px_1fr] gap-1 text-xs">
92
+ {acc.display_name && <><span className="text-muted-foreground">Name</span><span>{acc.display_name}</span></>}
93
+ {acc.email && <><span className="text-muted-foreground">Email</span><span>{acc.email}</span></>}
94
+ {acc.uuid && <><span className="text-muted-foreground">UUID</span><span className="font-mono text-[10px] break-all">{acc.uuid}</span></>}
95
+ </div>
96
+ </div>
97
+ )}
98
+ {org && (
99
+ <div className="space-y-1">
100
+ <p className="text-[11px] font-medium text-muted-foreground">Organization</p>
101
+ <div className="grid grid-cols-[80px_1fr] gap-1 text-xs">
102
+ {org.name && <><span className="text-muted-foreground">Name</span><span>{org.name}</span></>}
103
+ {org.organization_type && <><span className="text-muted-foreground">Type</span><span>{org.organization_type}</span></>}
104
+ {org.rate_limit_tier && <><span className="text-muted-foreground">Tier</span><span>{org.rate_limit_tier}</span></>}
105
+ {org.subscription_status && <><span className="text-muted-foreground">Status</span><span>{org.subscription_status}</span></>}
106
+ {org.has_extra_usage_enabled !== undefined && <><span className="text-muted-foreground">Extra usage</span><span>{org.has_extra_usage_enabled ? "Enabled" : "Disabled"}</span></>}
107
+ </div>
108
+ </div>
109
+ )}
110
+ {!acc && !org && <p className="text-muted-foreground">No profile data available.</p>}
111
+ </div>
112
+ <DialogFooter>
113
+ <Button size="sm" variant="outline" className="text-xs h-7" onClick={onClose}>Close</Button>
114
+ </DialogFooter>
115
+ </DialogContent>
116
+ </Dialog>
117
+ );
118
+ }
119
+
65
120
  export function AccountsSettingsSection() {
66
121
  const [accounts, setAccounts] = useState<AccountInfo[]>([]);
67
122
  const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
@@ -69,10 +124,23 @@ export function AccountsSettingsSection() {
69
124
  const [usageMap, setUsageMap] = useState<Map<string, AccountUsageEntry["usage"]>>(new Map());
70
125
  const [loading, setLoading] = useState(true);
71
126
  const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
127
+ const msgTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
128
+
129
+ function showMessage(msg: { type: "success" | "error"; text: string }) {
130
+ if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
131
+ setMessage(msg);
132
+ msgTimerRef.current = setTimeout(() => setMessage(null), 4000);
133
+ }
72
134
  const [showAddDialog, setShowAddDialog] = useState(false);
73
135
  const [newToken, setNewToken] = useState("");
74
136
  const [newLabel, setNewLabel] = useState("");
75
137
  const [adding, setAdding] = useState(false);
138
+ const [addError, setAddError] = useState<string | null>(null);
139
+ const [profileView, setProfileView] = useState<OAuthProfileData | null>(null);
140
+ const [oauthState, setOauthState] = useState<string | null>(null);
141
+ const [oauthCode, setOauthCode] = useState("");
142
+ const [oauthLoading, setOauthLoading] = useState(false);
143
+ const [oauthStep, setOauthStep] = useState<"idle" | "waiting">("idle");
76
144
 
77
145
  useEffect(() => {
78
146
  refresh();
@@ -93,19 +161,61 @@ export function AccountsSettingsSection() {
93
161
  async function handleAddAccount() {
94
162
  if (!newToken.trim()) return;
95
163
  setAdding(true);
164
+ setAddError(null);
96
165
  try {
97
166
  await addAccount({ apiKey: newToken.trim(), label: newLabel.trim() || undefined });
98
- setMessage({ type: "success", text: "Account added successfully!" });
167
+ showMessage({ type: "success", text: "Account added successfully!" });
99
168
  setShowAddDialog(false);
100
169
  setNewToken("");
101
170
  setNewLabel("");
171
+ setAddError(null);
102
172
  refresh();
103
173
  } catch (e) {
104
- setMessage({ type: "error", text: (e as Error).message });
174
+ setAddError((e as Error).message);
105
175
  }
106
176
  setAdding(false);
107
177
  }
108
178
 
179
+ async function handleOAuthLogin() {
180
+ setOauthLoading(true);
181
+ setAddError(null);
182
+ try {
183
+ const { url, state } = await getOAuthUrl();
184
+ setOauthState(state);
185
+ setOauthStep("waiting");
186
+ window.open(url, "_blank");
187
+ } catch (e) {
188
+ setAddError((e as Error).message);
189
+ }
190
+ setOauthLoading(false);
191
+ }
192
+
193
+ async function handleOAuthExchange() {
194
+ if (!oauthCode.trim() || !oauthState) return;
195
+ setOauthLoading(true);
196
+ setAddError(null);
197
+ try {
198
+ // Parse code — platform returns "CODE#STATE" or just the code
199
+ let code = oauthCode.trim();
200
+ if (code.includes("#")) code = code.split("#")[0] ?? code;
201
+ await exchangeOAuthCode(code, oauthState);
202
+ showMessage({ type: "success", text: "Account connected via OAuth!" });
203
+ setShowAddDialog(false);
204
+ resetOAuthState();
205
+ refresh();
206
+ } catch (e) {
207
+ setAddError((e as Error).message);
208
+ }
209
+ setOauthLoading(false);
210
+ }
211
+
212
+ function resetOAuthState() {
213
+ setOauthState(null);
214
+ setOauthCode("");
215
+ setOauthStep("idle");
216
+ setAddError(null);
217
+ }
218
+
109
219
  async function handleToggle(id: string, currentStatus: string) {
110
220
  const newStatus = currentStatus === "disabled" ? "active" : "disabled";
111
221
  await patchAccount(id, { status: newStatus });
@@ -157,7 +267,7 @@ export function AccountsSettingsSection() {
157
267
  a.click();
158
268
  URL.revokeObjectURL(url);
159
269
  } catch (e) {
160
- setMessage({ type: "error", text: (e as Error).message });
270
+ showMessage({ type: "error", text: (e as Error).message });
161
271
  }
162
272
  }
163
273
 
@@ -172,14 +282,47 @@ export function AccountsSettingsSection() {
172
282
  const res = await fetch("/api/accounts/import", { method: "POST", headers, body: text });
173
283
  const json = await res.json() as { ok: boolean; data?: { imported: number }; error?: string };
174
284
  if (!json.ok) throw new Error(json.error ?? "Import failed");
175
- setMessage({ type: "success", text: `Imported ${json.data?.imported ?? 0} account(s).` });
285
+ showMessage({ type: "success", text: `Imported ${json.data?.imported ?? 0} account(s).` });
176
286
  refresh();
177
287
  } catch (e) {
178
- setMessage({ type: "error", text: (e as Error).message || "Import failed" });
288
+ showMessage({ type: "error", text: (e as Error).message || "Import failed" });
179
289
  }
180
290
  e.target.value = "";
181
291
  }
182
292
 
293
+ async function handleExportClipboard() {
294
+ try {
295
+ const headers: HeadersInit = {};
296
+ const token = getAuthToken();
297
+ if (token) headers["Authorization"] = `Bearer ${token}`;
298
+ const res = await fetch("/api/accounts/export", { headers });
299
+ if (!res.ok) throw new Error(`Export failed: ${res.status}`);
300
+ const text = await res.text();
301
+ await navigator.clipboard.writeText(text);
302
+ showMessage({ type: "success", text: "Accounts copied to clipboard!" });
303
+ } catch (e) {
304
+ showMessage({ type: "error", text: (e as Error).message });
305
+ }
306
+ }
307
+
308
+ async function handleImportClipboard() {
309
+ try {
310
+ const text = await navigator.clipboard.readText();
311
+ if (!text.trim()) throw new Error("Clipboard is empty");
312
+ JSON.parse(text); // validate JSON
313
+ const headers: HeadersInit = { "Content-Type": "application/json" };
314
+ const token = getAuthToken();
315
+ if (token) headers["Authorization"] = `Bearer ${token}`;
316
+ const res = await fetch("/api/accounts/import", { method: "POST", headers, body: text });
317
+ const json = await res.json() as { ok: boolean; data?: { imported: number }; error?: string };
318
+ if (!json.ok) throw new Error(json.error ?? "Import failed");
319
+ showMessage({ type: "success", text: `Imported ${json.data?.imported ?? 0} account(s) from clipboard.` });
320
+ refresh();
321
+ } catch (e) {
322
+ showMessage({ type: "error", text: (e as Error).message || "Import from clipboard failed" });
323
+ }
324
+ }
325
+
183
326
  const tokenHint = newToken.trim() ? detectTokenType(newToken.trim()) : "";
184
327
 
185
328
  return (
@@ -210,44 +353,74 @@ export function AccountsSettingsSection() {
210
353
  </div>
211
354
  <div className="text-[11px] text-muted-foreground mt-0.5 flex gap-2 flex-wrap">
212
355
  {acc.email && acc.label && <span>{acc.email}</span>}
356
+ {acc.profileData?.organization?.name && (
357
+ <span>{subscriptionLabel(acc.profileData)}</span>
358
+ )}
213
359
  <span>{acc.totalRequests} reqs</span>
214
360
  <span>Last: {formatLastUsed(acc.lastUsedAt)}</span>
215
361
  </div>
216
362
  {usageMap.get(acc.id) && <CompactUsageBars usage={usageMap.get(acc.id)!} />}
217
363
  </div>
218
- <div className="flex items-center gap-1.5 shrink-0">
364
+ <div className="flex items-center gap-1 shrink-0">
365
+ {acc.profileData && (
366
+ <Button
367
+ size="icon"
368
+ variant="ghost"
369
+ className="size-8 cursor-pointer text-muted-foreground hover:text-foreground"
370
+ onClick={() => setProfileView(acc.profileData)}
371
+ title="View profile"
372
+ >
373
+ <Eye className="size-3.5" />
374
+ </Button>
375
+ )}
219
376
  <Switch
220
377
  checked={acc.status !== "disabled"}
221
378
  onCheckedChange={() => handleToggle(acc.id, acc.status)}
222
379
  disabled={acc.status === "cooldown"}
223
- className="scale-75"
380
+ className="scale-75 cursor-pointer"
224
381
  />
225
382
  <Button
226
- size="sm"
383
+ size="icon"
227
384
  variant="ghost"
228
- className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
385
+ className="size-8 cursor-pointer text-muted-foreground hover:text-destructive"
229
386
  onClick={() => handleDelete(acc.id, acc.email)}
387
+ title="Remove account"
230
388
  >
231
-
389
+ <X className="size-3.5" />
232
390
  </Button>
233
391
  </div>
234
392
  </div>
235
393
  ))}
236
394
  </div>
237
395
 
238
- <div className="flex gap-1.5 flex-wrap">
239
- <Button size="sm" className="h-7 text-xs" onClick={() => setShowAddDialog(true)}>
396
+ <div className="flex gap-1.5">
397
+ <Button size="sm" className="h-8 text-xs cursor-pointer" onClick={() => setShowAddDialog(true)}>
240
398
  + Add Account
241
399
  </Button>
242
- <Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleExport}>
243
- Export
244
- </Button>
245
- <label>
246
- <Button size="sm" variant="outline" className="h-7 text-xs" asChild>
247
- <span>Import</span>
248
- </Button>
249
- <input type="file" accept=".json" className="hidden" onChange={handleImport} />
250
- </label>
400
+ <DropdownMenu>
401
+ <DropdownMenuTrigger asChild>
402
+ <Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer">
403
+ <MoreHorizontal className="size-3.5 mr-1" /> More
404
+ </Button>
405
+ </DropdownMenuTrigger>
406
+ <DropdownMenuContent align="start">
407
+ <DropdownMenuItem className="text-xs cursor-pointer" onClick={handleExportClipboard}>
408
+ <Copy className="size-3.5 mr-2" /> Copy to clipboard
409
+ </DropdownMenuItem>
410
+ <DropdownMenuItem className="text-xs cursor-pointer" onClick={handleImportClipboard}>
411
+ <ClipboardPaste className="size-3.5 mr-2" /> Paste from clipboard
412
+ </DropdownMenuItem>
413
+ <DropdownMenuItem className="text-xs cursor-pointer" onClick={handleExport}>
414
+ <Download className="size-3.5 mr-2" /> Export as file
415
+ </DropdownMenuItem>
416
+ <DropdownMenuItem className="text-xs cursor-pointer" asChild>
417
+ <label className="flex items-center">
418
+ <Upload className="size-3.5 mr-2" /> Import from file
419
+ <input type="file" accept=".json" className="hidden" onChange={handleImport} />
420
+ </label>
421
+ </DropdownMenuItem>
422
+ </DropdownMenuContent>
423
+ </DropdownMenu>
251
424
  </div>
252
425
  </div>
253
426
 
@@ -295,15 +468,75 @@ export function AccountsSettingsSection() {
295
468
  </div>
296
469
  )}
297
470
 
298
- <Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
471
+ {/* Profile detail dialog */}
472
+ {profileView && (
473
+ <ProfileDetailDialog
474
+ profile={profileView}
475
+ open={!!profileView}
476
+ onClose={() => setProfileView(null)}
477
+ />
478
+ )}
479
+
480
+ <Dialog open={showAddDialog} onOpenChange={(v) => { setShowAddDialog(v); if (!v) resetOAuthState(); }}>
299
481
  <DialogContent className="sm:max-w-md">
300
482
  <DialogHeader>
301
483
  <DialogTitle className="text-sm">Add Claude Account</DialogTitle>
302
484
  <DialogDescription className="text-xs leading-relaxed">
303
- Supports both Claude Max/Pro session tokens and API keys. Token is encrypted and stored locally.
485
+ Connect your Claude account via OAuth (recommended) or paste a token manually.
304
486
  </DialogDescription>
305
487
  </DialogHeader>
306
488
  <div className="space-y-3">
489
+ {/* OAuth login — recommended */}
490
+ <div className="rounded-md border p-3 space-y-2">
491
+ <p className="text-[11px] font-medium">Recommended: Login with Claude</p>
492
+ <p className="text-[10px] text-muted-foreground">
493
+ Fetches your profile, enables auto-refresh, and uses the correct permissions.
494
+ </p>
495
+ {oauthStep === "idle" ? (
496
+ <Button
497
+ size="sm"
498
+ className="w-full h-8 text-xs"
499
+ onClick={handleOAuthLogin}
500
+ disabled={oauthLoading}
501
+ >
502
+ {oauthLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Opening...</> : "Login with Claude"}
503
+ </Button>
504
+ ) : (
505
+ <div className="space-y-2">
506
+ <p className="text-[10px] text-muted-foreground">
507
+ Authorize in the opened tab, then paste the code shown on the page:
508
+ </p>
509
+ <Input
510
+ placeholder="Paste code here..."
511
+ value={oauthCode}
512
+ onChange={(e) => setOauthCode(e.target.value)}
513
+ className="text-xs h-8 font-mono"
514
+ autoFocus
515
+ />
516
+ <div className="flex gap-1.5">
517
+ <Button
518
+ size="sm"
519
+ className="flex-1 h-7 text-xs"
520
+ onClick={handleOAuthExchange}
521
+ disabled={!oauthCode.trim() || oauthLoading}
522
+ >
523
+ {oauthLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Connecting...</> : "Connect Account"}
524
+ </Button>
525
+ <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={resetOAuthState}>
526
+ Cancel
527
+ </Button>
528
+ </div>
529
+ </div>
530
+ )}
531
+ </div>
532
+
533
+ <div className="flex items-center gap-2">
534
+ <div className="flex-1 border-t" />
535
+ <span className="text-[10px] text-muted-foreground">or paste token manually</span>
536
+ <div className="flex-1 border-t" />
537
+ </div>
538
+
539
+ {/* Manual token input */}
307
540
  <div className="space-y-1.5">
308
541
  <Label htmlFor="token" className="text-xs">Token</Label>
309
542
  <Input
@@ -331,14 +564,11 @@ export function AccountsSettingsSection() {
331
564
  />
332
565
  </div>
333
566
  <div className="rounded-md bg-muted/50 p-2.5 space-y-1.5">
334
- <p className="text-[10px] font-medium text-muted-foreground">How to get your token:</p>
567
+ <p className="text-[10px] font-medium text-muted-foreground">How to get a token manually:</p>
335
568
  <div className="text-[10px] text-muted-foreground space-y-1">
336
- <p><span className="font-medium">Claude Max/Pro:</span> Run in terminal:</p>
337
- <code className="block bg-background rounded px-1.5 py-1 text-[10px] font-mono select-all">
338
- claude setup-token
339
- </code>
340
- <p className="text-[9px]">Follow the prompts to generate a long-lived token (valid for 1 year).</p>
341
- <p className="mt-1"><span className="font-medium">API key:</span>{" "}
569
+ <p><span className="font-medium">Claude Max/Pro:</span> Run <code className="bg-background rounded px-1 font-mono">claude setup-token</code></p>
570
+ <p className="text-[9px]">Valid 1 year but no auto-refresh, may lack profile data.</p>
571
+ <p><span className="font-medium">API key:</span>{" "}
342
572
  <a
343
573
  href="https://console.anthropic.com/settings/keys"
344
574
  target="_blank"
@@ -351,12 +581,17 @@ export function AccountsSettingsSection() {
351
581
  </div>
352
582
  </div>
353
583
  </div>
584
+ {addError && (
585
+ <div className="text-[11px] p-2 rounded bg-red-500/10 text-red-600">
586
+ {addError}
587
+ </div>
588
+ )}
354
589
  <DialogFooter>
355
- <Button size="sm" variant="outline" className="text-xs h-7" onClick={() => setShowAddDialog(false)}>
590
+ <Button size="sm" variant="outline" className="text-xs h-7" onClick={() => { setShowAddDialog(false); resetOAuthState(); }}>
356
591
  Cancel
357
592
  </Button>
358
593
  <Button size="sm" className="text-xs h-7" onClick={handleAddAccount} disabled={!newToken.trim() || adding}>
359
- {adding ? "Adding..." : "Add Account"}
594
+ {adding ? "Adding..." : "Add Token"}
360
595
  </Button>
361
596
  </DialogFooter>
362
597
  </DialogContent>
@@ -1,5 +1,32 @@
1
1
  import { api } from "./api-client";
2
2
 
3
+ export interface OAuthProfileData {
4
+ account?: {
5
+ uuid?: string;
6
+ full_name?: string;
7
+ display_name?: string;
8
+ email?: string;
9
+ has_claude_max?: boolean;
10
+ has_claude_pro?: boolean;
11
+ created_at?: string;
12
+ };
13
+ organization?: {
14
+ uuid?: string;
15
+ name?: string;
16
+ organization_type?: string;
17
+ billing_type?: string;
18
+ rate_limit_tier?: string;
19
+ has_extra_usage_enabled?: boolean;
20
+ subscription_status?: string;
21
+ subscription_created_at?: string;
22
+ };
23
+ application?: {
24
+ uuid?: string;
25
+ name?: string;
26
+ slug?: string;
27
+ };
28
+ }
29
+
3
30
  export interface AccountInfo {
4
31
  id: string;
5
32
  label: string | null;
@@ -10,9 +37,19 @@ export interface AccountInfo {
10
37
  priority: number;
11
38
  totalRequests: number;
12
39
  lastUsedAt: number | null;
40
+ profileData: OAuthProfileData | null;
13
41
  createdAt: number;
14
42
  }
15
43
 
44
+ export interface VerifyResult {
45
+ valid: boolean;
46
+ email?: string;
47
+ orgName?: string;
48
+ subscriptionType?: string;
49
+ authMethod?: string;
50
+ profileData?: OAuthProfileData;
51
+ }
52
+
16
53
  export interface AccountSettings {
17
54
  strategy: "round-robin" | "fill-first";
18
55
  maxRetry: number;
@@ -61,6 +98,18 @@ export interface AccountUsageEntry {
61
98
  };
62
99
  }
63
100
 
101
+ export function verifyAccount(id: string): Promise<VerifyResult> {
102
+ return api.post<VerifyResult>(`/api/accounts/${id}/verify`);
103
+ }
104
+
105
+ export function getOAuthUrl(): Promise<{ url: string; state: string }> {
106
+ return api.get<{ url: string; state: string }>("/api/accounts/oauth/url");
107
+ }
108
+
109
+ export function exchangeOAuthCode(code: string, state: string): Promise<AccountInfo> {
110
+ return api.post<AccountInfo>("/api/accounts/oauth/exchange", { code, state });
111
+ }
112
+
64
113
  export function getAllAccountUsages(): Promise<AccountUsageEntry[]> {
65
114
  return api.get<AccountUsageEntry[]>("/api/accounts/usage");
66
115
  }
@@ -126,6 +126,13 @@
126
126
  outline: none;
127
127
  }
128
128
 
129
+ /* Prevent iOS Safari auto-zoom on focus (requires font-size >= 16px) */
130
+ @media screen and (max-width: 767px) {
131
+ input, textarea, select {
132
+ font-size: 16px !important;
133
+ }
134
+ }
135
+
129
136
  /* Prevent overscroll bounce on iOS */
130
137
  html,
131
138
  body {