@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.
- package/CHANGELOG.md +15 -0
- package/bunfig.toml +2 -0
- package/dist/web/assets/chat-tab-C0AcTU9S.js +7 -0
- package/dist/web/assets/{code-editor-DXqocnye.js → code-editor-CYt4hqge.js} +1 -1
- package/dist/web/assets/{database-viewer-ChX5vA56.js → database-viewer-CDto4TrW.js} +1 -1
- package/dist/web/assets/{diff-viewer-8RNfSVOl.js → diff-viewer-2zSPeCzX.js} +1 -1
- package/dist/web/assets/git-graph-HfH98qwn.js +1 -0
- package/dist/web/assets/index-CTOMzCnZ.js +28 -0
- package/dist/web/assets/index-D6GLlwUx.css +2 -0
- package/dist/web/assets/keybindings-store-DrjDQzVs.js +1 -0
- package/dist/web/assets/{markdown-renderer-BOHSi1fK.js → markdown-renderer-dqkYhU3y.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DRo3924t.js → postgres-viewer-kqZBNVYW.js} +1 -1
- package/dist/web/assets/settings-tab-jhRBFgf_.js +1 -0
- package/dist/web/assets/{sqlite-viewer-0iVQjCmF.js → sqlite-viewer-C2744fw1.js} +1 -1
- package/dist/web/assets/switch-PAf5UhcN.js +1 -0
- package/dist/web/assets/{terminal-tab-Cuznr8Lg.js → terminal-tab-BDqc6Dl5.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/routes/accounts.ts +60 -6
- package/src/services/account.service.ts +180 -17
- package/src/services/claude-usage.service.ts +9 -2
- package/src/services/db.service.ts +25 -3
- package/src/web/components/chat/chat-history-bar.tsx +2 -1
- package/src/web/components/chat/message-list.tsx +4 -2
- package/src/web/components/chat/usage-badge.tsx +118 -22
- package/src/web/components/settings/accounts-settings-section.tsx +268 -33
- package/src/web/lib/api-settings.ts +49 -0
- package/src/web/styles/globals.css +7 -0
- package/test-claude-oauth-v2.mjs +165 -0
- package/test-claude-oauth.mjs +175 -0
- package/test-verify-oat.mjs +106 -0
- package/dist/web/assets/ai-settings-section-BxCMGg-I.js +0 -1
- package/dist/web/assets/chat-tab-DtIaMWNT.js +0 -7
- package/dist/web/assets/git-graph-DYbWcg6M.js +0 -1
- package/dist/web/assets/index-BzhcIgja.js +0 -28
- package/dist/web/assets/index-sMxUHxFZ.css +0 -2
- package/dist/web/assets/keybindings-store-DBQQ_pTh.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
+
showMessage({ type: "success", text: `Imported ${json.data?.imported ?? 0} account(s).` });
|
|
176
286
|
refresh();
|
|
177
287
|
} catch (e) {
|
|
178
|
-
|
|
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
|
|
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="
|
|
383
|
+
size="icon"
|
|
227
384
|
variant="ghost"
|
|
228
|
-
className="
|
|
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
|
|
239
|
-
<Button size="sm" className="h-
|
|
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
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
337
|
-
<
|
|
338
|
-
|
|
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
|
|
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 {
|