@hienlh/ppm 0.7.26 → 0.7.28
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 +21 -0
- package/CLAUDE.md +18 -0
- package/dist/web/assets/chat-tab-B0UcLXFA.js +7 -0
- package/dist/web/assets/{code-editor-Gf5j24XD.js → code-editor-Bb-RxKRW.js} +1 -1
- package/dist/web/assets/{database-viewer-CavLUDcg.js → database-viewer-B4pr_bwC.js} +1 -1
- package/dist/web/assets/{diff-viewer-DCtD7LAK.js → diff-viewer-DuHuqbG4.js} +1 -1
- package/dist/web/assets/{git-graph-ihMFS2VR.js → git-graph-BkTGWVMA.js} +1 -1
- package/dist/web/assets/index-BCibi3mV.css +2 -0
- package/dist/web/assets/index-BWVej31S.js +28 -0
- package/dist/web/assets/keybindings-store-DoOYThSa.js +1 -0
- package/dist/web/assets/{markdown-renderer-BQoBZBCF.js → markdown-renderer-CyObkWZ-.js} +1 -1
- package/dist/web/assets/{postgres-viewer-SV4HmMM_.js → postgres-viewer-CHVUVt7c.js} +1 -1
- package/dist/web/assets/{settings-tab-omvX466u.js → settings-tab-C1Uj7t80.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CZz6cYBU.js → sqlite-viewer-D1ohxjF9.js} +1 -1
- package/dist/web/assets/{switch-PAf5UhcN.js → switch-UODDpwuO.js} +1 -1
- package/dist/web/assets/{terminal-tab-C-fMaKoC.js → terminal-tab-DGzY_K3A.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/docs/design-guidelines.md +79 -0
- package/docs/project-roadmap.md +121 -397
- package/package.json +1 -1
- package/src/cli/commands/restart.ts +59 -10
- package/src/lib/account-crypto.ts +52 -1
- package/src/server/routes/accounts.ts +18 -9
- package/src/services/account.service.ts +46 -9
- package/src/web/components/chat/usage-badge.tsx +1 -1
- package/src/web/components/settings/accounts-settings-section.tsx +237 -165
- package/src/web/lib/api-settings.ts +4 -0
- package/dist/web/assets/chat-tab-RhAZhVvp.js +0 -7
- package/dist/web/assets/index-DVuyQcnI.css +0 -2
- package/dist/web/assets/index-bndwgasB.js +0 -28
- package/dist/web/assets/keybindings-store-C69-mCE5.js +0 -1
|
@@ -6,8 +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,
|
|
10
|
-
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
9
|
+
import { Eye, Loader2, Copy, X, Download, Upload, Lock } from "lucide-react";
|
|
11
10
|
import { getAuthToken } from "../../lib/api-client";
|
|
12
11
|
import {
|
|
13
12
|
getAccounts,
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
getAccountSettings,
|
|
21
20
|
updateAccountSettings,
|
|
22
21
|
getAllAccountUsages,
|
|
22
|
+
importAccounts,
|
|
23
23
|
type AccountInfo,
|
|
24
24
|
type AccountSettings,
|
|
25
25
|
type AccountUsageEntry,
|
|
@@ -141,8 +141,19 @@ export function AccountsSettingsSection() {
|
|
|
141
141
|
const [oauthCode, setOauthCode] = useState("");
|
|
142
142
|
const [oauthLoading, setOauthLoading] = useState(false);
|
|
143
143
|
const [oauthStep, setOauthStep] = useState<"idle" | "waiting">("idle");
|
|
144
|
-
|
|
145
|
-
const [
|
|
144
|
+
// Export dialog
|
|
145
|
+
const [showExportDialog, setShowExportDialog] = useState(false);
|
|
146
|
+
const [exportPassword, setExportPassword] = useState("");
|
|
147
|
+
const [exportConfirm, setExportConfirm] = useState("");
|
|
148
|
+
const [exportSelected, setExportSelected] = useState<Set<string>>(new Set());
|
|
149
|
+
const [exporting, setExporting] = useState(false);
|
|
150
|
+
|
|
151
|
+
// Import dialog
|
|
152
|
+
const [showImportDialog, setShowImportDialog] = useState(false);
|
|
153
|
+
const [importPassword, setImportPassword] = useState("");
|
|
154
|
+
const [importData, setImportData] = useState("");
|
|
155
|
+
const [importing, setImporting] = useState(false);
|
|
156
|
+
const [importError, setImportError] = useState<string | null>(null);
|
|
146
157
|
|
|
147
158
|
useEffect(() => {
|
|
148
159
|
refresh();
|
|
@@ -254,99 +265,80 @@ export function AccountsSettingsSection() {
|
|
|
254
265
|
return <Badge variant="destructive" className="text-[10px] px-1.5 py-0">Cooldown{cd ? ` (${cd})` : ""}</Badge>;
|
|
255
266
|
}
|
|
256
267
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const res = await fetch("/api/accounts/export", { headers });
|
|
263
|
-
if (!res.ok) throw new Error(`Export failed: ${res.status}`);
|
|
264
|
-
const blob = await res.blob();
|
|
265
|
-
const url = URL.createObjectURL(blob);
|
|
266
|
-
const a = document.createElement("a");
|
|
267
|
-
a.href = url;
|
|
268
|
-
a.download = "ppm-accounts-backup.json";
|
|
269
|
-
a.click();
|
|
270
|
-
URL.revokeObjectURL(url);
|
|
271
|
-
} catch (e) {
|
|
272
|
-
showMessage({ type: "error", text: (e as Error).message });
|
|
273
|
-
}
|
|
268
|
+
function openExportDialog() {
|
|
269
|
+
setExportPassword("");
|
|
270
|
+
setExportConfirm("");
|
|
271
|
+
setExportSelected(new Set(accounts.map((a) => a.id)));
|
|
272
|
+
setShowExportDialog(true);
|
|
274
273
|
}
|
|
275
274
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const headers: HeadersInit = { "Content-Type": "application/json" };
|
|
282
|
-
const token = getAuthToken();
|
|
283
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
284
|
-
const res = await fetch("/api/accounts/import", { method: "POST", headers, body: text });
|
|
285
|
-
const json = await res.json() as { ok: boolean; data?: { imported: number }; error?: string };
|
|
286
|
-
if (!json.ok) throw new Error(json.error ?? "Import failed");
|
|
287
|
-
showMessage({ type: "success", text: `Imported ${json.data?.imported ?? 0} account(s).` });
|
|
288
|
-
refresh();
|
|
289
|
-
} catch (e) {
|
|
290
|
-
showMessage({ type: "error", text: (e as Error).message || "Import failed" });
|
|
291
|
-
}
|
|
292
|
-
e.target.value = "";
|
|
275
|
+
function openImportDialog(prefillData?: string) {
|
|
276
|
+
setImportPassword("");
|
|
277
|
+
setImportData(prefillData ?? "");
|
|
278
|
+
setImportError(null);
|
|
279
|
+
setShowImportDialog(true);
|
|
293
280
|
}
|
|
294
281
|
|
|
295
|
-
async function
|
|
282
|
+
async function doExport(toClipboard: boolean) {
|
|
283
|
+
if (exportPassword.length < 4) return;
|
|
284
|
+
if (exportPassword !== exportConfirm) return;
|
|
285
|
+
setExporting(true);
|
|
296
286
|
try {
|
|
297
|
-
const headers: HeadersInit = {};
|
|
287
|
+
const headers: HeadersInit = { "Content-Type": "application/json" };
|
|
298
288
|
const token = getAuthToken();
|
|
299
289
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
300
|
-
const res = await fetch("/api/accounts/export", {
|
|
301
|
-
|
|
290
|
+
const res = await fetch("/api/accounts/export", {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers,
|
|
293
|
+
body: JSON.stringify({ password: exportPassword, accountIds: [...exportSelected] }),
|
|
294
|
+
});
|
|
295
|
+
if (!res.ok) { const j = await res.json() as any; throw new Error(j.error ?? `Export failed: ${res.status}`); }
|
|
302
296
|
const text = await res.text();
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
297
|
+
if (toClipboard) {
|
|
298
|
+
try {
|
|
299
|
+
await navigator.clipboard.writeText(text);
|
|
300
|
+
showMessage({ type: "success", text: "Backup copied to clipboard!" });
|
|
301
|
+
} catch {
|
|
302
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
303
|
+
const a = document.createElement("a");
|
|
304
|
+
a.href = URL.createObjectURL(blob);
|
|
305
|
+
a.download = "ppm-accounts-backup.json";
|
|
306
|
+
a.click();
|
|
307
|
+
URL.revokeObjectURL(a.href);
|
|
308
|
+
showMessage({ type: "success", text: "Clipboard unavailable — downloaded as file." });
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
308
311
|
const blob = new Blob([text], { type: "application/json" });
|
|
309
312
|
const a = document.createElement("a");
|
|
310
313
|
a.href = URL.createObjectURL(blob);
|
|
311
|
-
a.download = "ppm-accounts.json";
|
|
314
|
+
a.download = "ppm-accounts-backup.json";
|
|
312
315
|
a.click();
|
|
313
316
|
URL.revokeObjectURL(a.href);
|
|
314
|
-
showMessage({ type: "success", text: "
|
|
317
|
+
showMessage({ type: "success", text: "Backup downloaded." });
|
|
315
318
|
}
|
|
319
|
+
setShowExportDialog(false);
|
|
316
320
|
} catch (e) {
|
|
317
321
|
showMessage({ type: "error", text: (e as Error).message });
|
|
318
322
|
}
|
|
323
|
+
setExporting(false);
|
|
319
324
|
}
|
|
320
325
|
|
|
321
|
-
async function
|
|
322
|
-
|
|
326
|
+
async function doImport() {
|
|
327
|
+
if (!importData.trim() || !importPassword) return;
|
|
328
|
+
setImporting(true);
|
|
329
|
+
setImportError(null);
|
|
323
330
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
setShowPasteDialog(true);
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
await doImportText(text, "clipboard");
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async function doImportText(text: string, source: string) {
|
|
334
|
-
try {
|
|
335
|
-
if (!text.trim()) throw new Error("Input is empty");
|
|
336
|
-
JSON.parse(text); // validate JSON
|
|
337
|
-
const headers: HeadersInit = { "Content-Type": "application/json" };
|
|
338
|
-
const token = getAuthToken();
|
|
339
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
340
|
-
const res = await fetch("/api/accounts/import", { method: "POST", headers, body: text });
|
|
341
|
-
const json = await res.json() as { ok: boolean; data?: { imported: number }; error?: string };
|
|
342
|
-
if (!json.ok) throw new Error(json.error ?? "Import failed");
|
|
343
|
-
showMessage({ type: "success", text: `Imported ${json.data?.imported ?? 0} account(s) from ${source}.` });
|
|
331
|
+
const result = await importAccounts({ data: importData.trim(), password: importPassword });
|
|
332
|
+
showMessage({ type: "success", text: `Imported ${result.imported} account(s).` });
|
|
333
|
+
setShowImportDialog(false);
|
|
344
334
|
refresh();
|
|
345
335
|
} catch (e) {
|
|
346
|
-
|
|
336
|
+
setImportError((e as Error).message || "Import failed");
|
|
347
337
|
}
|
|
338
|
+
setImporting(false);
|
|
348
339
|
}
|
|
349
340
|
|
|
341
|
+
|
|
350
342
|
const tokenHint = newToken.trim() ? detectTokenType(newToken.trim()) : "";
|
|
351
343
|
|
|
352
344
|
return (
|
|
@@ -367,84 +359,57 @@ export function AccountsSettingsSection() {
|
|
|
367
359
|
{!loading && accounts.length === 0 && (
|
|
368
360
|
<p className="text-[11px] text-muted-foreground">No accounts connected.</p>
|
|
369
361
|
)}
|
|
370
|
-
{accounts.map((acc) =>
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
362
|
+
{accounts.map((acc) => {
|
|
363
|
+
const usage = usageMap.get(acc.id);
|
|
364
|
+
return (
|
|
365
|
+
<div key={acc.id} className="p-2.5 rounded-lg border bg-card space-y-1.5">
|
|
366
|
+
{/* Row 1: name + badges + actions */}
|
|
367
|
+
<div className="flex items-center justify-between gap-1">
|
|
368
|
+
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
|
369
|
+
<span className="text-xs font-medium truncate">{acc.label ?? acc.email ?? acc.id.slice(0, 8)}</span>
|
|
370
|
+
{statusBadge(acc)}
|
|
371
|
+
</div>
|
|
372
|
+
<div className="flex items-center shrink-0">
|
|
373
|
+
{acc.profileData && (
|
|
374
|
+
<Button size="icon" variant="ghost" className="size-7 cursor-pointer text-muted-foreground hover:text-foreground" onClick={() => setProfileView(acc.profileData)} title="View profile">
|
|
375
|
+
<Eye className="size-3" />
|
|
376
|
+
</Button>
|
|
377
|
+
)}
|
|
378
|
+
<Switch checked={acc.status !== "disabled"} onCheckedChange={() => handleToggle(acc.id, acc.status)} disabled={acc.status === "cooldown"} className="scale-[0.65] cursor-pointer" />
|
|
379
|
+
<Button size="icon" variant="ghost" className="size-7 cursor-pointer text-muted-foreground hover:text-destructive" onClick={() => handleDelete(acc.id, acc.email)} title="Remove">
|
|
380
|
+
<X className="size-3" />
|
|
381
|
+
</Button>
|
|
382
|
+
</div>
|
|
377
383
|
</div>
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
<span>{
|
|
384
|
+
{/* Row 2: meta + usage inline */}
|
|
385
|
+
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
|
|
386
|
+
<div className="flex gap-1.5 items-center min-w-0 truncate">
|
|
387
|
+
{acc.email && acc.label && <span className="truncate">{acc.email}</span>}
|
|
388
|
+
<span>{acc.totalRequests} req{acc.totalRequests !== 1 ? "s" : ""}</span>
|
|
389
|
+
<span>· {formatLastUsed(acc.lastUsedAt)}</span>
|
|
390
|
+
</div>
|
|
391
|
+
{usage && (usage.session || usage.weekly) && (
|
|
392
|
+
<div className="flex gap-2 shrink-0 tabular-nums">
|
|
393
|
+
{usage.session && <span className={miniPctColor(Math.round(usage.session.utilization * 100))}>5h {Math.round(usage.session.utilization * 100)}%</span>}
|
|
394
|
+
{usage.weekly && <span className={miniPctColor(Math.round(usage.weekly.utilization * 100))}>Wk {Math.round(usage.weekly.utilization * 100)}%</span>}
|
|
395
|
+
</div>
|
|
382
396
|
)}
|
|
383
|
-
<span>{acc.totalRequests} reqs</span>
|
|
384
|
-
<span>Last: {formatLastUsed(acc.lastUsedAt)}</span>
|
|
385
397
|
</div>
|
|
386
|
-
{usageMap.get(acc.id) && <CompactUsageBars usage={usageMap.get(acc.id)!} />}
|
|
387
398
|
</div>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
<Button
|
|
391
|
-
size="icon"
|
|
392
|
-
variant="ghost"
|
|
393
|
-
className="size-8 cursor-pointer text-muted-foreground hover:text-foreground"
|
|
394
|
-
onClick={() => setProfileView(acc.profileData)}
|
|
395
|
-
title="View profile"
|
|
396
|
-
>
|
|
397
|
-
<Eye className="size-3.5" />
|
|
398
|
-
</Button>
|
|
399
|
-
)}
|
|
400
|
-
<Switch
|
|
401
|
-
checked={acc.status !== "disabled"}
|
|
402
|
-
onCheckedChange={() => handleToggle(acc.id, acc.status)}
|
|
403
|
-
disabled={acc.status === "cooldown"}
|
|
404
|
-
className="scale-75 cursor-pointer"
|
|
405
|
-
/>
|
|
406
|
-
<Button
|
|
407
|
-
size="icon"
|
|
408
|
-
variant="ghost"
|
|
409
|
-
className="size-8 cursor-pointer text-muted-foreground hover:text-destructive"
|
|
410
|
-
onClick={() => handleDelete(acc.id, acc.email)}
|
|
411
|
-
title="Remove account"
|
|
412
|
-
>
|
|
413
|
-
<X className="size-3.5" />
|
|
414
|
-
</Button>
|
|
415
|
-
</div>
|
|
416
|
-
</div>
|
|
417
|
-
))}
|
|
399
|
+
);
|
|
400
|
+
})}
|
|
418
401
|
</div>
|
|
419
402
|
|
|
420
|
-
<div className="
|
|
403
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
421
404
|
<Button size="sm" className="h-8 text-xs cursor-pointer" onClick={() => setShowAddDialog(true)}>
|
|
422
|
-
+ Add
|
|
405
|
+
+ Add
|
|
406
|
+
</Button>
|
|
407
|
+
<Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer" onClick={() => openExportDialog()}>
|
|
408
|
+
<Download className="size-3.5 mr-1" /> Export
|
|
409
|
+
</Button>
|
|
410
|
+
<Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer" onClick={() => openImportDialog()}>
|
|
411
|
+
<Upload className="size-3.5 mr-1" /> Import
|
|
423
412
|
</Button>
|
|
424
|
-
<DropdownMenu>
|
|
425
|
-
<DropdownMenuTrigger asChild>
|
|
426
|
-
<Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer">
|
|
427
|
-
<MoreHorizontal className="size-3.5 mr-1" /> More
|
|
428
|
-
</Button>
|
|
429
|
-
</DropdownMenuTrigger>
|
|
430
|
-
<DropdownMenuContent align="start">
|
|
431
|
-
<DropdownMenuItem className="text-xs cursor-pointer" onClick={handleExportClipboard}>
|
|
432
|
-
<Copy className="size-3.5 mr-2" /> Copy to clipboard
|
|
433
|
-
</DropdownMenuItem>
|
|
434
|
-
<DropdownMenuItem className="text-xs cursor-pointer" onClick={handleImportClipboard}>
|
|
435
|
-
<ClipboardPaste className="size-3.5 mr-2" /> Paste from clipboard
|
|
436
|
-
</DropdownMenuItem>
|
|
437
|
-
<DropdownMenuItem className="text-xs cursor-pointer" onClick={handleExport}>
|
|
438
|
-
<Download className="size-3.5 mr-2" /> Export as file
|
|
439
|
-
</DropdownMenuItem>
|
|
440
|
-
<DropdownMenuItem className="text-xs cursor-pointer" asChild>
|
|
441
|
-
<label className="flex items-center">
|
|
442
|
-
<Upload className="size-3.5 mr-2" /> Import from file
|
|
443
|
-
<input type="file" accept=".json" className="hidden" onChange={handleImport} />
|
|
444
|
-
</label>
|
|
445
|
-
</DropdownMenuItem>
|
|
446
|
-
</DropdownMenuContent>
|
|
447
|
-
</DropdownMenu>
|
|
448
413
|
</div>
|
|
449
414
|
</div>
|
|
450
415
|
|
|
@@ -621,37 +586,144 @@ export function AccountsSettingsSection() {
|
|
|
621
586
|
</DialogContent>
|
|
622
587
|
</Dialog>
|
|
623
588
|
|
|
624
|
-
{/*
|
|
625
|
-
<Dialog open={
|
|
589
|
+
{/* Export dialog — account selection + password */}
|
|
590
|
+
<Dialog open={showExportDialog} onOpenChange={(v) => { if (!v) setShowExportDialog(false); }}>
|
|
626
591
|
<DialogContent className="sm:max-w-md">
|
|
627
592
|
<DialogHeader>
|
|
628
|
-
<DialogTitle className="text-sm"
|
|
629
|
-
<DialogDescription className="text-xs">
|
|
630
|
-
Paste the exported JSON data below.
|
|
631
|
-
</DialogDescription>
|
|
593
|
+
<DialogTitle className="text-sm flex items-center gap-1.5"><Lock className="size-3.5" /> Export Accounts</DialogTitle>
|
|
594
|
+
<DialogDescription className="text-xs">Select accounts and set a password to protect the backup.</DialogDescription>
|
|
632
595
|
</DialogHeader>
|
|
633
|
-
<
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
596
|
+
<div className="space-y-3">
|
|
597
|
+
{/* Account selection */}
|
|
598
|
+
<div className="space-y-1">
|
|
599
|
+
<div className="flex items-center justify-between mb-1">
|
|
600
|
+
<p className="text-[11px] font-medium text-muted-foreground">Accounts to export</p>
|
|
601
|
+
<button
|
|
602
|
+
className="text-[10px] text-primary hover:underline cursor-pointer"
|
|
603
|
+
onClick={() => setExportSelected(
|
|
604
|
+
exportSelected.size === accounts.length ? new Set() : new Set(accounts.map((a) => a.id))
|
|
605
|
+
)}
|
|
606
|
+
>
|
|
607
|
+
{exportSelected.size === accounts.length ? "Deselect all" : "Select all"}
|
|
608
|
+
</button>
|
|
609
|
+
</div>
|
|
610
|
+
<div className="max-h-36 overflow-y-auto space-y-1 border rounded p-2">
|
|
611
|
+
{accounts.map((acc) => (
|
|
612
|
+
<div key={acc.id} className="flex items-center gap-2">
|
|
613
|
+
<input
|
|
614
|
+
type="checkbox"
|
|
615
|
+
id={`exp-${acc.id}`}
|
|
616
|
+
checked={exportSelected.has(acc.id)}
|
|
617
|
+
onChange={(e) => {
|
|
618
|
+
const s = new Set(exportSelected);
|
|
619
|
+
e.target.checked ? s.add(acc.id) : s.delete(acc.id);
|
|
620
|
+
setExportSelected(s);
|
|
621
|
+
}}
|
|
622
|
+
className="size-3.5 accent-primary cursor-pointer"
|
|
623
|
+
/>
|
|
624
|
+
<label htmlFor={`exp-${acc.id}`} className="text-xs cursor-pointer truncate">
|
|
625
|
+
{acc.label ?? acc.email ?? acc.id.slice(0, 8)}
|
|
626
|
+
{acc.email && acc.label && <span className="text-muted-foreground ml-1 text-[10px]">({acc.email})</span>}
|
|
627
|
+
</label>
|
|
628
|
+
</div>
|
|
629
|
+
))}
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
{/* Password */}
|
|
633
|
+
<div className="space-y-1.5">
|
|
634
|
+
<Label className="text-xs">Password</Label>
|
|
635
|
+
<Input
|
|
636
|
+
type="password"
|
|
637
|
+
placeholder="Min 4 characters"
|
|
638
|
+
value={exportPassword}
|
|
639
|
+
onChange={(e) => setExportPassword(e.target.value)}
|
|
640
|
+
className="text-xs h-8"
|
|
641
|
+
autoComplete="new-password"
|
|
642
|
+
/>
|
|
643
|
+
</div>
|
|
644
|
+
<div className="space-y-1.5">
|
|
645
|
+
<Label className="text-xs">Confirm password</Label>
|
|
646
|
+
<Input
|
|
647
|
+
type="password"
|
|
648
|
+
placeholder="Re-enter password"
|
|
649
|
+
value={exportConfirm}
|
|
650
|
+
onChange={(e) => setExportConfirm(e.target.value)}
|
|
651
|
+
className="text-xs h-8"
|
|
652
|
+
autoComplete="new-password"
|
|
653
|
+
/>
|
|
654
|
+
{exportConfirm && exportPassword !== exportConfirm && (
|
|
655
|
+
<p className="text-[10px] text-red-500">Passwords do not match</p>
|
|
656
|
+
)}
|
|
657
|
+
</div>
|
|
658
|
+
<p className="text-[10px] text-muted-foreground">
|
|
659
|
+
Encrypted with AES-256-GCM + scrypt. Keep the password safe — it cannot be recovered.
|
|
660
|
+
</p>
|
|
661
|
+
</div>
|
|
662
|
+
<DialogFooter className="gap-1.5 flex-col sm:flex-row">
|
|
663
|
+
<Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={() => setShowExportDialog(false)}>
|
|
664
|
+
Cancel
|
|
665
|
+
</Button>
|
|
666
|
+
<Button
|
|
667
|
+
size="sm" variant="outline" className="text-xs h-7 cursor-pointer"
|
|
668
|
+
disabled={exportPassword.length < 4 || exportPassword !== exportConfirm || exportSelected.size === 0 || exporting}
|
|
669
|
+
onClick={() => doExport(true)}
|
|
670
|
+
>
|
|
671
|
+
<Copy className="size-3 mr-1" /> Copy to clipboard
|
|
672
|
+
</Button>
|
|
673
|
+
<Button
|
|
674
|
+
size="sm" className="text-xs h-7 cursor-pointer"
|
|
675
|
+
disabled={exportPassword.length < 4 || exportPassword !== exportConfirm || exportSelected.size === 0 || exporting}
|
|
676
|
+
onClick={() => doExport(false)}
|
|
677
|
+
>
|
|
678
|
+
{exporting ? <><Loader2 className="size-3 animate-spin mr-1" /> Exporting...</> : <><Download className="size-3 mr-1" /> Download</>}
|
|
679
|
+
</Button>
|
|
680
|
+
</DialogFooter>
|
|
681
|
+
</DialogContent>
|
|
682
|
+
</Dialog>
|
|
683
|
+
|
|
684
|
+
{/* Import dialog — paste/file data + password */}
|
|
685
|
+
<Dialog open={showImportDialog} onOpenChange={(v) => { if (!v) setShowImportDialog(false); }}>
|
|
686
|
+
<DialogContent className="sm:max-w-md">
|
|
687
|
+
<DialogHeader>
|
|
688
|
+
<DialogTitle className="text-sm flex items-center gap-1.5"><Lock className="size-3.5" /> Import Accounts</DialogTitle>
|
|
689
|
+
<DialogDescription className="text-xs">Paste or load the backup data, then enter the password used during export.</DialogDescription>
|
|
690
|
+
</DialogHeader>
|
|
691
|
+
<div className="space-y-3">
|
|
692
|
+
<div className="space-y-1.5">
|
|
693
|
+
<Label className="text-xs">Backup data</Label>
|
|
694
|
+
<textarea
|
|
695
|
+
value={importData}
|
|
696
|
+
onChange={(e) => setImportData(e.target.value)}
|
|
697
|
+
placeholder="Paste backup JSON here..."
|
|
698
|
+
rows={4}
|
|
699
|
+
className="w-full text-xs p-2 rounded border border-border bg-background font-mono resize-none focus:outline-none focus:ring-1 focus:ring-primary"
|
|
700
|
+
/>
|
|
701
|
+
</div>
|
|
702
|
+
<div className="space-y-1.5">
|
|
703
|
+
<Label className="text-xs">Password</Label>
|
|
704
|
+
<Input
|
|
705
|
+
type="password"
|
|
706
|
+
placeholder="Password used during export"
|
|
707
|
+
value={importPassword}
|
|
708
|
+
onChange={(e) => setImportPassword(e.target.value)}
|
|
709
|
+
className="text-xs h-8"
|
|
710
|
+
autoComplete="current-password"
|
|
711
|
+
/>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
{importError && (
|
|
715
|
+
<div className="text-[11px] p-2 rounded bg-red-500/10 text-red-600">{importError}</div>
|
|
716
|
+
)}
|
|
640
717
|
<DialogFooter>
|
|
641
|
-
<Button
|
|
718
|
+
<Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={() => setShowImportDialog(false)}>
|
|
642
719
|
Cancel
|
|
643
720
|
</Button>
|
|
644
721
|
<Button
|
|
645
|
-
size="sm"
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
onClick={async () => {
|
|
649
|
-
await doImportText(pasteText, "paste");
|
|
650
|
-
setShowPasteDialog(false);
|
|
651
|
-
setPasteText("");
|
|
652
|
-
}}
|
|
722
|
+
size="sm" className="text-xs h-7 cursor-pointer"
|
|
723
|
+
disabled={!importData.trim() || !importPassword || importing}
|
|
724
|
+
onClick={doImport}
|
|
653
725
|
>
|
|
654
|
-
Import
|
|
726
|
+
{importing ? <><Loader2 className="size-3 animate-spin mr-1" /> Importing...</> : "Import"}
|
|
655
727
|
</Button>
|
|
656
728
|
</DialogFooter>
|
|
657
729
|
</DialogContent>
|
|
@@ -114,6 +114,10 @@ export function getAllAccountUsages(): Promise<AccountUsageEntry[]> {
|
|
|
114
114
|
return api.get<AccountUsageEntry[]>("/api/accounts/usage");
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
export function importAccounts(params: { data: string; password: string }): Promise<{ imported: number }> {
|
|
118
|
+
return api.post<{ imported: number }>("/api/accounts/import", params);
|
|
119
|
+
}
|
|
120
|
+
|
|
117
121
|
export interface AIProviderSettings {
|
|
118
122
|
type?: string;
|
|
119
123
|
api_key_env?: string;
|