@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CLAUDE.md +18 -0
  3. package/dist/web/assets/chat-tab-B0UcLXFA.js +7 -0
  4. package/dist/web/assets/{code-editor-Gf5j24XD.js → code-editor-Bb-RxKRW.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CavLUDcg.js → database-viewer-B4pr_bwC.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-DCtD7LAK.js → diff-viewer-DuHuqbG4.js} +1 -1
  7. package/dist/web/assets/{git-graph-ihMFS2VR.js → git-graph-BkTGWVMA.js} +1 -1
  8. package/dist/web/assets/index-BCibi3mV.css +2 -0
  9. package/dist/web/assets/index-BWVej31S.js +28 -0
  10. package/dist/web/assets/keybindings-store-DoOYThSa.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BQoBZBCF.js → markdown-renderer-CyObkWZ-.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-SV4HmMM_.js → postgres-viewer-CHVUVt7c.js} +1 -1
  13. package/dist/web/assets/{settings-tab-omvX466u.js → settings-tab-C1Uj7t80.js} +1 -1
  14. package/dist/web/assets/{sqlite-viewer-CZz6cYBU.js → sqlite-viewer-D1ohxjF9.js} +1 -1
  15. package/dist/web/assets/{switch-PAf5UhcN.js → switch-UODDpwuO.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-C-fMaKoC.js → terminal-tab-DGzY_K3A.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/dist/web/sw.js +1 -1
  19. package/docs/design-guidelines.md +79 -0
  20. package/docs/project-roadmap.md +121 -397
  21. package/package.json +1 -1
  22. package/src/cli/commands/restart.ts +59 -10
  23. package/src/lib/account-crypto.ts +52 -1
  24. package/src/server/routes/accounts.ts +18 -9
  25. package/src/services/account.service.ts +46 -9
  26. package/src/web/components/chat/usage-badge.tsx +1 -1
  27. package/src/web/components/settings/accounts-settings-section.tsx +237 -165
  28. package/src/web/lib/api-settings.ts +4 -0
  29. package/dist/web/assets/chat-tab-RhAZhVvp.js +0 -7
  30. package/dist/web/assets/index-DVuyQcnI.css +0 -2
  31. package/dist/web/assets/index-bndwgasB.js +0 -28
  32. 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, ClipboardPaste, X, MoreHorizontal, Download, Upload } from "lucide-react";
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
- const [showPasteDialog, setShowPasteDialog] = useState(false);
145
- const [pasteText, setPasteText] = useState("");
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
- async function handleExport() {
258
- try {
259
- const headers: HeadersInit = {};
260
- const token = getAuthToken();
261
- if (token) headers["Authorization"] = `Bearer ${token}`;
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
- async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
277
- const file = e.target.files?.[0];
278
- if (!file) return;
279
- try {
280
- const text = await file.text();
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 handleExportClipboard() {
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", { headers });
301
- if (!res.ok) throw new Error(`Export failed: ${res.status}`);
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
- try {
304
- await navigator.clipboard.writeText(text);
305
- showMessage({ type: "success", text: "Accounts copied to clipboard!" });
306
- } catch {
307
- // Clipboard API not available (mobile Safari) — fallback to file download
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: "Clipboard unavailable — downloaded as file." });
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 handleImportClipboard() {
322
- let text: string;
326
+ async function doImport() {
327
+ if (!importData.trim() || !importPassword) return;
328
+ setImporting(true);
329
+ setImportError(null);
323
330
  try {
324
- text = await navigator.clipboard.readText();
325
- } catch {
326
- // Clipboard API not available (mobile Safari) — show paste dialog
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
- showMessage({ type: "error", text: (e as Error).message || "Import failed" });
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
- <div key={acc.id} className={`flex items-center justify-between p-2.5 rounded-lg border bg-card gap-2 ${acc.id === activeAccountId ? "ring-1 ring-primary/50 border-primary/30" : ""}`}>
372
- <div className="flex-1 min-w-0">
373
- <div className="flex items-center gap-1.5 flex-wrap">
374
- <span className="text-xs font-medium truncate">{acc.label ?? acc.email ?? acc.id.slice(0, 8)}</span>
375
- {statusBadge(acc)}
376
- {acc.id === activeAccountId && <Badge variant="outline" className="text-[9px] px-1 py-0 border-primary/40 text-primary">In use</Badge>}
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
- <div className="text-[11px] text-muted-foreground mt-0.5 flex gap-2 flex-wrap">
379
- {acc.email && acc.label && <span>{acc.email}</span>}
380
- {acc.profileData?.organization?.name && (
381
- <span>{subscriptionLabel(acc.profileData)}</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
- <div className="flex items-center gap-1 shrink-0">
389
- {acc.profileData && (
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="flex gap-1.5">
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 Account
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
- {/* Paste dialog — fallback when Clipboard API is unavailable (mobile Safari) */}
625
- <Dialog open={showPasteDialog} onOpenChange={(v) => { if (!v) { setShowPasteDialog(false); setPasteText(""); } }}>
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">Paste Account Data</DialogTitle>
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
- <textarea
634
- value={pasteText}
635
- onChange={(e) => setPasteText(e.target.value)}
636
- placeholder='{"accounts": [...]}'
637
- rows={6}
638
- 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"
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 variant="outline" size="sm" className="text-xs cursor-pointer" onClick={() => { setShowPasteDialog(false); setPasteText(""); }}>
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
- className="text-xs cursor-pointer"
647
- disabled={!pasteText.trim()}
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;