@hienlh/ppm 0.8.49 → 0.8.51
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 +23 -0
- package/dist/web/assets/api-settings-D4bgXrLU.js +1 -0
- package/dist/web/assets/chat-tab-CoV1KQMy.js +7 -0
- package/dist/web/assets/{code-editor-CdiHsvVd.js → code-editor-BZPwIGKR.js} +1 -1
- package/dist/web/assets/{database-viewer-DCB3fyHi.js → database-viewer-Bof6ObKy.js} +1 -1
- package/dist/web/assets/{diff-viewer-DZEXDwGs.js → diff-viewer-te-ZDE1c.js} +1 -1
- package/dist/web/assets/{git-graph-BfgY255b.js → git-graph-D5MCrTdW.js} +1 -1
- package/dist/web/assets/index-Bb5A248z.js +37 -0
- package/dist/web/assets/index-CoyMn-Mj.css +2 -0
- package/dist/web/assets/keybindings-store-CS8iFKWp.js +1 -0
- package/dist/web/assets/{markdown-renderer-F1beFJEy.js → markdown-renderer-BVNQfyqo.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CxO5FaWj.js → postgres-viewer-DAAoR6eS.js} +1 -1
- package/dist/web/assets/{settings-store-xG6mKqkD.js → settings-store-DL2KEbtc.js} +2 -2
- package/dist/web/assets/settings-tab-BfXWlmwG.js +1 -0
- package/dist/web/assets/{sqlite-viewer-CQNRFUxH.js → sqlite-viewer-C0pY249Q.js} +1 -1
- package/dist/web/assets/{terminal-tab-CEvaCyVU.js → terminal-tab-pBZIdGj-.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-DlFSiqvG.js → use-monaco-theme-DwP4EHdO.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +21 -8
- package/src/server/index.ts +4 -0
- package/src/server/routes/accounts.ts +137 -2
- package/src/server/routes/proxy.ts +79 -0
- package/src/server/routes/settings.ts +53 -1
- package/src/services/proxy.service.ts +139 -0
- package/src/types/config.ts +1 -0
- package/src/web/components/chat/message-list.tsx +2 -125
- package/src/web/components/settings/accounts-settings-section.tsx +255 -6
- package/src/web/components/settings/ai-settings-section.tsx +21 -0
- package/src/web/components/settings/proxy-settings-section.tsx +217 -0
- package/src/web/components/settings/settings-tab.tsx +5 -2
- package/src/web/lib/api-settings.ts +52 -0
- package/test-tokens.mjs +212 -0
- package/dist/web/assets/api-settings-CaKDC7_s.js +0 -1
- package/dist/web/assets/chat-tab-CkVy9ut7.js +0 -7
- package/dist/web/assets/index-DubLYgN1.css +0 -2
- package/dist/web/assets/index-odr3ymlS.js +0 -28
- package/dist/web/assets/keybindings-store-tyvdfWMV.js +0 -1
- package/dist/web/assets/settings-tab-DLtrBBV2.js +0 -1
|
@@ -72,71 +72,15 @@ export function MessageList({
|
|
|
72
72
|
);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// Track which user message is pinned (scrolled above viewport) + push-out offset
|
|
76
|
-
const [pinnedContent, setPinnedContent] = useState<string | null>(null);
|
|
77
|
-
const [pushOffset, setPushOffset] = useState(0);
|
|
78
|
-
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
79
|
-
const pinnedRef = useRef<HTMLDivElement>(null);
|
|
80
|
-
|
|
81
75
|
const filtered = useMemo(() => messages.filter((msg) => {
|
|
82
76
|
const hasContent = msg.content && msg.content.trim().length > 0;
|
|
83
77
|
const hasEvents = msg.events && msg.events.length > 0;
|
|
84
78
|
return hasContent || hasEvents;
|
|
85
79
|
}), [messages]);
|
|
86
80
|
|
|
87
|
-
// Observe user message elements to track which one is pinned + push-out transition
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
const wrapper = wrapperRef.current;
|
|
90
|
-
if (!wrapper) return;
|
|
91
|
-
const scrollEl = wrapper.querySelector("[data-stick-to-bottom-scroll]") as HTMLElement
|
|
92
|
-
?? wrapper.firstElementChild as HTMLElement;
|
|
93
|
-
if (!scrollEl || scrollEl.scrollHeight <= scrollEl.clientHeight) return;
|
|
94
|
-
|
|
95
|
-
const handleScroll = () => {
|
|
96
|
-
const userEls = wrapper.querySelectorAll<HTMLElement>("[data-user-content]");
|
|
97
|
-
const scrollRect = scrollEl.getBoundingClientRect();
|
|
98
|
-
const pinnedH = pinnedRef.current?.offsetHeight ?? 0;
|
|
99
|
-
|
|
100
|
-
let lastAbove: string | null = null;
|
|
101
|
-
let nextTop = Infinity;
|
|
102
|
-
|
|
103
|
-
for (let i = 0; i < userEls.length; i++) {
|
|
104
|
-
const rect = userEls[i]!.getBoundingClientRect();
|
|
105
|
-
if (rect.top < scrollRect.top + 4) {
|
|
106
|
-
lastAbove = userEls[i]!.getAttribute("data-user-content");
|
|
107
|
-
// Find the next user message after this one
|
|
108
|
-
const nextEl = userEls[i + 1];
|
|
109
|
-
nextTop = nextEl ? nextEl.getBoundingClientRect().top - scrollRect.top : Infinity;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
setPinnedContent(lastAbove);
|
|
114
|
-
// Push-out: when next header enters the pinned area, offset upward
|
|
115
|
-
if (pinnedH > 0 && nextTop < pinnedH) {
|
|
116
|
-
setPushOffset(nextTop - pinnedH);
|
|
117
|
-
} else {
|
|
118
|
-
setPushOffset(0);
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
|
123
|
-
handleScroll();
|
|
124
|
-
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
|
125
|
-
}, [filtered]);
|
|
126
|
-
|
|
127
81
|
return (
|
|
128
82
|
<div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
|
|
129
|
-
|
|
130
|
-
{pinnedContent && (
|
|
131
|
-
<div
|
|
132
|
-
ref={pinnedRef}
|
|
133
|
-
className="absolute top-0 left-0 right-0 z-20 bg-background"
|
|
134
|
-
style={pushOffset < 0 ? { transform: `translateY(${pushOffset}px)` } : undefined}
|
|
135
|
-
>
|
|
136
|
-
<PinnedUserMessage content={pinnedContent} projectName={projectName} />
|
|
137
|
-
</div>
|
|
138
|
-
)}
|
|
139
|
-
<StickToBottom ref={wrapperRef} className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
|
|
83
|
+
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
|
|
140
84
|
<StickToBottom.Content className="p-4 space-y-4">
|
|
141
85
|
{filtered.map((msg) => (
|
|
142
86
|
<MessageBubble
|
|
@@ -162,73 +106,6 @@ export function MessageList({
|
|
|
162
106
|
);
|
|
163
107
|
}
|
|
164
108
|
|
|
165
|
-
/** Compact pinned bar showing the current user message at the top of chat */
|
|
166
|
-
function PinnedUserMessage({ content, projectName }: { content: string; projectName?: string }) {
|
|
167
|
-
const { files, text } = useMemo(() => {
|
|
168
|
-
const parsed = parseUserAttachments(content);
|
|
169
|
-
const { cleanText } = extractSystemTags(parsed.text);
|
|
170
|
-
return { files: parsed.files, text: cleanText };
|
|
171
|
-
}, [content]);
|
|
172
|
-
|
|
173
|
-
const [expanded, setExpanded] = useState(false);
|
|
174
|
-
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
175
|
-
const contentRef = useRef<HTMLDivElement>(null);
|
|
176
|
-
|
|
177
|
-
// Reset expanded state when pinned message changes
|
|
178
|
-
useEffect(() => { setExpanded(false); }, [content]);
|
|
179
|
-
|
|
180
|
-
useEffect(() => {
|
|
181
|
-
const el = contentRef.current;
|
|
182
|
-
if (!el) return;
|
|
183
|
-
const check = () => setIsOverflowing(el.scrollHeight > el.clientHeight + 2);
|
|
184
|
-
check();
|
|
185
|
-
const ro = new ResizeObserver(check);
|
|
186
|
-
ro.observe(el);
|
|
187
|
-
return () => ro.disconnect();
|
|
188
|
-
}, [text]);
|
|
189
|
-
|
|
190
|
-
if (!text && files.length === 0) return null;
|
|
191
|
-
|
|
192
|
-
return (
|
|
193
|
-
<div className="shrink-0 px-4 pt-3 pb-2">
|
|
194
|
-
<div className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary space-y-2 border border-primary/15 shadow-sm">
|
|
195
|
-
{files.length > 0 && (
|
|
196
|
-
<div className="flex flex-wrap gap-1.5">
|
|
197
|
-
{files.map((filePath, i) => (
|
|
198
|
-
<div key={i} className="flex items-center gap-1 rounded-md border border-border/60 bg-background/40 px-1.5 py-0.5 text-[11px] text-text-secondary">
|
|
199
|
-
{isImagePath(filePath) ? <ImageIcon className="size-3 shrink-0" /> : <FileText className="size-3 shrink-0" />}
|
|
200
|
-
<span className="truncate max-w-32">{basename(filePath)}</span>
|
|
201
|
-
</div>
|
|
202
|
-
))}
|
|
203
|
-
</div>
|
|
204
|
-
)}
|
|
205
|
-
{text && (
|
|
206
|
-
<div>
|
|
207
|
-
<div
|
|
208
|
-
ref={contentRef}
|
|
209
|
-
className={cn(
|
|
210
|
-
"whitespace-pre-wrap break-words transition-all duration-200",
|
|
211
|
-
!expanded && "line-clamp-2",
|
|
212
|
-
expanded && "max-h-[40vh] overflow-y-auto",
|
|
213
|
-
)}
|
|
214
|
-
>
|
|
215
|
-
{text}
|
|
216
|
-
</div>
|
|
217
|
-
{(isOverflowing || expanded) && (
|
|
218
|
-
<button
|
|
219
|
-
onClick={() => setExpanded(!expanded)}
|
|
220
|
-
className="flex items-center gap-1 text-xs text-primary/70 hover:text-primary mt-1 transition-colors"
|
|
221
|
-
>
|
|
222
|
-
{expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
|
|
223
|
-
</button>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
)}
|
|
227
|
-
</div>
|
|
228
|
-
</div>
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
109
|
/** Floating button to scroll back to bottom when user has scrolled up */
|
|
233
110
|
function ScrollToBottomButton() {
|
|
234
111
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
@@ -374,7 +251,7 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
374
251
|
}, [text]);
|
|
375
252
|
|
|
376
253
|
return (
|
|
377
|
-
<div
|
|
254
|
+
<div className="group/user relative rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary border border-primary/15 shadow-sm">
|
|
378
255
|
{/* System tags as badges */}
|
|
379
256
|
{tags.length > 0 && <SystemTagBadges tags={tags} />}
|
|
380
257
|
|
|
@@ -6,7 +6,7 @@ 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, X, Download, Upload, Lock } from "lucide-react";
|
|
9
|
+
import { Eye, Loader2, Copy, X, Download, Upload, Lock, FlaskConical } from "lucide-react";
|
|
10
10
|
import { getAuthToken } from "../../lib/api-client";
|
|
11
11
|
import {
|
|
12
12
|
getAccounts,
|
|
@@ -20,10 +20,15 @@ import {
|
|
|
20
20
|
updateAccountSettings,
|
|
21
21
|
getAllAccountUsages,
|
|
22
22
|
importAccounts,
|
|
23
|
+
testAccountToken,
|
|
24
|
+
testExport,
|
|
25
|
+
testRawToken,
|
|
23
26
|
type AccountInfo,
|
|
24
27
|
type AccountSettings,
|
|
25
28
|
type AccountUsageEntry,
|
|
26
29
|
type OAuthProfileData,
|
|
30
|
+
type TokenTestResult,
|
|
31
|
+
type ExportedTokenInfo,
|
|
27
32
|
} from "../../lib/api-settings";
|
|
28
33
|
|
|
29
34
|
function detectTokenType(token: string): string {
|
|
@@ -152,6 +157,7 @@ export function AccountsSettingsSection() {
|
|
|
152
157
|
const [exportSelected, setExportSelected] = useState<Set<string>>(new Set());
|
|
153
158
|
const [exporting, setExporting] = useState(false);
|
|
154
159
|
const [exportFullTransfer, setExportFullTransfer] = useState(false);
|
|
160
|
+
const [exportRefreshBefore, setExportRefreshBefore] = useState(false);
|
|
155
161
|
|
|
156
162
|
// Import dialog
|
|
157
163
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
|
@@ -160,6 +166,16 @@ export function AccountsSettingsSection() {
|
|
|
160
166
|
const [importing, setImporting] = useState(false);
|
|
161
167
|
const [importError, setImportError] = useState<string | null>(null);
|
|
162
168
|
|
|
169
|
+
// Token test dialog
|
|
170
|
+
const [showTokenTest, setShowTokenTest] = useState(false);
|
|
171
|
+
const [tokenTestResults, setTokenTestResults] = useState<Map<string, { loading: boolean; result?: TokenTestResult; error?: string }>>(new Map());
|
|
172
|
+
const [tokenTestRefresh, setTokenTestRefresh] = useState(false);
|
|
173
|
+
// Export simulation — accumulate rounds
|
|
174
|
+
const [exportRounds, setExportRounds] = useState<{ round: number; time: string; includeRefresh: boolean; items: ExportedTokenInfo[] }[]>([]);
|
|
175
|
+
const [exportSimLoading, setExportSimLoading] = useState(false);
|
|
176
|
+
const [exportSimIncludeRefresh, setExportSimIncludeRefresh] = useState(false);
|
|
177
|
+
const [rawTokenTests, setRawTokenTests] = useState<Map<string, { loading: boolean; status?: string; code?: number; error?: string }>>(new Map());
|
|
178
|
+
|
|
163
179
|
useEffect(() => {
|
|
164
180
|
refresh();
|
|
165
181
|
}, []);
|
|
@@ -304,6 +320,7 @@ export function AccountsSettingsSection() {
|
|
|
304
320
|
setExportConfirm("");
|
|
305
321
|
setExportSelected(new Set(exportableAccounts.map((a) => a.id)));
|
|
306
322
|
setExportFullTransfer(false);
|
|
323
|
+
setExportRefreshBefore(false);
|
|
307
324
|
setShowExportDialog(true);
|
|
308
325
|
}
|
|
309
326
|
|
|
@@ -325,7 +342,7 @@ export function AccountsSettingsSection() {
|
|
|
325
342
|
const res = await fetch("/api/accounts/export", {
|
|
326
343
|
method: "POST",
|
|
327
344
|
headers,
|
|
328
|
-
body: JSON.stringify({ password: exportPassword, accountIds: [...exportSelected], includeRefreshToken: exportFullTransfer }),
|
|
345
|
+
body: JSON.stringify({ password: exportPassword, accountIds: [...exportSelected], includeRefreshToken: exportFullTransfer, refreshBeforeExport: exportRefreshBefore }),
|
|
329
346
|
});
|
|
330
347
|
if (!res.ok) { const j = await res.json() as any; throw new Error(j.error ?? `Export failed: ${res.status}`); }
|
|
331
348
|
const text = await res.text();
|
|
@@ -375,6 +392,46 @@ export function AccountsSettingsSection() {
|
|
|
375
392
|
}
|
|
376
393
|
|
|
377
394
|
|
|
395
|
+
async function simulateExport() {
|
|
396
|
+
const oauthIds = accounts.filter((a) => a.hasRefreshToken).map((a) => a.id);
|
|
397
|
+
if (!oauthIds.length) return;
|
|
398
|
+
setExportSimLoading(true);
|
|
399
|
+
try {
|
|
400
|
+
const data = await testExport(oauthIds, exportSimIncludeRefresh);
|
|
401
|
+
const roundNum = exportRounds.length + 1;
|
|
402
|
+
const time = new Date().toLocaleTimeString();
|
|
403
|
+
setExportRounds((prev) => [...prev, { round: roundNum, time, includeRefresh: exportSimIncludeRefresh, items: data }]);
|
|
404
|
+
} catch { /* ignore */ }
|
|
405
|
+
setExportSimLoading(false);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function testRawTokenClick(key: string, token: string) {
|
|
409
|
+
setRawTokenTests((prev) => new Map(prev).set(key, { loading: true }));
|
|
410
|
+
try {
|
|
411
|
+
const result = await testRawToken(token);
|
|
412
|
+
setRawTokenTests((prev) => new Map(prev).set(key, { loading: false, ...result }));
|
|
413
|
+
} catch (e) {
|
|
414
|
+
setRawTokenTests((prev) => new Map(prev).set(key, { loading: false, status: "error", error: (e as Error).message }));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function runTokenTest(id: string) {
|
|
419
|
+
setTokenTestResults((prev) => new Map(prev).set(id, { loading: true }));
|
|
420
|
+
try {
|
|
421
|
+
const result = await testAccountToken(id, tokenTestRefresh);
|
|
422
|
+
setTokenTestResults((prev) => new Map(prev).set(id, { loading: false, result }));
|
|
423
|
+
} catch (e) {
|
|
424
|
+
setTokenTestResults((prev) => new Map(prev).set(id, { loading: false, error: (e as Error).message }));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function runAllTokenTests() {
|
|
429
|
+
const oauthAccounts = accounts.filter((a) => a.expiresAt !== null);
|
|
430
|
+
for (const acc of oauthAccounts) {
|
|
431
|
+
runTokenTest(acc.id);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
378
435
|
const tokenHint = newToken.trim() ? detectTokenType(newToken.trim()) : "";
|
|
379
436
|
|
|
380
437
|
return (
|
|
@@ -442,7 +499,7 @@ export function AccountsSettingsSection() {
|
|
|
442
499
|
})}
|
|
443
500
|
</div>
|
|
444
501
|
|
|
445
|
-
<div className="grid grid-cols-3
|
|
502
|
+
<div className={`grid gap-1.5 ${import.meta.env.DEV ? "grid-cols-4" : "grid-cols-3"}`}>
|
|
446
503
|
<Button size="sm" className="h-8 text-xs cursor-pointer" onClick={() => setShowAddDialog(true)}>
|
|
447
504
|
+ Add
|
|
448
505
|
</Button>
|
|
@@ -452,6 +509,11 @@ export function AccountsSettingsSection() {
|
|
|
452
509
|
<Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer" onClick={() => openImportDialog()}>
|
|
453
510
|
<Upload className="size-3.5 mr-1" /> Import
|
|
454
511
|
</Button>
|
|
512
|
+
{import.meta.env.DEV && (
|
|
513
|
+
<Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer" onClick={() => { setTokenTestResults(new Map()); setTokenTestRefresh(false); setExportRounds([]); setRawTokenTests(new Map()); setExportSimIncludeRefresh(false); setShowTokenTest(true); }}>
|
|
514
|
+
<FlaskConical className="size-3.5 mr-1" /> Test
|
|
515
|
+
</Button>
|
|
516
|
+
)}
|
|
455
517
|
</div>
|
|
456
518
|
</div>
|
|
457
519
|
|
|
@@ -715,6 +777,19 @@ export function AccountsSettingsSection() {
|
|
|
715
777
|
Include refresh tokens (full transfer)
|
|
716
778
|
</label>
|
|
717
779
|
</div>
|
|
780
|
+
{/* Refresh before export toggle */}
|
|
781
|
+
<div className="flex items-center gap-2">
|
|
782
|
+
<input
|
|
783
|
+
type="checkbox"
|
|
784
|
+
id="export-refresh-before"
|
|
785
|
+
checked={exportRefreshBefore}
|
|
786
|
+
onChange={(e) => setExportRefreshBefore(e.target.checked)}
|
|
787
|
+
className="size-3.5 accent-primary cursor-pointer"
|
|
788
|
+
/>
|
|
789
|
+
<label htmlFor="export-refresh-before" className="text-[11px] cursor-pointer">
|
|
790
|
+
Refresh tokens before export
|
|
791
|
+
</label>
|
|
792
|
+
</div>
|
|
718
793
|
{exportFullTransfer ? (
|
|
719
794
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-2.5 space-y-1">
|
|
720
795
|
<p className="text-[10px] font-medium text-red-600">Full transfer — source accounts will expire</p>
|
|
@@ -722,11 +797,18 @@ export function AccountsSettingsSection() {
|
|
|
722
797
|
Refresh tokens are included. Once the target machine refreshes, <strong>accounts on this machine will only work for ~1h</strong> then become temporary. Use this only to move accounts to another machine.
|
|
723
798
|
</p>
|
|
724
799
|
</div>
|
|
725
|
-
) : (
|
|
800
|
+
) : exportRefreshBefore ? (
|
|
726
801
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5 space-y-1">
|
|
727
|
-
<p className="text-[10px] font-medium text-amber-600">
|
|
802
|
+
<p className="text-[10px] font-medium text-amber-600">Refresh before export — invalidates previous shares</p>
|
|
803
|
+
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
|
804
|
+
Tokens will be refreshed to maximize validity (~1h). But <strong>any previously shared tokens will be invalidated</strong> because Anthropic only allows 1 active access token per account.
|
|
805
|
+
</p>
|
|
806
|
+
</div>
|
|
807
|
+
) : (
|
|
808
|
+
<div className="rounded-md border border-green-500/30 bg-green-500/5 p-2.5 space-y-1">
|
|
809
|
+
<p className="text-[10px] font-medium text-green-600">Share current token (default, safe)</p>
|
|
728
810
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
|
729
|
-
|
|
811
|
+
Exports the current access token as-is. Host and target share the same token. No invalidation. Token validity = remaining time until next auto-refresh.
|
|
730
812
|
</p>
|
|
731
813
|
</div>
|
|
732
814
|
)}
|
|
@@ -776,6 +858,173 @@ export function AccountsSettingsSection() {
|
|
|
776
858
|
</DialogContent>
|
|
777
859
|
</Dialog>
|
|
778
860
|
|
|
861
|
+
{/* Token test dialog */}
|
|
862
|
+
<Dialog open={showTokenTest} onOpenChange={(v) => { if (!v) setShowTokenTest(false); }}>
|
|
863
|
+
<DialogContent className="sm:max-w-xl max-h-[85vh] flex flex-col">
|
|
864
|
+
<DialogHeader>
|
|
865
|
+
<DialogTitle className="text-sm flex items-center gap-1.5"><FlaskConical className="size-3.5" /> Token Test</DialogTitle>
|
|
866
|
+
<DialogDescription className="text-xs">Test tokens & simulate export to compare token validity.</DialogDescription>
|
|
867
|
+
</DialogHeader>
|
|
868
|
+
<div className="space-y-3 overflow-y-auto flex-1 pr-1">
|
|
869
|
+
{/* Section 1: Quick test current DB tokens */}
|
|
870
|
+
<div className="space-y-2">
|
|
871
|
+
<div className="flex items-center justify-between">
|
|
872
|
+
<p className="text-[11px] font-medium">Current Tokens (DB)</p>
|
|
873
|
+
<Button size="sm" variant="outline" className="h-6 text-[10px] cursor-pointer" onClick={runAllTokenTests}>
|
|
874
|
+
Test All
|
|
875
|
+
</Button>
|
|
876
|
+
</div>
|
|
877
|
+
<div className="space-y-1.5">
|
|
878
|
+
{accounts.map((acc) => {
|
|
879
|
+
const entry = tokenTestResults.get(acc.id);
|
|
880
|
+
return (
|
|
881
|
+
<div key={acc.id} className="p-2 rounded border bg-card space-y-1">
|
|
882
|
+
<div className="flex items-center justify-between gap-2">
|
|
883
|
+
<div className="min-w-0 flex-1">
|
|
884
|
+
<span className="text-[11px] font-medium truncate block">{acc.label ?? acc.email ?? acc.id.slice(0, 8)}</span>
|
|
885
|
+
{acc.expiresAt && (
|
|
886
|
+
<span className="text-[10px] text-muted-foreground">
|
|
887
|
+
{acc.expiresAt > Math.floor(Date.now() / 1000)
|
|
888
|
+
? `${Math.floor((acc.expiresAt - Math.floor(Date.now() / 1000)) / 60)}m left`
|
|
889
|
+
: `expired ${Math.floor((Math.floor(Date.now() / 1000) - acc.expiresAt) / 60)}m ago`}
|
|
890
|
+
</span>
|
|
891
|
+
)}
|
|
892
|
+
</div>
|
|
893
|
+
<Button size="sm" variant="outline" className="h-6 text-[10px] cursor-pointer shrink-0" disabled={entry?.loading} onClick={() => runTokenTest(acc.id)}>
|
|
894
|
+
{entry?.loading ? <Loader2 className="size-3 animate-spin" /> : "Test"}
|
|
895
|
+
</Button>
|
|
896
|
+
</div>
|
|
897
|
+
{entry && !entry.loading && (
|
|
898
|
+
<div className="text-[10px] pl-1 border-l-2 border-muted ml-1">
|
|
899
|
+
{entry.error && <p className="text-red-500">{entry.error}</p>}
|
|
900
|
+
{entry.result && (
|
|
901
|
+
<span className={entry.result.accessToken.status.startsWith("valid") ? "text-green-600" : "text-red-500"}>
|
|
902
|
+
{entry.result.accessToken.status} {entry.result.accessToken.code && `(${entry.result.accessToken.code})`}
|
|
903
|
+
</span>
|
|
904
|
+
)}
|
|
905
|
+
</div>
|
|
906
|
+
)}
|
|
907
|
+
</div>
|
|
908
|
+
);
|
|
909
|
+
})}
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
|
|
913
|
+
{/* Section 2: Export simulation */}
|
|
914
|
+
<div className="border-t pt-3 space-y-2">
|
|
915
|
+
<div className="flex items-center justify-between">
|
|
916
|
+
<p className="text-[11px] font-medium">Simulate Export</p>
|
|
917
|
+
{exportRounds.length > 0 && (
|
|
918
|
+
<span className="text-[10px] text-muted-foreground">{exportRounds.length} round(s)</span>
|
|
919
|
+
)}
|
|
920
|
+
</div>
|
|
921
|
+
<p className="text-[10px] text-muted-foreground">
|
|
922
|
+
Each "Run Export" appends a new round. All tokens from every round are kept for comparison.
|
|
923
|
+
</p>
|
|
924
|
+
<div className="flex items-center gap-3">
|
|
925
|
+
<div className="flex items-center gap-1.5">
|
|
926
|
+
<input
|
|
927
|
+
type="checkbox"
|
|
928
|
+
id="sim-include-refresh"
|
|
929
|
+
checked={exportSimIncludeRefresh}
|
|
930
|
+
onChange={(e) => setExportSimIncludeRefresh(e.target.checked)}
|
|
931
|
+
className="size-3 accent-primary cursor-pointer"
|
|
932
|
+
/>
|
|
933
|
+
<label htmlFor="sim-include-refresh" className="text-[10px] cursor-pointer">Include refresh tokens</label>
|
|
934
|
+
</div>
|
|
935
|
+
<Button size="sm" className="h-7 text-[11px] cursor-pointer" disabled={exportSimLoading} onClick={simulateExport}>
|
|
936
|
+
{exportSimLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Exporting...</> : `Run Export #${exportRounds.length + 1}`}
|
|
937
|
+
</Button>
|
|
938
|
+
</div>
|
|
939
|
+
|
|
940
|
+
{exportRounds.length > 0 && (
|
|
941
|
+
<div className="space-y-3">
|
|
942
|
+
{/* Test All button across all rounds */}
|
|
943
|
+
<Button
|
|
944
|
+
size="sm"
|
|
945
|
+
variant="outline"
|
|
946
|
+
className="w-full h-7 text-[10px] cursor-pointer"
|
|
947
|
+
onClick={() => {
|
|
948
|
+
for (const round of exportRounds) {
|
|
949
|
+
for (const item of round.items) {
|
|
950
|
+
if (item.preExportTokenFull) testRawTokenClick(`r${round.round}-pre-${item.id}`, item.preExportTokenFull);
|
|
951
|
+
if (item.exportedTokenFull) testRawTokenClick(`r${round.round}-exp-${item.id}`, item.exportedTokenFull);
|
|
952
|
+
if (item.postExportTokenFull) testRawTokenClick(`r${round.round}-post-${item.id}`, item.postExportTokenFull);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}}
|
|
956
|
+
>
|
|
957
|
+
Test All Tokens ({exportRounds.reduce((n, r) => n + r.items.length * 3, 0)} tokens)
|
|
958
|
+
</Button>
|
|
959
|
+
|
|
960
|
+
{/* Render each round */}
|
|
961
|
+
{exportRounds.map((round) => (
|
|
962
|
+
<div key={round.round} className="space-y-1.5">
|
|
963
|
+
<div className="flex items-center gap-2">
|
|
964
|
+
<p className="text-[11px] font-medium text-primary">Round #{round.round}</p>
|
|
965
|
+
<span className="text-[9px] text-muted-foreground">{round.time}</span>
|
|
966
|
+
<Badge variant={round.includeRefresh ? "destructive" : "secondary"} className="text-[8px] px-1 py-0">
|
|
967
|
+
{round.includeRefresh ? "with refresh" : "access only"}
|
|
968
|
+
</Badge>
|
|
969
|
+
</div>
|
|
970
|
+
{round.items.map((item) => (
|
|
971
|
+
<div key={`r${round.round}-${item.id}`} className="p-2 rounded-lg border bg-card space-y-1.5">
|
|
972
|
+
<div className="flex items-center gap-2">
|
|
973
|
+
<span className="text-[10px] font-medium">{item.label ?? item.email ?? item.id.slice(0, 8)}</span>
|
|
974
|
+
{item.tokenChanged && <Badge variant="secondary" className="text-[8px] px-1 py-0">DB token changed</Badge>}
|
|
975
|
+
</div>
|
|
976
|
+
{[
|
|
977
|
+
{ key: `r${round.round}-pre-${item.id}`, label: "Pre-export", token: item.preExportTokenFull, preview: item.preExportToken, expires: item.preExportExpires },
|
|
978
|
+
{ key: `r${round.round}-exp-${item.id}`, label: "Exported", token: item.exportedTokenFull, preview: item.exportedToken, expires: item.exportedExpires },
|
|
979
|
+
{ key: `r${round.round}-post-${item.id}`, label: "Post-export", token: item.postExportTokenFull, preview: item.postExportToken, expires: item.postExportExpires },
|
|
980
|
+
].map((row) => {
|
|
981
|
+
const test = rawTokenTests.get(row.key);
|
|
982
|
+
return (
|
|
983
|
+
<div key={row.key} className="flex items-center gap-1.5 text-[10px]">
|
|
984
|
+
<span className="w-20 shrink-0 text-muted-foreground text-[9px]">{row.label}</span>
|
|
985
|
+
<code className="flex-1 truncate font-mono text-[9px] bg-muted px-1 rounded">{row.preview ?? "N/A"}</code>
|
|
986
|
+
{row.expires && (
|
|
987
|
+
<span className="text-[9px] text-muted-foreground shrink-0">
|
|
988
|
+
{row.expires > Math.floor(Date.now() / 1000)
|
|
989
|
+
? `${Math.floor((row.expires - Math.floor(Date.now() / 1000)) / 60)}m`
|
|
990
|
+
: `exp`}
|
|
991
|
+
</span>
|
|
992
|
+
)}
|
|
993
|
+
{row.token ? (
|
|
994
|
+
<Button
|
|
995
|
+
size="sm"
|
|
996
|
+
variant="outline"
|
|
997
|
+
className="h-5 text-[9px] px-1.5 cursor-pointer shrink-0"
|
|
998
|
+
disabled={test?.loading}
|
|
999
|
+
onClick={() => testRawTokenClick(row.key, row.token!)}
|
|
1000
|
+
>
|
|
1001
|
+
{test?.loading ? <Loader2 className="size-2.5 animate-spin" /> : "Test"}
|
|
1002
|
+
</Button>
|
|
1003
|
+
) : <span className="text-[9px] text-muted-foreground">-</span>}
|
|
1004
|
+
{test && !test.loading && (
|
|
1005
|
+
<span className={`text-[9px] font-medium shrink-0 ${test.status?.startsWith("valid") ? "text-green-600" : "text-red-500"}`}>
|
|
1006
|
+
{test.status}
|
|
1007
|
+
</span>
|
|
1008
|
+
)}
|
|
1009
|
+
</div>
|
|
1010
|
+
);
|
|
1011
|
+
})}
|
|
1012
|
+
</div>
|
|
1013
|
+
))}
|
|
1014
|
+
</div>
|
|
1015
|
+
))}
|
|
1016
|
+
</div>
|
|
1017
|
+
)}
|
|
1018
|
+
</div>
|
|
1019
|
+
</div>
|
|
1020
|
+
<DialogFooter>
|
|
1021
|
+
<Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={() => setShowTokenTest(false)}>
|
|
1022
|
+
Close
|
|
1023
|
+
</Button>
|
|
1024
|
+
</DialogFooter>
|
|
1025
|
+
</DialogContent>
|
|
1026
|
+
</Dialog>
|
|
1027
|
+
|
|
779
1028
|
{/* Import dialog — paste/file data + password */}
|
|
780
1029
|
<Dialog open={showImportDialog} onOpenChange={(v) => { if (!v) setShowImportDialog(false); }}>
|
|
781
1030
|
<DialogContent className="sm:max-w-md">
|
|
@@ -117,6 +117,27 @@ export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
|
|
|
117
117
|
/>
|
|
118
118
|
</div>
|
|
119
119
|
|
|
120
|
+
<div className={fieldGap}>
|
|
121
|
+
<Label htmlFor="ai-api-key" className={compact ? labelSize : undefined}>API Key / Token</Label>
|
|
122
|
+
<Input
|
|
123
|
+
key={`apikey-${revision}`}
|
|
124
|
+
id="ai-api-key"
|
|
125
|
+
type="password"
|
|
126
|
+
defaultValue={config?.api_key ?? ""}
|
|
127
|
+
placeholder="sk-ant-... (optional, overrides accounts)"
|
|
128
|
+
className={compact ? "h-7 text-[11px] font-mono" : "font-mono"}
|
|
129
|
+
onBlur={(e) => {
|
|
130
|
+
const val = e.target.value.trim();
|
|
131
|
+
// Don't save if it's the masked value
|
|
132
|
+
if (val.startsWith("••••")) return;
|
|
133
|
+
handleSave("api_key", val || undefined);
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
<p className={`${compact ? "text-[9px]" : "text-[11px]"} text-muted-foreground`}>
|
|
137
|
+
Direct API key or OAuth token. Leave empty to use connected accounts.
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
120
141
|
<div className={fieldGap}>
|
|
121
142
|
<Label htmlFor="ai-effort" className={compact ? labelSize : undefined}>Effort</Label>
|
|
122
143
|
<Select
|