dsh-connect-workbuddy 4.0.0 → 4.2.0

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/lib/client.js CHANGED
@@ -70,12 +70,47 @@ window.__ModuleLoader__.load({
70
70
  * splitting them would duplicate the guards and invite them to drift apart.
71
71
  */
72
72
  const WORKBUDDY_POOL_PATH = "/plugins/dsh-connect-workbuddy/pool";
73
+ /**
74
+ * Plugin-owned OAuth sign-in endpoint: one session step per call.
75
+ *
76
+ * `?action=start` asks the upstream for a login state and returns the URL to
77
+ * open; `?action=poll` (+ `loginId` in the body) reports whether the user
78
+ * finished, and on success persists the account into the vault. POST +
79
+ * loopback-only like the card's other mutations: the poll carries the login
80
+ * binding, and the Host-side session state must not be settable by another
81
+ * origin.
82
+ */
83
+ const WORKBUDDY_OAUTH_PATH = "/plugins/dsh-connect-workbuddy/oauth";
73
84
  /** Query parameter selecting which pool batch action a request means. */
74
85
  const WORKBUDDY_POOL_ACTION_PARAM = "action";
86
+ /** How long the card waits between polls while the user is scanning. */
87
+ const OAUTH_POLL_INTERVAL_MS = 2e3;
88
+ /**
89
+ * Plugin-owned account transfer endpoint: the batch export / import of
90
+ * credentials between machines and between tools of the SAME format family.
91
+ *
92
+ * `?action=export` POSTs account ids, answers a JSON ARRAY of credential
93
+ * records; `?action=preview` POSTs file text, answers a token-free listing;
94
+ * `?action=import` POSTs file text plus selected indexes, merges into the
95
+ * vault. POST + loopback-only like every other mutation: the file content is
96
+ * arbitrary user data and export carries token material.
97
+ */
98
+ const WORKBUDDY_TRANSFER_PATH = "/plugins/dsh-connect-workbuddy/transfer";
75
99
  /** Address one region's pool endpoint with an action. */
76
100
  function withWorkBuddyRegionAndAction(path, region, action) {
77
101
  return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_POOL_ACTION_PARAM}=${action}`;
78
102
  }
103
+ /**
104
+ * Address one region's endpoint for ANY route that dispatches on `action`.
105
+ *
106
+ * The OAuth and transfer routes reject a request with no action (400), so
107
+ * their callers must build the URL here rather than with
108
+ * {@link withWorkBuddyRegion} — a request missing the parameter fails the same
109
+ * way whether the omission was a mistake or not.
110
+ */
111
+ function withWorkBuddyRouteAction(path, region, action) {
112
+ return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_POOL_ACTION_PARAM}=${action}`;
113
+ }
79
114
  /** Query parameter naming the region a card request addresses. */
80
115
  const WORKBUDDY_REGION_PARAM = "region";
81
116
  /** Every region, in card tab order. */
@@ -1091,7 +1126,7 @@ window.__ModuleLoader__.load({
1091
1126
  * section implying the feature exists.
1092
1127
  */
1093
1128
  function AccountPool(props) {
1094
- const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount } = props;
1129
+ const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools } = props;
1095
1130
  const [draft, setDraft] = (0, react.useState)(null);
1096
1131
  const [saving, setSaving] = (0, react.useState)(false);
1097
1132
  const [saveError, setSaveError] = (0, react.useState)(void 0);
@@ -1367,6 +1402,98 @@ window.__ModuleLoader__.load({
1367
1402
  region,
1368
1403
  t
1369
1404
  ]);
1405
+ /** The account whose per-row action is in flight; gates every row's buttons. */
1406
+ const [rowBusyAccountId, setRowBusyAccountId] = (0, react.useState)(void 0);
1407
+ /**
1408
+ * Test ONE account from its row.
1409
+ *
1410
+ * Same Host rule as the batch (testOne reuses the batch's probe), the same
1411
+ * log vocabulary, and the same refresh-after — a row result must read exactly
1412
+ * like the batch result it replaces.
1413
+ */
1414
+ const runRowTest = (0, react.useCallback)(async (accountId) => {
1415
+ setRowBusyAccountId(accountId);
1416
+ setActionError(void 0);
1417
+ const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "test") + "&accountId=" + encodeURIComponent(accountId);
1418
+ try {
1419
+ const response = await fetch(url, {
1420
+ method: "POST",
1421
+ headers: { accept: "application/json" },
1422
+ credentials: "same-origin"
1423
+ });
1424
+ const body = await response.json().catch(() => void 0);
1425
+ if (!response.ok) throw new Error(poolFailureText(t, {
1426
+ status: response.status,
1427
+ reason: body?.reason,
1428
+ error: body?.error
1429
+ }));
1430
+ if (!mounted.current) return;
1431
+ const row = body?.rows?.[0];
1432
+ if (row === void 0) {
1433
+ setActionError(t("row.poolLogTestRowMalformed", { accountName: t("row.accountUnnamed") }));
1434
+ return;
1435
+ }
1436
+ const outcome = row.result?.outcome;
1437
+ if (outcome === void 0) appendLog(t("row.poolLogTestRowMalformed", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "warn");
1438
+ else appendLog(t("row.poolLogTestRow", {
1439
+ accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
1440
+ outcome: outcomeText(t, outcome)
1441
+ }), outcomeOk(outcome) ? "ok" : "warn");
1442
+ onRefresh?.();
1443
+ } catch (error) {
1444
+ const message = error instanceof Error ? error.message : t("row.requestFailed");
1445
+ if (mounted.current) {
1446
+ setActionError(message);
1447
+ appendLog(t("row.poolLogBatchFailed", { message }), "error");
1448
+ }
1449
+ } finally {
1450
+ if (mounted.current) setRowBusyAccountId(void 0);
1451
+ }
1452
+ }, [
1453
+ appendLog,
1454
+ onRefresh,
1455
+ region,
1456
+ t
1457
+ ]);
1458
+ /**
1459
+ * Remove ONE account's plugin-stored credential from its row. The Host only
1460
+ * ever deletes the vault entry; a desktop sign-in reappears on the next
1461
+ * rescan, and the message below says which of the two happened.
1462
+ */
1463
+ const runRowRemove = (0, react.useCallback)(async (accountId, accountName) => {
1464
+ setRowBusyAccountId(accountId);
1465
+ setActionError(void 0);
1466
+ const url = withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, "remove") + "&accountId=" + encodeURIComponent(accountId);
1467
+ try {
1468
+ const response = await fetch(url, {
1469
+ method: "POST",
1470
+ headers: { accept: "application/json" },
1471
+ credentials: "same-origin"
1472
+ });
1473
+ const body = await response.json().catch(() => void 0);
1474
+ if (!response.ok) throw new Error(poolFailureText(t, {
1475
+ status: response.status,
1476
+ reason: body?.reason,
1477
+ error: body?.error
1478
+ }));
1479
+ if (!mounted.current) return;
1480
+ appendLog(body?.removed === true ? t("row.poolRowRemoved", { accountName: accountName === "" ? t("row.accountUnnamed") : accountName }) : t("row.poolRowRemoveDesktopOnly", { accountName: accountName === "" ? t("row.accountUnnamed") : accountName }), body?.removed === true ? "ok" : "info");
1481
+ onRefresh?.();
1482
+ } catch (error) {
1483
+ const message = error instanceof Error ? error.message : t("row.requestFailed");
1484
+ if (mounted.current) {
1485
+ setActionError(message);
1486
+ appendLog(t("row.poolLogBatchFailed", { message }), "error");
1487
+ }
1488
+ } finally {
1489
+ if (mounted.current) setRowBusyAccountId(void 0);
1490
+ }
1491
+ }, [
1492
+ appendLog,
1493
+ onRefresh,
1494
+ region,
1495
+ t
1496
+ ]);
1370
1497
  if (pool === void 0) return null;
1371
1498
  /**
1372
1499
  * Where the target model came from, and which model it is.
@@ -1439,13 +1566,22 @@ window.__ModuleLoader__.load({
1439
1566
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1440
1567
  disabled: effectiveMembers.length === 0 || !canEditPool,
1441
1568
  onClick: () => selectAll(false)
1442
- }, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-table" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null), ...pool.accounts.map((account) => renderAccountRow({
1569
+ }, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...pool.accounts.map((account) => renderAccountRow({
1443
1570
  t,
1444
1571
  account,
1445
1572
  checked: active.memberAccountIds.includes(account.accountId),
1446
1573
  canEdit: canEditPool,
1447
1574
  checkinSupported: pool.checkinSupported,
1448
- onToggle: (next) => toggleMember(account.accountId, next)
1575
+ onToggle: (next) => toggleMember(account.accountId, next),
1576
+ busy: rowBusyAccountId !== void 0,
1577
+ testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
1578
+ targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
1579
+ onTest: () => {
1580
+ runRowTest(account.accountId);
1581
+ },
1582
+ onRemove: () => {
1583
+ runRowRemove(account.accountId, account.accountName);
1584
+ }
1449
1585
  }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1450
1586
  className: "dsm-workbuddy-pool-warn",
1451
1587
  role: "status"
@@ -1483,7 +1619,8 @@ window.__ModuleLoader__.load({
1483
1619
  },
1484
1620
  onDiscard: discard,
1485
1621
  appendLog,
1486
- targetProvenance
1622
+ targetProvenance,
1623
+ ...accountTools === void 0 ? {} : { accountTools }
1487
1624
  }), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolRegionNote")), renderLog({
1488
1625
  t,
1489
1626
  log,
@@ -1496,7 +1633,7 @@ window.__ModuleLoader__.load({
1496
1633
  }
1497
1634
  /** One account's row. */
1498
1635
  function renderAccountRow(input) {
1499
- const { t, account, checked, canEdit, checkinSupported, onToggle } = input;
1636
+ const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove } = input;
1500
1637
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
1501
1638
  const excluded = exclusionText(t, account.excludedBy);
1502
1639
  const probe = account.probe;
@@ -1537,11 +1674,24 @@ window.__ModuleLoader__.load({
1537
1674
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
1538
1675
  className: "dsm-workbuddy-pool-unknown",
1539
1676
  title: t("row.poolNeverTested")
1540
- }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null);
1677
+ }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("button", {
1678
+ type: "button",
1679
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1680
+ disabled: busy || testDisabled,
1681
+ title: testDisabled ? targetHint : void 0,
1682
+ onClick: onTest
1683
+ }, t("row.poolRowTest")), (0, react.createElement)("button", {
1684
+ type: "button",
1685
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1686
+ disabled: busy,
1687
+ title: t("row.poolRowRemoveHint"),
1688
+ "aria-label": t("row.poolRowRemoveAria", { accountName: name }),
1689
+ onClick: onRemove
1690
+ }, t("row.poolRowRemove"))));
1541
1691
  }
1542
1692
  /** The preference editor plus its save/discard actions. */
1543
1693
  function renderSettings(input) {
1544
- const { t, pool, active, dirty, saving, siblingBusy, saveError, settingsScope, onEdit, onSave, onDiscard, targetProvenance } = input;
1694
+ const { t, pool, active, dirty, saving, siblingBusy, saveError, settingsScope, onEdit, onSave, onDiscard, targetProvenance, accountTools } = input;
1545
1695
  const canEdit = settingsScope !== void 0;
1546
1696
  const toggle = (label, hint, checked, onChange, disabled = false) => (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, label), (0, react.createElement)("span", null, hint)), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("label", { className: "sw" }, (0, react.createElement)("input", {
1547
1697
  type: "checkbox",
@@ -1549,7 +1699,7 @@ window.__ModuleLoader__.load({
1549
1699
  disabled: disabled || !canEdit,
1550
1700
  onChange: (event) => onChange(event.currentTarget.checked)
1551
1701
  }), (0, react.createElement)("span", { className: "sw-track" }))));
1552
- return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings-wrap" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolSettingsTitle")), dirty ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolDirty")) : null), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolSettingsHint")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings" }, toggle(t("row.poolEnabled"), t("row.poolEnabledHint"), active.enabled, (next) => onEdit((current) => ({
1702
+ return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings-wrap" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolSettingsTitle")), dirty ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolDirty")) : null), accountTools === void 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-tools" }, accountTools), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolSettingsHint")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings" }, toggle(t("row.poolEnabled"), t("row.poolEnabledHint"), active.enabled, (next) => onEdit((current) => ({
1553
1703
  ...current,
1554
1704
  enabled: next
1555
1705
  }))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolTargetLabel")), (0, react.createElement)("span", null, targetProvenance === null ? null : `${targetProvenance} `, t("row.poolTargetHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
@@ -1621,6 +1771,534 @@ window.__ModuleLoader__.load({
1621
1771
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-log-time" }, formatClock$1(entry.atMs)), (0, react.createElement)("span", { className: `dsm-workbuddy-pool-log-text dsm-workbuddy-pool-log-${entry.tone}` }, entry.text)))));
1622
1772
  }
1623
1773
  //#endregion
1774
+ //#region src/client/OAuthSignIn.tsx
1775
+ /**
1776
+ * The card's OAuth QR sign-in section.
1777
+ *
1778
+ * 参考:扫码登录的通行形态 —— 发起登录 → 打开授权页 →
1779
+ * 轮询到完成为止 → 展示结果。本实现把轮询循环压成一个组件内 effect:
1780
+ * 打开授权页的是用户点击的链接(浏览器标签页,非弹窗),组件只负责
1781
+ * 按 `OAUTH_POLL_INTERVAL_MS` 节奏问 Host「完成了吗」,完成即停。
1782
+ *
1783
+ * 为什么不用 window.open:卡片运行在 Harness 的设置页 iframe/沙箱里,弹窗
1784
+ * 会被拦截;把授权 URL 渲染成普通链接(target=_blank rel=noopener)是
1785
+ * 沙箱内最可靠的方式,用户也可以复制链接到手机上继续(URL 即登录态)。
1786
+ *
1787
+ * 所有状态就地管理,零跨组件往返:取消 = 停止轮询 + 清理 loginId;
1788
+ * Host 重启导致 loginId 失效会得到 done+error,与超时同一条文案路径。
1789
+ *
1790
+ * @module dsh-connect-workbuddy/client/OAuthSignIn
1791
+ */
1792
+ /** Seconds since the login started, for the waiting line. */
1793
+ function elapsedSeconds(sinceMs, nowMs) {
1794
+ return Math.max(0, Math.floor((nowMs - sinceMs) / 1e3));
1795
+ }
1796
+ /**
1797
+ * The section. Rendered on BOTH sign-in states: a signed-out machine gains
1798
+ * its first account here; a signed-in one can add another without leaving
1799
+ * the card.
1800
+ */
1801
+ function OAuthSignIn({ t, region }) {
1802
+ const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
1803
+ const [pollTick, setPollTick] = (0, react.useState)(0);
1804
+ const [nowMs, setNowMs] = (0, react.useState)(() => Date.now());
1805
+ const mounted = (0, react.useRef)(true);
1806
+ (0, react.useEffect)(() => {
1807
+ mounted.current = true;
1808
+ return () => {
1809
+ mounted.current = false;
1810
+ };
1811
+ }, []);
1812
+ const waiting = phase.kind === "waiting";
1813
+ (0, react.useEffect)(() => {
1814
+ if (!waiting || phase.kind !== "waiting") return;
1815
+ const expiresAtMs = phase.expiresAtMs;
1816
+ const timer = setInterval(() => {
1817
+ if (!mounted.current) return;
1818
+ const now = Date.now();
1819
+ setNowMs(now);
1820
+ if (now > expiresAtMs) setPhase({
1821
+ kind: "finished",
1822
+ message: "error",
1823
+ error: t("row.oauthTimedOut")
1824
+ });
1825
+ }, 1e3);
1826
+ return () => {
1827
+ clearInterval(timer);
1828
+ };
1829
+ }, [waiting, phase]);
1830
+ (0, react.useEffect)(() => {
1831
+ if (phase.kind !== "waiting") return;
1832
+ const { loginId } = phase;
1833
+ let cancelled = false;
1834
+ const timer = setTimeout(() => {
1835
+ (async () => {
1836
+ try {
1837
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_OAUTH_PATH, region, "poll"), {
1838
+ method: "POST",
1839
+ headers: {
1840
+ "content-type": "application/json",
1841
+ accept: "application/json"
1842
+ },
1843
+ credentials: "same-origin",
1844
+ body: JSON.stringify({ loginId })
1845
+ });
1846
+ const body = await response.json();
1847
+ if (cancelled || !mounted.current) return;
1848
+ if (!response.ok || !body || typeof body !== "object") {
1849
+ setPhase({
1850
+ kind: "finished",
1851
+ message: "error",
1852
+ error: `HTTP ${response.status}`
1853
+ });
1854
+ return;
1855
+ }
1856
+ if (!body.done) {
1857
+ setPollTick((tick) => tick + 1);
1858
+ return;
1859
+ }
1860
+ if (typeof body.error === "string" && body.error !== "") setPhase({
1861
+ kind: "finished",
1862
+ message: "error",
1863
+ error: body.error
1864
+ });
1865
+ else setPhase({
1866
+ kind: "finished",
1867
+ message: "added",
1868
+ accountName: body.accountName ?? ""
1869
+ });
1870
+ } catch {
1871
+ setPollTick((tick) => tick + 1);
1872
+ }
1873
+ })();
1874
+ }, OAUTH_POLL_INTERVAL_MS);
1875
+ return () => {
1876
+ cancelled = true;
1877
+ clearTimeout(timer);
1878
+ };
1879
+ }, [
1880
+ phase,
1881
+ pollTick,
1882
+ region
1883
+ ]);
1884
+ const start = async () => {
1885
+ setPhase({ kind: "opening" });
1886
+ try {
1887
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_OAUTH_PATH, region, "start"), {
1888
+ method: "POST",
1889
+ headers: { accept: "application/json" },
1890
+ credentials: "same-origin"
1891
+ });
1892
+ const body = await response.json();
1893
+ if (!mounted.current) return;
1894
+ if (!response.ok || typeof body.loginId !== "string" || typeof body.verificationUri !== "string") {
1895
+ setPhase({
1896
+ kind: "finished",
1897
+ message: "error",
1898
+ error: body.error ?? `HTTP ${response.status}`
1899
+ });
1900
+ return;
1901
+ }
1902
+ setPhase({
1903
+ kind: "waiting",
1904
+ loginId: body.loginId,
1905
+ verificationUri: body.verificationUri,
1906
+ startedAtMs: Date.now(),
1907
+ expiresAtMs: Date.now() + (typeof body.expiresIn === "number" && body.expiresIn > 0 ? body.expiresIn : 600) * 1e3
1908
+ });
1909
+ setNowMs(Date.now());
1910
+ } catch (error) {
1911
+ if (mounted.current) setPhase({
1912
+ kind: "finished",
1913
+ message: "error",
1914
+ error: error instanceof Error ? error.message : String(error)
1915
+ });
1916
+ }
1917
+ };
1918
+ if (phase.kind === "finished") {
1919
+ const text = phase.message === "added" ? t("row.oauthDone", { accountName: phase.accountName === "" || phase.accountName === void 0 ? t("row.accountUnnamed") : phase.accountName }) : phase.message === "cancelled" ? t("row.oauthCancelled") : t("row.oauthError", { message: phase.error ?? "" });
1920
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1921
+ className: "dsm-workbuddy-bar",
1922
+ "aria-label": t("row.oauthTitle"),
1923
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1924
+ className: `dsm-workbuddy-bar-status ${phase.message === "added" ? "dsm-workbuddy-bar-ok" : "dsm-workbuddy-bar-error"}`,
1925
+ children: text
1926
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1927
+ type: "button",
1928
+ className: "dsm-btn dsm-btn-outline",
1929
+ onClick: () => {
1930
+ setPhase({ kind: "idle" });
1931
+ },
1932
+ children: t("row.oauthStart")
1933
+ })]
1934
+ });
1935
+ }
1936
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1937
+ className: "dsm-workbuddy-bar",
1938
+ "aria-label": t("row.oauthTitle"),
1939
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1940
+ className: "dsm-workbuddy-bar-label",
1941
+ children: t("row.oauthTitle")
1942
+ }), phase.kind === "waiting" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1943
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1944
+ className: "dsm-workbuddy-bar-status",
1945
+ children: [
1946
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
1947
+ href: phase.verificationUri,
1948
+ target: "_blank",
1949
+ rel: "noopener noreferrer",
1950
+ children: t("row.oauthOpen")
1951
+ }),
1952
+ " — ",
1953
+ t("row.oauthOpened")
1954
+ ]
1955
+ }),
1956
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1957
+ className: "dsm-workbuddy-bar-hint",
1958
+ children: [
1959
+ t("row.oauthWaiting", { seconds: elapsedSeconds(phase.startedAtMs, nowMs) }),
1960
+ " ",
1961
+ t("row.oauthExpires", { minutes: Math.max(1, Math.round((phase.expiresAtMs - nowMs) / 6e4)) })
1962
+ ]
1963
+ }),
1964
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1965
+ type: "button",
1966
+ className: "dsm-btn dsm-btn-outline",
1967
+ onClick: () => {
1968
+ setPhase({
1969
+ kind: "finished",
1970
+ message: "cancelled"
1971
+ });
1972
+ },
1973
+ children: t("row.oauthCancel")
1974
+ })
1975
+ ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1976
+ type: "button",
1977
+ className: "dsm-btn dsm-btn-outline",
1978
+ disabled: phase.kind === "opening",
1979
+ onClick: () => {
1980
+ start();
1981
+ },
1982
+ children: phase.kind === "opening" ? t("row.oauthStarting") : t("row.oauthStart")
1983
+ })]
1984
+ });
1985
+ }
1986
+ //#endregion
1987
+ //#region src/client/AccountTransfer.tsx
1988
+ /**
1989
+ * The card's account-transfer section: batch export / import of the region's
1990
+ * stored credentials, in the file format shared with sibling credential
1991
+ * managers for the same upstream.
1992
+ *
1993
+ * 参考:扫码登录小节(OAuthSignIn)的组件形态 —— 单个 section、就地管理
1994
+ * 全部状态(busy / 结果行 / 错误)、零跨组件往返。导出弹框的交互(多选
1995
+ * 账号 → 下载 JSON)与导入弹框(选文件 → 脱敏预览 → 勾选 → 导入)沿用
1996
+ * 通行实现的步骤划分;预览只含展示字段与 hasToken 布尔量,token 明文
1997
+ * 永不进入浏览器。
1998
+ *
1999
+ * 导出用 <a download> Blob:卡片运行在设置页 iframe 里,无宿主保存对话框
2000
+ * 可用;a[download] 同源下载是沙箱内最可靠的方式。
2001
+ *
2002
+ * @module dsh-connect-workbuddy/client/AccountTransfer
2003
+ */
2004
+ /** Build the default export file name: workbuddy-accounts-YYYY-MM-DD.json. */
2005
+ function exportFileName() {
2006
+ const pad = (value) => String(value).padStart(2, "0");
2007
+ const d = /* @__PURE__ */ new Date();
2008
+ return `workbuddy-accounts-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.json`;
2009
+ }
2010
+ /** Download a JSON array as a local file (no host save dialog inside the iframe). */
2011
+ function downloadJson(fileName, data) {
2012
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
2013
+ const url = URL.createObjectURL(blob);
2014
+ const anchor = document.createElement("a");
2015
+ anchor.href = url;
2016
+ anchor.download = fileName;
2017
+ document.body.appendChild(anchor);
2018
+ anchor.click();
2019
+ anchor.remove();
2020
+ URL.revokeObjectURL(url);
2021
+ }
2022
+ /** The section. Rendered on both sign-in states — a signed-out machine can
2023
+ * still import a file exported elsewhere; export without accounts answers
2024
+ * the error line. */
2025
+ function AccountTransfer({ t, region, accounts }) {
2026
+ const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
2027
+ const [dialog, setDialog] = (0, react.useState)(void 0);
2028
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
2029
+ const [preview, setPreview] = (0, react.useState)(void 0);
2030
+ const [importSelected, setImportSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
2031
+ const fileInput = (0, react.useRef)(null);
2032
+ const mounted = (0, react.useRef)(true);
2033
+ (0, react.useEffect)(() => {
2034
+ mounted.current = true;
2035
+ return () => {
2036
+ mounted.current = false;
2037
+ };
2038
+ }, []);
2039
+ const closeDialog = () => {
2040
+ setDialog(void 0);
2041
+ setSelected(/* @__PURE__ */ new Set());
2042
+ setPreview(void 0);
2043
+ setImportSelected(/* @__PURE__ */ new Set());
2044
+ };
2045
+ const runExport = async () => {
2046
+ if (selected.size === 0) return;
2047
+ setPhase({ kind: "exporting" });
2048
+ try {
2049
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "export"), {
2050
+ method: "POST",
2051
+ headers: {
2052
+ "content-type": "application/json",
2053
+ accept: "application/json"
2054
+ },
2055
+ credentials: "same-origin",
2056
+ body: JSON.stringify({ accountIds: [...selected] })
2057
+ });
2058
+ const body = await response.json();
2059
+ if (!mounted.current) return;
2060
+ if (!response.ok || !Array.isArray(body.accounts)) {
2061
+ setPhase({
2062
+ kind: "error",
2063
+ message: body.error ?? `HTTP ${response.status}`
2064
+ });
2065
+ return;
2066
+ }
2067
+ downloadJson(exportFileName(), body.accounts);
2068
+ closeDialog();
2069
+ setPhase({
2070
+ kind: "done",
2071
+ message: t("row.transferExported", {
2072
+ count: body.accounts.length,
2073
+ fileName: exportFileName()
2074
+ })
2075
+ });
2076
+ } catch (error) {
2077
+ if (mounted.current) setPhase({
2078
+ kind: "error",
2079
+ message: error instanceof Error ? error.message : String(error)
2080
+ });
2081
+ }
2082
+ };
2083
+ const readImportFile = async (file) => {
2084
+ setPhase({ kind: "importing" });
2085
+ try {
2086
+ const text = await file.text();
2087
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "preview"), {
2088
+ method: "POST",
2089
+ headers: {
2090
+ "content-type": "application/json",
2091
+ accept: "application/json"
2092
+ },
2093
+ credentials: "same-origin",
2094
+ body: JSON.stringify({ text })
2095
+ });
2096
+ const body = await response.json();
2097
+ if (!mounted.current) return;
2098
+ if (!response.ok || !Array.isArray(body.accounts)) {
2099
+ setPhase({
2100
+ kind: "error",
2101
+ message: body.error ?? `HTTP ${response.status}`
2102
+ });
2103
+ return;
2104
+ }
2105
+ setPreview(body);
2106
+ setImportSelected(new Set(body.accounts.filter((entry) => entry.hasToken).map((entry) => entry.index)));
2107
+ setPhase({ kind: "idle" });
2108
+ } catch (error) {
2109
+ if (mounted.current) setPhase({
2110
+ kind: "error",
2111
+ message: error instanceof Error ? error.message : String(error)
2112
+ });
2113
+ }
2114
+ };
2115
+ const runImport = async () => {
2116
+ if (preview === void 0 || importSelected.size === 0 || fileInput.current?.files?.[0] === void 0) return;
2117
+ setPhase({ kind: "importing" });
2118
+ try {
2119
+ const text = await fileInput.current.files[0].text();
2120
+ const response = await fetch(withWorkBuddyRouteAction(WORKBUDDY_TRANSFER_PATH, region, "import"), {
2121
+ method: "POST",
2122
+ headers: {
2123
+ "content-type": "application/json",
2124
+ accept: "application/json"
2125
+ },
2126
+ credentials: "same-origin",
2127
+ body: JSON.stringify({
2128
+ text,
2129
+ indexes: [...importSelected]
2130
+ })
2131
+ });
2132
+ const body = await response.json();
2133
+ if (!mounted.current) return;
2134
+ if (!response.ok || typeof body.imported !== "number") {
2135
+ setPhase({
2136
+ kind: "error",
2137
+ message: body.error ?? `HTTP ${response.status}`
2138
+ });
2139
+ return;
2140
+ }
2141
+ closeDialog();
2142
+ setPhase({
2143
+ kind: "done",
2144
+ message: t("row.transferImported", {
2145
+ imported: body.imported,
2146
+ skipped: body.skipped ?? 0
2147
+ })
2148
+ });
2149
+ } catch (error) {
2150
+ if (mounted.current) setPhase({
2151
+ kind: "error",
2152
+ message: error instanceof Error ? error.message : String(error)
2153
+ });
2154
+ }
2155
+ };
2156
+ const busy = phase.kind === "exporting" || phase.kind === "importing";
2157
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2158
+ className: "dsm-workbuddy-bar",
2159
+ "aria-label": t("row.transferTitle"),
2160
+ children: [
2161
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2162
+ className: "dsm-workbuddy-bar-label",
2163
+ children: t("row.transferTitle")
2164
+ }),
2165
+ phase.kind === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2166
+ className: "dsm-workbuddy-bar-status dsm-workbuddy-bar-ok",
2167
+ children: phase.message
2168
+ }) : phase.kind === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2169
+ className: "dsm-workbuddy-bar-status dsm-workbuddy-bar-error",
2170
+ children: t("row.transferError", { message: phase.message })
2171
+ }) : null,
2172
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2173
+ className: "dsm-workbuddy-bar-actions",
2174
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2175
+ type: "button",
2176
+ className: "dsm-btn dsm-btn-outline",
2177
+ disabled: busy || accounts.length === 0,
2178
+ onClick: () => {
2179
+ setSelected(/* @__PURE__ */ new Set());
2180
+ setDialog("export");
2181
+ },
2182
+ children: t("row.transferExport")
2183
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2184
+ type: "button",
2185
+ className: "dsm-btn dsm-btn-outline",
2186
+ disabled: busy,
2187
+ onClick: () => {
2188
+ setPreview(void 0);
2189
+ setImportSelected(/* @__PURE__ */ new Set());
2190
+ setDialog("import");
2191
+ },
2192
+ children: t("row.transferImport")
2193
+ })]
2194
+ }),
2195
+ dialog === "export" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2196
+ className: "dsm-workbuddy-transfer-dialog",
2197
+ role: "dialog",
2198
+ "aria-label": t("row.transferExport"),
2199
+ children: [
2200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferExportPick", { total: accounts.length }) }),
2201
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2202
+ className: "dsm-workbuddy-hint",
2203
+ children: t("row.transferHint")
2204
+ }),
2205
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2206
+ className: "dsm-workbuddy-transfer-list",
2207
+ children: accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
2208
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2209
+ type: "checkbox",
2210
+ checked: selected.has(account.id),
2211
+ onChange: (event) => {
2212
+ const next = new Set(selected);
2213
+ if (event.target.checked) next.add(account.id);
2214
+ else next.delete(account.id);
2215
+ setSelected(next);
2216
+ }
2217
+ }),
2218
+ " ",
2219
+ account.accountName === "" ? t("row.accountUnnamed") : account.accountName
2220
+ ] }) }, account.id))
2221
+ }),
2222
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2223
+ className: "dsm-workbuddy-transfer-dialog-actions",
2224
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2225
+ type: "button",
2226
+ className: "dsm-btn dsm-btn-outline",
2227
+ onClick: closeDialog,
2228
+ children: t("row.transferCancel")
2229
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2230
+ type: "button",
2231
+ className: "dsm-btn dsm-btn-outline",
2232
+ disabled: selected.size === 0 || phase.kind === "exporting",
2233
+ onClick: () => {
2234
+ runExport();
2235
+ },
2236
+ children: phase.kind === "exporting" ? t("row.transferExporting") : t("row.transferExportConfirm", { count: selected.size })
2237
+ })]
2238
+ })
2239
+ ]
2240
+ }) : null,
2241
+ dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2242
+ className: "dsm-workbuddy-transfer-dialog",
2243
+ role: "dialog",
2244
+ "aria-label": t("row.transferImport"),
2245
+ children: [
2246
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferImportPick") }),
2247
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2248
+ ref: fileInput,
2249
+ type: "file",
2250
+ accept: "application/json,.json",
2251
+ disabled: busy,
2252
+ onChange: (event) => {
2253
+ const file = event.target.files?.[0];
2254
+ if (file !== void 0) readImportFile(file);
2255
+ }
2256
+ }),
2257
+ preview === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2258
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferPreviewTotal", { total: preview.total }) }),
2259
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2260
+ className: "dsm-workbuddy-transfer-list",
2261
+ children: preview.accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
2262
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2263
+ type: "checkbox",
2264
+ disabled: !entry.hasToken,
2265
+ checked: importSelected.has(entry.index),
2266
+ onChange: (event) => {
2267
+ const next = new Set(importSelected);
2268
+ if (event.target.checked) next.add(entry.index);
2269
+ else next.delete(entry.index);
2270
+ setImportSelected(next);
2271
+ }
2272
+ }),
2273
+ " ",
2274
+ entry.nickname !== "" ? entry.nickname : entry.email !== "" ? entry.email : entry.uid !== "" ? entry.uid : t("row.transferEntryUnknown"),
2275
+ entry.hasToken ? "" : ` — ${t("row.transferNoToken")}`
2276
+ ] }) }, entry.index))
2277
+ }),
2278
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2279
+ className: "dsm-workbuddy-transfer-dialog-actions",
2280
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2281
+ type: "button",
2282
+ className: "dsm-btn dsm-btn-outline",
2283
+ onClick: closeDialog,
2284
+ children: t("row.transferCancel")
2285
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2286
+ type: "button",
2287
+ className: "dsm-btn dsm-btn-outline",
2288
+ disabled: importSelected.size === 0 || phase.kind === "importing",
2289
+ onClick: () => {
2290
+ runImport();
2291
+ },
2292
+ children: phase.kind === "importing" ? t("row.transferImporting") : t("row.transferImportConfirm", { count: importSelected.size })
2293
+ })]
2294
+ })
2295
+ ] })
2296
+ ]
2297
+ }) : null
2298
+ ]
2299
+ });
2300
+ }
2301
+ //#endregion
1624
2302
  //#region src/native-modality.ts
1625
2303
  /**
1626
2304
  * Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
@@ -1845,7 +2523,9 @@ window.__ModuleLoader__.load({
1845
2523
  .dsm-workbuddy-pool-warn{margin:0;padding:9px 11px;border-radius:9px;font-size:12px;line-height:17px;color:var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.09);border:1px solid rgba(245,158,11,.3)}
1846
2524
  .dsm-workbuddy-pool-error{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-state-error-primary,#ef4444);white-space:pre-line}
1847
2525
  .dsm-workbuddy-pool-table{border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
1848
- .dsm-workbuddy-pool-row{display:grid;grid-template-columns:1.6fr 1fr 1.3fr .9fr;gap:10px;align-items:center;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:13px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
2526
+ .dsm-workbuddy-pool-row{display:grid;grid-template-columns:1.6fr 1fr 1.3fr .9fr .8fr;gap:10px;align-items:center;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:13px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
2527
+ /* No check-in column (the international region): the freed track goes to the actions. */
2528
+ .dsm-workbuddy-pool-table:not(.dsm-workbuddy-pool-table-checkin) .dsm-workbuddy-pool-row{grid-template-columns:1.6fr 1fr 1.3fr 1.7fr}
1849
2529
  .dsm-workbuddy-pool-row:first-child{border-top:0}
1850
2530
  .dsm-workbuddy-pool-row-head{padding:8px 14px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-weight:600;letter-spacing:.02em;text-transform:uppercase}
1851
2531
  .dsm-workbuddy-pool-row-current{background:rgba(86,134,254,.07)}
@@ -1889,6 +2569,12 @@ window.__ModuleLoader__.load({
1889
2569
  .dsm-workbuddy-pool-settings-wrap{display:flex;flex-direction:column;gap:10px}
1890
2570
  .dsm-workbuddy-pool-dirty{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
1891
2571
  .dsm-workbuddy-pool-settings{display:flex;flex-direction:column;gap:1px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
2572
+ /* QR sign-in + export/import, hosted inside the pool settings block.
2573
+ The block's own bordered rows belong to the SAVED preferences, so these
2574
+ get their own container: same rhythm (12px 14px) so the two read as one
2575
+ group, but a dashed edge marking them as immediate actions rather than
2576
+ drafts the Save button below would commit. */
2577
+ .dsm-workbuddy-pool-tools{display:flex;flex-direction:column;gap:2px;border:1px dashed var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;padding:4px 10px}
1892
2578
  .dsm-workbuddy-pool-set{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:12px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31)}
1893
2579
  .dsm-workbuddy-pool-set:first-child{border-top:0}
1894
2580
  .dsm-workbuddy-pool-set-copy{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
@@ -1918,6 +2604,9 @@ window.__ModuleLoader__.load({
1918
2604
  .dsm-workbuddy-pool-log-error{color:var(--dsw-alias-state-error-primary,#ef4444)}
1919
2605
  .dsm-workbuddy-pool-log-info{color:var(--dsw-alias-label-secondary,#c6c9d0)}
1920
2606
  .dsm-workbuddy-pool-log-empty{padding:14px;font-size:12px;color:var(--dsw-alias-label-tertiary,#999)}
2607
+ /* Per-row actions (test / remove), right side of each pool row. */
2608
+ .dsm-workbuddy-pool-row-actions{display:flex;gap:6px;align-items:center;justify-content:flex-end;flex:none}
2609
+ .dsm-workbuddy-pool-row-actions .dsm-workbuddy-pool-small-btn{padding:2px 10px;font-size:12px}
1921
2610
  @media (max-width:760px){.dsm-workbuddy-pool-row{grid-template-columns:1fr 1fr;row-gap:6px}.dsm-workbuddy-pool-select{max-width:180px}}
1922
2611
  /* Pool switch, namespaced under .dsm-workbuddy-pool so it cannot collide with host styles. */
1923
2612
  .dsm-workbuddy-pool .sw{position:relative;display:inline-block;width:38px;height:22px;flex:none;cursor:pointer}
@@ -1928,6 +2617,34 @@ window.__ModuleLoader__.load({
1928
2617
  .dsm-workbuddy-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
1929
2618
  .dsm-workbuddy-pool .sw input:disabled+.sw-track{opacity:.4}
1930
2619
  .dsm-workbuddy-pool .sw input:disabled{cursor:default}
2620
+ /* Account action bars: QR sign-in and export/import.
2621
+ One row each — label, live status, actions — instead of a padded block
2622
+ per feature. The bar is the resting height; only an open dialog (below)
2623
+ grows vertically, so the card does not scroll past a wall of chrome to
2624
+ reach the buttons. Status text shares the row rather than claiming its own,
2625
+ and is what truncates first when the card is narrow. */
2626
+ .dsm-workbuddy-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0;padding:0}
2627
+ .dsm-workbuddy-bar-label{flex:none;margin:0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#c6c9d0)}
2628
+ .dsm-workbuddy-bar-status{flex:1 1 auto;min-width:0;margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e8e9ed);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2629
+ .dsm-workbuddy-bar-hint{color:var(--dsw-alias-label-tertiary,#999)}
2630
+ .dsm-workbuddy-bar-actions{display:flex;gap:8px;flex:none;margin-left:auto}
2631
+ .dsm-workbuddy-bar a{color:var(--dsw-alias-brand-primary,#5686fe);text-decoration:none}
2632
+ .dsm-workbuddy-bar a:hover{text-decoration:underline}
2633
+ .dsm-workbuddy-bar-ok{color:var(--dsw-alias-state-success-primary,#22a06b)}
2634
+ .dsm-workbuddy-bar-error{color:var(--dsw-alias-state-error-primary,#ef4444)}
2635
+ /* Secondary text inside the transfer DIALOG (not a bar): the privacy note
2636
+ shown when credentials leave the machine. Kept independent of the bar
2637
+ classes so the dialog does not depend on the bar's layout to be readable. */
2638
+ .dsm-workbuddy-hint{color:var(--dsw-alias-label-tertiary,#999)}
2639
+ /* The transfer dialog is deliberately a block: it holds a scrollable account
2640
+ list and per-entry checkboxes, which cannot live in a one-row bar. */
2641
+ .dsm-workbuddy-transfer-dialog{margin-top:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-secondary,transparent)}
2642
+ .dsm-workbuddy-transfer-dialog p{margin:0 0 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e8e9ed)}
2643
+ .dsm-workbuddy-transfer-dialog-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
2644
+ .dsm-workbuddy-transfer-list{list-style:none;margin:0 0 8px;padding:0;max-height:180px;overflow-y:auto}
2645
+ .dsm-workbuddy-transfer-list label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-primary,#e8e9ed);cursor:pointer;padding:2px 0}
2646
+ .dsm-workbuddy-transfer-list input[type="checkbox"]{flex:none}
2647
+ .dsm-workbuddy-transfer input[type="file"]{font-size:12px;color:var(--dsw-alias-label-primary,#e8e9ed);margin-bottom:8px}
1931
2648
  `;
1932
2649
  /**
1933
2650
  * Composer credit readout styles, injected by the readout component itself.
@@ -2945,6 +3662,25 @@ window.__ModuleLoader__.load({
2945
3662
  searched
2946
3663
  });
2947
3664
  status.status === "error" || status.selectionExplicit;
3665
+ /**
3666
+ * Account-adding and account-migration controls, defined ONCE and rendered
3667
+ * in both sign-in states: inside the pool settings block when an account
3668
+ * exists, and in the signed-out branch when it does not (the pool section is
3669
+ * not rendered there at all). Two JSX copies would be free to drift, and the
3670
+ * two states are the same feature — a user who learns these controls in one
3671
+ * must find them in the same order in the other.
3672
+ */
3673
+ const accountTools = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OAuthSignIn, {
3674
+ t,
3675
+ region: activeRegion
3676
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountTransfer, {
3677
+ t,
3678
+ region: activeRegion,
3679
+ accounts: status.status === "error" ? [] : status.accounts.map((account) => ({
3680
+ id: account.id,
3681
+ accountName: account.accountName
3682
+ }))
3683
+ })] });
2948
3684
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
2949
3685
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
2950
3686
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -3091,6 +3827,7 @@ window.__ModuleLoader__.load({
3091
3827
  switchAccount(accountId);
3092
3828
  },
3093
3829
  selectingAccount: switchingAccount,
3830
+ accountTools,
3094
3831
  siblingBusy: saving || togglingRegion !== void 0,
3095
3832
  onBusyChange: setPoolBusy,
3096
3833
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
@@ -3329,6 +4066,10 @@ window.__ModuleLoader__.load({
3329
4066
  children: busy ? t("row.accountsScanning") : t("row.accountsRescan")
3330
4067
  })
3331
4068
  }),
4069
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4070
+ className: "dsm-workbuddy-pool-tools",
4071
+ children: accountTools
4072
+ }),
3332
4073
  searched.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SearchedPaths, {
3333
4074
  items: searched,
3334
4075
  t
@@ -4006,6 +4747,37 @@ window.__ModuleLoader__.load({
4006
4747
  "row.probeFailedStatus": "✗ Test failed (HTTP {status})",
4007
4748
  "row.probeError": "Could not run the test: {message}",
4008
4749
  "row.cheer": "Star on GitHub",
4750
+ "row.oauthTitle": "Add an account by QR sign-in",
4751
+ "row.oauthStart": "Sign in with QR code",
4752
+ "row.oauthStarting": "Opening…",
4753
+ "row.oauthOpenHint": "Open the sign-in page and confirm — the account is added here automatically when you finish:",
4754
+ "row.oauthOpen": "Open sign-in page",
4755
+ "row.oauthOpened": "Opened. Waiting for you to finish…",
4756
+ "row.oauthWaiting": "Waiting for the sign-in to finish… ({seconds}s)",
4757
+ "row.oauthExpires": "The sign-in window expires in about {minutes} min.",
4758
+ "row.oauthCancel": "Cancel",
4759
+ "row.oauthCancelled": "Sign-in cancelled.",
4760
+ "row.oauthTimedOut": "The sign-in timed out — start again when ready.",
4761
+ "row.oauthDone": "Added “{accountName}”. Pick it above to use it.",
4762
+ "row.oauthError": "Sign-in failed: {message}",
4763
+ "row.oauthUnavailable": "QR sign-in is not offered by this build of the plugin.",
4764
+ "row.transferTitle": "Export / import accounts",
4765
+ "row.transferHint": "Move accounts between computers as a JSON file. The file carries the credentials — keep it private. The format is shared with other WorkBuddy credential managers.",
4766
+ "row.transferExport": "Export accounts",
4767
+ "row.transferExporting": "Exporting…",
4768
+ "row.transferExportPick": "Choose the accounts to export ({total} available):",
4769
+ "row.transferExportConfirm": "Export {count} account(s)",
4770
+ "row.transferExported": "Exported {count} account(s) to {fileName}.",
4771
+ "row.transferImport": "Import accounts",
4772
+ "row.transferImporting": "Importing…",
4773
+ "row.transferImportPick": "Choose a JSON file exported from this or a compatible tool:",
4774
+ "row.transferPreviewTotal": "{total} account(s) in the file — untick what should stay out. Accounts already stored here are overwritten by the import.",
4775
+ "row.transferImportConfirm": "Import {count} account(s)",
4776
+ "row.transferImported": "Imported {imported} account(s); {skipped} skipped (no token, or belongs to the other region).",
4777
+ "row.transferEntryUnknown": "unknown account",
4778
+ "row.transferNoToken": "no token in this record",
4779
+ "row.transferCancel": "Cancel",
4780
+ "row.transferError": "Transfer failed: {message}",
4009
4781
  "row.poolTitle": "Account pool",
4010
4782
  "row.poolNoCandidate": "No account in this pool can be used right now.",
4011
4783
  "row.poolNoCandidateHint": "Members are rate-limited, out of credits, rejected, or unreachable. Rate limits, drained quotas and transient failures return to the pool on their own — at the time the upstream states, or after a 30-minute fallback. Only a rejected credential needs a new sign-in.",
@@ -4104,6 +4876,12 @@ window.__ModuleLoader__.load({
4104
4876
  "row.poolCurrentHeader": "In use: {account}",
4105
4877
  "row.poolNotMember": "Not in pool",
4106
4878
  "row.poolUnsavedMembers": "Selection changed — save to apply",
4879
+ "row.poolRowTest": "Test",
4880
+ "row.poolRowRemove": "Remove",
4881
+ "row.poolRowRemoveHint": "Forget this account's plugin-stored credential. A desktop-app sign-in reappears on the next scan — only the plugin's own copy is deleted.",
4882
+ "row.poolRowRemoveAria": "Remove the stored credential of {accountName}",
4883
+ "row.poolRowRemoved": "{accountName}: removed from the plugin's storage.",
4884
+ "row.poolRowRemoveDesktopOnly": "{accountName}: nothing plugin-stored to remove — this account comes from the desktop app's own sign-in.",
4107
4885
  "row.showCreditsCn": "Show WB CN credits in the composer",
4108
4886
  "row.showCreditsGlobal": "Show WB AI credits in the composer",
4109
4887
  "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
@@ -4221,6 +4999,37 @@ window.__ModuleLoader__.load({
4221
4999
  "row.probeFailedStatus": "✗ 测试失败(HTTP {status})",
4222
5000
  "row.probeError": "无法执行测试:{message}",
4223
5001
  "row.cheer": "鼓励一下",
5002
+ "row.oauthTitle": "扫码添加账号",
5003
+ "row.oauthStart": "扫码登录",
5004
+ "row.oauthStarting": "正在打开…",
5005
+ "row.oauthOpenHint": "打开登录页并完成确认——完成后账号会自动出现在这里:",
5006
+ "row.oauthOpen": "打开登录页",
5007
+ "row.oauthOpened": "已打开,等待你完成登录…",
5008
+ "row.oauthWaiting": "正在等待登录完成…({seconds} 秒)",
5009
+ "row.oauthExpires": "登录窗口约 {minutes} 分钟后过期。",
5010
+ "row.oauthCancel": "取消",
5011
+ "row.oauthCancelled": "已取消登录。",
5012
+ "row.oauthTimedOut": "登录超时——请重新开始。",
5013
+ "row.oauthDone": "已添加「{accountName}」。在上方选中它即可使用。",
5014
+ "row.oauthError": "登录失败:{message}",
5015
+ "row.oauthUnavailable": "当前版本的插件不支持扫码登录。",
5016
+ "row.transferTitle": "导出 / 导入账号",
5017
+ "row.transferHint": "用 JSON 文件在电脑之间迁移账号。文件里是账号凭据——请妥善保管。该格式与同类 WorkBuddy 凭据管理工具通用。",
5018
+ "row.transferExport": "导出账号",
5019
+ "row.transferExporting": "正在导出…",
5020
+ "row.transferExportPick": "选择要导出的账号(共 {total} 个):",
5021
+ "row.transferExportConfirm": "导出 {count} 个账号",
5022
+ "row.transferExported": "已导出 {count} 个账号到 {fileName}。",
5023
+ "row.transferImport": "导入账号",
5024
+ "row.transferImporting": "正在导入…",
5025
+ "row.transferImportPick": "选择本插件或同类工具导出的 JSON 文件:",
5026
+ "row.transferPreviewTotal": "文件中共 {total} 个账号——不需要的请取消勾选。导入会覆盖这里已存储的同名账号。",
5027
+ "row.transferImportConfirm": "导入 {count} 个账号",
5028
+ "row.transferImported": "已导入 {imported} 个账号;跳过 {skipped} 个(缺 token,或属于另一个区域)。",
5029
+ "row.transferEntryUnknown": "未知账号",
5030
+ "row.transferNoToken": "此记录没有 token",
5031
+ "row.transferCancel": "取消",
5032
+ "row.transferError": "传输失败:{message}",
4224
5033
  "row.poolTitle": "账号池",
4225
5034
  "row.poolNoCandidate": "账号池里目前没有可用的账号。",
4226
5035
  "row.poolNoCandidateHint": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要重新登录。",
@@ -4319,6 +5128,12 @@ window.__ModuleLoader__.load({
4319
5128
  "row.poolCurrentHeader": "当前使用:{account}",
4320
5129
  "row.poolNotMember": "未入池",
4321
5130
  "row.poolUnsavedMembers": "勾选已改动 —— 保存后生效",
5131
+ "row.poolRowTest": "测试",
5132
+ "row.poolRowRemove": "删除",
5133
+ "row.poolRowRemoveHint": "删除插件保存的这个账号凭据。桌面 App 的登录不会被删除——下次扫描它还会出现;只有插件自己的副本会被移除。",
5134
+ "row.poolRowRemoveAria": "删除 {accountName} 的已存凭据",
5135
+ "row.poolRowRemoved": "{accountName}:已从插件存储中移除。",
5136
+ "row.poolRowRemoveDesktopOnly": "{accountName}:没有插件存储的凭据可删——这个账号来自桌面 App 自己的登录。",
4322
5137
  "row.showCreditsCn": "在对话框显示 WB CN 积分",
4323
5138
  "row.showCreditsGlobal": "在对话框显示 WB AI 积分",
4324
5139
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",