dsh-connect-trae 3.5.1 → 3.6.1

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
@@ -48,6 +48,17 @@ window.__ModuleLoader__.load({
48
48
  * account row.
49
49
  */
50
50
  const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
51
+ /**
52
+ * Plugin-owned account transfer endpoint: batch export and import of a region's
53
+ * stored credentials (issue #37).
54
+ *
55
+ * One route with an `action` query parameter (`export` | `preview` | `import`),
56
+ * mirroring the sibling `dsh-connect-workbuddy` plugin's transfer route so the
57
+ * two cards behave the same way for a user who has both installed. The vault
58
+ * document never leaves the Host: the browser receives parsed answers, and a
59
+ * preview carries display fields only — never token material.
60
+ */
61
+ const TRAE_TRANSFER_PATH = "/plugins/dsh-connect-trae/transfer";
51
62
  /** Query parameter naming the region a card request addresses. */
52
63
  const TRAE_REGION_PARAM = "region";
53
64
  /** Every region, in card tab order. */
@@ -60,6 +71,18 @@ window.__ModuleLoader__.load({
60
71
  return `${path}?${TRAE_REGION_PARAM}=${region}`;
61
72
  }
62
73
  /**
74
+ * Address one region's transfer route with its step, e.g.
75
+ * `/plugins/dsh-connect-trae/transfer?region=cn&action=export`.
76
+ *
77
+ * Separate from {@link withTraeRegion} because this route carries a second
78
+ * query parameter: a caller that appended `&action=` by hand would be one typo
79
+ * away from a 400, which is exactly the failure the sibling workbuddy plugin
80
+ * hit (#42) when five requests dropped that parameter.
81
+ */
82
+ function withTraeTransferAction(path, region, action) {
83
+ return `${path}?${TRAE_REGION_PARAM}=${region}&action=${action}`;
84
+ }
85
+ /**
63
86
  * Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
64
87
  * volatile-marked settings field as (see {@link unwrapVolatileDeep}).
65
88
  */
@@ -312,6 +335,13 @@ window.__ModuleLoader__.load({
312
335
  .dsm-trae-usage-account{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:14px;background:var(--dsw-alias-bg-layer-2,#24262c)}
313
336
  .dsm-trae-usage-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
314
337
  .dsm-trae-usage-expiry{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
338
+ /* Refresh-token countdown (issue #37 follow-up). Sits under the access-token
339
+ line and aligns with it, so the two read as one stack: "token renews" then
340
+ "but this is when renewal itself ends". Only rendered when it is close or
341
+ already past, so a healthy account is unchanged. */
342
+ .dsm-trae-refresh-warn{padding-left:19px;font-size:12px;line-height:18px}
343
+ .dsm-trae-refresh-warn-soon{color:var(--dsw-alias-state-warn-primary,#b45309)}
344
+ .dsm-trae-refresh-warn-lapsed{color:var(--dsw-alias-state-error-primary,#dc2626)}
315
345
  .dsm-trae-account-picker{display:block}
316
346
  /* The 璁剧疆 block: label + hint on the left, control on the right, one save for
317
347
  the whole block. It replaces the bare checkbox rows that used to write on
@@ -482,6 +512,32 @@ window.__ModuleLoader__.load({
482
512
  .dsm-trae-expiry-week{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-primary,#e6e6e6)}
483
513
  .dsm-trae-expiry-lapsed{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-tertiary,#999)}
484
514
  .dsm-trae-expiry-detail{font-size:11px;line-height:16px;opacity:.8}
515
+ /* Account import / export (issue #37). A sub-block of the accounts section:
516
+ the transfer row, plus the two dialogs it opens in place. The file input is
517
+ hidden rather than absent — the button opens the system picker, and the input
518
+ must stay mounted to be clicked. */
519
+ .dsm-trae-transfer{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.2))}
520
+ .dsm-trae-transfer-label{margin:0 0 6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,GrayText)}
521
+ .dsm-trae-transfer-actions{display:flex;flex-wrap:wrap;gap:8px}
522
+ .dsm-trae-transfer-file{display:none}
523
+ .dsm-trae-transfer-status{margin:6px 0 0;font-size:11px;line-height:16px}
524
+ .dsm-trae-transfer-ok{color:var(--dsw-alias-state-success-primary,#16a34a)}
525
+ .dsm-trae-transfer-error{color:var(--dsw-alias-state-error-primary,#dc2626)}
526
+ .dsm-trae-transfer-dialog{
527
+ margin-top:8px;padding:10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));
528
+ border-radius:8px;font-size:12px;line-height:18px;
529
+ }
530
+ .dsm-trae-transfer-dialog>p{margin:0 0 6px}
531
+ .dsm-trae-transfer-list{list-style:none;margin:6px 0;padding:0;max-height:180px;overflow:auto}
532
+ .dsm-trae-transfer-list label{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer}
533
+ .dsm-trae-transfer-list input{flex:none}
534
+ .dsm-trae-transfer-dialog-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
535
+ /* A text-button rather than a boxed one: "select all" is a shortcut inside the
536
+ dialog, and a third button would compete with the two that act. */
537
+ .dsm-trae-transfer-link{
538
+ margin-top:6px;padding:0;border:0;background:none;cursor:pointer;
539
+ color:var(--dsw-alias-brand-primary,#2563eb);font-size:11px;line-height:16px;
540
+ }
485
541
  .dsm-trae-models-hint{margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
486
542
  /* Verdict badge. Only a real refusal is coloured as a warning; an unknown
487
543
  verdict stays neutral because an inconclusive probe is not evidence against
@@ -785,6 +841,368 @@ window.__ModuleLoader__.load({
785
841
  });
786
842
  }
787
843
  //#endregion
844
+ //#region src/client/AccountTransfer.tsx
845
+ /**
846
+ * The card's account-transfer section: batch export and import of a region's
847
+ * stored credentials, in the file format shared with sibling credential
848
+ * managers for the same upstream (the `dsh-connect-workbuddy` family).
849
+ *
850
+ * Modelled on that plugin's `AccountTransfer`, deliberately: the two plugins
851
+ * share one external-presentation language, and a user who has imported
852
+ * accounts in one should recognise the flow in the other. The shape is a single
853
+ * section owning all of its state (busy / result line / error), with the export
854
+ * dialog (pick accounts → download JSON) and the import dialog (pick file →
855
+ * desensitised preview → tick → import) split the same way.
856
+ *
857
+ * Two properties are load-bearing and must not be relaxed:
858
+ * - The preview carries display fields and a `hasToken` boolean only. Tokens
859
+ * never enter the browser at all until the user explicitly exports a chosen
860
+ * account, and the Host re-parses the file on import rather than trusting
861
+ * anything this component says about it.
862
+ * - Import is offered even when the region has NO account: a machine that has
863
+ * never signed in is exactly where importing someone else's export matters
864
+ * most. Only EXPORT needs an existing account to select.
865
+ *
866
+ * The download uses `<a download>` on a Blob because the card runs inside the
867
+ * settings iframe and has no host save dialog; a same-origin anchor download is
868
+ * the reliable path there.
869
+ *
870
+ * @module dsh-connect-trae/client/AccountTransfer
871
+ */
872
+ /** How long the veil may wait for the card's re-read before it comes down. */
873
+ const REFRESH_SETTLE_TIMEOUT_MS = 5e3;
874
+ /** Build the default export file name: trae-accounts-YYYY-MM-DD.json. */
875
+ function exportFileName() {
876
+ const pad = (value) => String(value).padStart(2, "0");
877
+ const now = /* @__PURE__ */ new Date();
878
+ return `trae-accounts-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}.json`;
879
+ }
880
+ /** Download a JSON value as a local file (no host save dialog inside the iframe). */
881
+ function downloadJson(fileName, data) {
882
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
883
+ const url = URL.createObjectURL(blob);
884
+ const anchor = document.createElement("a");
885
+ anchor.href = url;
886
+ anchor.download = fileName;
887
+ document.body.appendChild(anchor);
888
+ anchor.click();
889
+ anchor.remove();
890
+ URL.revokeObjectURL(url);
891
+ }
892
+ /** Resolve once `value` settles, or after `timeoutMs` — whichever comes first. */
893
+ async function settlesWithin(value, timeoutMs) {
894
+ await Promise.race([Promise.resolve(value).catch(() => void 0), new Promise((resolve) => {
895
+ setTimeout(resolve, timeoutMs);
896
+ })]);
897
+ }
898
+ function AccountTransfer({ t, region, accounts, onChanged }) {
899
+ const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
900
+ const [dialog, setDialog] = (0, react.useState)(void 0);
901
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
902
+ const [preview, setPreview] = (0, react.useState)(void 0);
903
+ const [importSelected, setImportSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
904
+ const fileInput = (0, react.useRef)(null);
905
+ /**
906
+ * The text of the file the user just picked, held here rather than re-read
907
+ * from the input at import time: the picker is a bare system dialog that
908
+ * closes immediately, so between preview and confirm no dialog holds the
909
+ * File. Re-reading `fileInput.current.files` would also break the moment
910
+ * anything clears the input — and it must be cleared after every pick, or
911
+ * choosing the SAME file twice fires no change event.
912
+ */
913
+ const importText = (0, react.useRef)("");
914
+ const mounted = (0, react.useRef)(true);
915
+ (0, react.useEffect)(() => {
916
+ mounted.current = true;
917
+ return () => {
918
+ mounted.current = false;
919
+ };
920
+ }, []);
921
+ const closeDialog = () => {
922
+ setDialog(void 0);
923
+ setSelected(/* @__PURE__ */ new Set());
924
+ setPreview(void 0);
925
+ setImportSelected(/* @__PURE__ */ new Set());
926
+ };
927
+ const runExport = async () => {
928
+ if (selected.size === 0) return;
929
+ setPhase({ kind: "exporting" });
930
+ try {
931
+ const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "export"), {
932
+ method: "POST",
933
+ headers: {
934
+ "content-type": "application/json",
935
+ accept: "application/json"
936
+ },
937
+ credentials: "same-origin",
938
+ body: JSON.stringify({ accountIds: [...selected] })
939
+ });
940
+ const body = await response.json();
941
+ if (!mounted.current) return;
942
+ if (!response.ok || !Array.isArray(body.accounts)) {
943
+ setPhase({
944
+ kind: "error",
945
+ message: body.error ?? `HTTP ${response.status}`
946
+ });
947
+ return;
948
+ }
949
+ const fileName = exportFileName();
950
+ downloadJson(fileName, body.accounts);
951
+ closeDialog();
952
+ setPhase({
953
+ kind: "done",
954
+ message: t("row.transferExported", {
955
+ count: body.accounts.length,
956
+ fileName
957
+ })
958
+ });
959
+ } catch (error) {
960
+ if (mounted.current) setPhase({
961
+ kind: "error",
962
+ message: error instanceof Error ? error.message : String(error)
963
+ });
964
+ }
965
+ };
966
+ const readImportFile = async (file) => {
967
+ setPhase({ kind: "importing" });
968
+ try {
969
+ const text = await file.text();
970
+ importText.current = text;
971
+ const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "preview"), {
972
+ method: "POST",
973
+ headers: {
974
+ "content-type": "application/json",
975
+ accept: "application/json"
976
+ },
977
+ credentials: "same-origin",
978
+ body: JSON.stringify({ text })
979
+ });
980
+ const body = await response.json();
981
+ if (!mounted.current) return;
982
+ if (!response.ok || !Array.isArray(body.accounts)) {
983
+ setPhase({
984
+ kind: "error",
985
+ message: body.error ?? `HTTP ${response.status}`
986
+ });
987
+ return;
988
+ }
989
+ setPreview(body);
990
+ setImportSelected(new Set(body.accounts.filter((entry) => entry.hasToken).map((entry) => entry.index)));
991
+ setPhase({ kind: "idle" });
992
+ } catch (error) {
993
+ if (mounted.current) setPhase({
994
+ kind: "error",
995
+ message: error instanceof Error ? error.message : String(error)
996
+ });
997
+ }
998
+ };
999
+ const runImport = async () => {
1000
+ if (preview === void 0 || importSelected.size === 0 || importText.current === "") return;
1001
+ setPhase({ kind: "importing" });
1002
+ try {
1003
+ const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "import"), {
1004
+ method: "POST",
1005
+ headers: {
1006
+ "content-type": "application/json",
1007
+ accept: "application/json"
1008
+ },
1009
+ credentials: "same-origin",
1010
+ body: JSON.stringify({
1011
+ text: importText.current,
1012
+ indexes: [...importSelected]
1013
+ })
1014
+ });
1015
+ const body = await response.json();
1016
+ if (!mounted.current) return;
1017
+ if (!response.ok || typeof body.imported !== "number") {
1018
+ setPhase({
1019
+ kind: "error",
1020
+ message: body.error ?? `HTTP ${response.status}`
1021
+ });
1022
+ return;
1023
+ }
1024
+ closeDialog();
1025
+ const message = t("row.transferImported", {
1026
+ imported: body.imported,
1027
+ skipped: body.skipped ?? 0
1028
+ });
1029
+ setPhase({
1030
+ kind: "refreshing",
1031
+ message
1032
+ });
1033
+ await settlesWithin(onChanged?.(), REFRESH_SETTLE_TIMEOUT_MS);
1034
+ if (!mounted.current) return;
1035
+ setPhase({
1036
+ kind: "done",
1037
+ message
1038
+ });
1039
+ } catch (error) {
1040
+ if (mounted.current) setPhase({
1041
+ kind: "error",
1042
+ message: error instanceof Error ? error.message : String(error)
1043
+ });
1044
+ }
1045
+ };
1046
+ const busy = phase.kind === "exporting" || phase.kind === "importing" || phase.kind === "refreshing";
1047
+ const veilMessage = phase.kind === "exporting" ? t("row.transferExporting") : phase.kind === "importing" || phase.kind === "refreshing" ? t("row.transferImporting") : "";
1048
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1049
+ className: "dsm-trae-transfer",
1050
+ "aria-label": t("row.transferTitle"),
1051
+ children: [
1052
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
1053
+ visible: busy,
1054
+ message: veilMessage,
1055
+ ...busy ? { hint: t("row.transferVeilHint") } : {}
1056
+ }),
1057
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1058
+ className: "dsm-trae-transfer-label",
1059
+ children: t("row.transferTitle")
1060
+ }),
1061
+ phase.kind === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1062
+ className: "dsm-trae-transfer-status dsm-trae-transfer-ok",
1063
+ children: phase.message
1064
+ }) : phase.kind === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1065
+ className: "dsm-trae-transfer-status dsm-trae-transfer-error",
1066
+ children: t("row.transferError", { message: phase.message })
1067
+ }) : null,
1068
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1069
+ className: "dsm-trae-transfer-actions",
1070
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1071
+ type: "button",
1072
+ className: "dsm-btn dsm-btn-outline",
1073
+ disabled: busy || accounts.length === 0,
1074
+ onClick: () => {
1075
+ setSelected(/* @__PURE__ */ new Set());
1076
+ setDialog("export");
1077
+ },
1078
+ children: t("row.transferExport")
1079
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1080
+ type: "button",
1081
+ className: "dsm-btn dsm-btn-outline",
1082
+ disabled: busy,
1083
+ onClick: () => {
1084
+ fileInput.current?.click();
1085
+ },
1086
+ children: t("row.transferImport")
1087
+ })]
1088
+ }),
1089
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1090
+ ref: fileInput,
1091
+ type: "file",
1092
+ accept: "application/json,.json",
1093
+ className: "dsm-trae-transfer-file",
1094
+ onChange: (event) => {
1095
+ const file = event.target.files?.[0];
1096
+ event.target.value = "";
1097
+ if (file !== void 0) {
1098
+ setPreview(void 0);
1099
+ setImportSelected(/* @__PURE__ */ new Set());
1100
+ setDialog("import");
1101
+ readImportFile(file);
1102
+ }
1103
+ }
1104
+ }),
1105
+ dialog === "export" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1106
+ className: "dsm-trae-transfer-dialog",
1107
+ role: "dialog",
1108
+ "aria-label": t("row.transferExport"),
1109
+ children: [
1110
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferExportPick", { total: accounts.length }) }),
1111
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1112
+ className: "dsm-trae-hint",
1113
+ children: t("row.transferHint")
1114
+ }),
1115
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1116
+ className: "dsm-trae-transfer-list",
1117
+ children: accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1118
+ type: "checkbox",
1119
+ checked: selected.has(account.id),
1120
+ onChange: (event) => {
1121
+ const next = new Set(selected);
1122
+ if (event.target.checked) next.add(account.id);
1123
+ else next.delete(account.id);
1124
+ setSelected(next);
1125
+ }
1126
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: account.accountName })] }) }, account.id))
1127
+ }),
1128
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1129
+ className: "dsm-trae-transfer-dialog-actions",
1130
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1131
+ type: "button",
1132
+ className: "dsm-btn",
1133
+ disabled: selected.size === 0,
1134
+ onClick: () => {
1135
+ runExport();
1136
+ },
1137
+ children: t("row.transferExportConfirm", { count: selected.size })
1138
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1139
+ type: "button",
1140
+ className: "dsm-btn dsm-btn-outline",
1141
+ onClick: closeDialog,
1142
+ children: t("row.transferCancel")
1143
+ })]
1144
+ }),
1145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1146
+ type: "button",
1147
+ className: "dsm-trae-transfer-link",
1148
+ onClick: () => setSelected(new Set(accounts.map((account) => account.id))),
1149
+ children: t("row.transferSelectAll")
1150
+ })
1151
+ ]
1152
+ }) : null,
1153
+ dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1154
+ className: "dsm-trae-transfer-dialog",
1155
+ role: "dialog",
1156
+ "aria-label": t("row.transferImport"),
1157
+ children: preview === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferReading") }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1158
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferImportPick", { total: preview.total }) }),
1159
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1160
+ className: "dsm-trae-transfer-list",
1161
+ children: preview.accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1162
+ type: "checkbox",
1163
+ checked: importSelected.has(entry.index),
1164
+ disabled: !entry.hasToken,
1165
+ onChange: (event) => {
1166
+ const next = new Set(importSelected);
1167
+ if (event.target.checked) next.add(entry.index);
1168
+ else next.delete(entry.index);
1169
+ setImportSelected(next);
1170
+ }
1171
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1172
+ entry.nickname || entry.uid || t("row.transferUnnamed"),
1173
+ entry.email === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1174
+ className: "dsm-trae-hint",
1175
+ children: [" · ", entry.email]
1176
+ }),
1177
+ entry.hasToken ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1178
+ className: "dsm-trae-hint",
1179
+ children: [" · ", t("row.transferNoToken")]
1180
+ })
1181
+ ] })] }) }, entry.index))
1182
+ }),
1183
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1184
+ className: "dsm-trae-transfer-dialog-actions",
1185
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1186
+ type: "button",
1187
+ className: "dsm-btn",
1188
+ disabled: importSelected.size === 0,
1189
+ onClick: () => {
1190
+ runImport();
1191
+ },
1192
+ children: t("row.transferImportConfirm", { count: importSelected.size })
1193
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1194
+ type: "button",
1195
+ className: "dsm-btn dsm-btn-outline",
1196
+ onClick: closeDialog,
1197
+ children: t("row.transferCancel")
1198
+ })]
1199
+ })
1200
+ ] })
1201
+ }) : null
1202
+ ]
1203
+ });
1204
+ }
1205
+ //#endregion
788
1206
  //#region src/client/plan-text.ts
789
1207
  /**
790
1208
  * The international region's "what plan is this account on" text.
@@ -895,6 +1313,40 @@ window.__ModuleLoader__.load({
895
1313
  minute: "2-digit"
896
1314
  }).format(new Date(value));
897
1315
  }
1316
+ /** How close a refresh token must be to lapsing before the card warns, in days. */
1317
+ const REFRESH_WARN_DAYS = 30;
1318
+ /**
1319
+ * The refresh-token warning for one account, or undefined when there is nothing
1320
+ * worth saying.
1321
+ *
1322
+ * The access token renews itself every few minutes, so its own expiry answers
1323
+ * "when does this need attention" with "never, until the refresh token lapses".
1324
+ * The refresh token is therefore the only honest countdown on this card, and it
1325
+ * has to be shown BEFORE it runs out: once it lapses the plugin cannot renew at
1326
+ * all and the only recovery is signing in to Trae again, which a user who has
1327
+ * been reading "auto-renewal" for months has no reason to expect.
1328
+ *
1329
+ * Three tones, matching the credit-expiry notice: `lapsed` (renewal already
1330
+ * dead), `soon` (inside the warning window), and nothing otherwise — a healthy
1331
+ * token must not add a line to every account row forever.
1332
+ */
1333
+ function refreshTokenWarning(expiresAtMs, nowMs, t, formatTime) {
1334
+ if (expiresAtMs === void 0) return void 0;
1335
+ const text = formatTime(expiresAtMs);
1336
+ if (expiresAtMs <= nowMs) return {
1337
+ tone: "lapsed",
1338
+ text: t("row.refreshTokenLapsed", { expiresAt: text })
1339
+ };
1340
+ const days = Math.ceil((expiresAtMs - nowMs) / 864e5);
1341
+ if (days > REFRESH_WARN_DAYS) return void 0;
1342
+ return {
1343
+ tone: "soon",
1344
+ text: t("row.refreshTokenExpiring", {
1345
+ expiresAt: text,
1346
+ days
1347
+ })
1348
+ };
1349
+ }
898
1350
  function formatCapacity(value, unknown) {
899
1351
  if (value === void 0) return unknown;
900
1352
  if (value >= 1e6 && value % 1e6 === 0) return `${value / 1e6}M`;
@@ -2164,6 +2616,14 @@ window.__ModuleLoader__.load({
2164
2616
  sourceLabel(account.source)
2165
2617
  ]
2166
2618
  }),
2619
+ (() => {
2620
+ const warning = refreshTokenWarning(account.refreshExpiresAtMs, Date.now(), t, formatDateTime);
2621
+ return warning === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2622
+ className: `dsm-trae-refresh-warn dsm-trae-refresh-warn-${warning.tone}`,
2623
+ role: "status",
2624
+ children: warning.text
2625
+ });
2626
+ })(),
2167
2627
  error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2168
2628
  className: "dsm-trae-account-note",
2169
2629
  role: "alert",
@@ -2246,6 +2706,21 @@ window.__ModuleLoader__.load({
2246
2706
  className: "dsm-trae-usage-note",
2247
2707
  role: "status",
2248
2708
  children: rescanNote
2709
+ }),
2710
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountTransfer, {
2711
+ t,
2712
+ region: activeRegion,
2713
+ accounts: status.status === "signed-in" ? status.accounts.map((account) => ({
2714
+ id: account.id,
2715
+ accountName: account.accountName
2716
+ })) : [],
2717
+ onChanged: async () => {
2718
+ const fresh = await refreshUsage(activeRegion);
2719
+ if (fresh !== void 0) setStatusByRegion((current) => ({
2720
+ ...current,
2721
+ [activeRegion]: fresh
2722
+ }));
2723
+ }
2249
2724
  })
2250
2725
  ]
2251
2726
  }),
@@ -3191,6 +3666,8 @@ window.__ModuleLoader__.load({
3191
3666
  "row.sourceCli": "Trae CLI",
3192
3667
  "row.signedIn": "Signed in: {accountName}",
3193
3668
  "row.tokenExpiry": "Access token expires {expiresAt} (auto-renewal)",
3669
+ "row.refreshTokenExpiring": "Sign in to Trae again by {expiresAt} ({days} day(s) left) — automatic renewal stops after that.",
3670
+ "row.refreshTokenLapsed": "Automatic renewal has stopped: the Trae sign-in expired {expiresAt}. Sign in to Trae again to restore it.",
3194
3671
  "row.requestFailed": "Request failed",
3195
3672
  "row.creditsAvailableLabel": "Available",
3196
3673
  "row.workCreditsLabel": "Work credits",
@@ -3225,6 +3702,25 @@ window.__ModuleLoader__.load({
3225
3702
  "row.refreshing": "Refreshing…",
3226
3703
  "row.accountsTitle": "Trae account",
3227
3704
  "row.accountsHint": "Sign-ins detected on this machine. Tokens are never shown or saved.",
3705
+ "row.transferTitle": "Import & export accounts",
3706
+ "row.transferExport": "Export selected",
3707
+ "row.transferImport": "Import from file",
3708
+ "row.transferCancel": "Cancel",
3709
+ "row.transferSelectAll": "Select all",
3710
+ "row.transferExportPick": "Choose which of the {total} accounts to export.",
3711
+ "row.transferExportConfirm": "Export {count}",
3712
+ "row.transferExported": "Exported {count} account(s) to {fileName}.",
3713
+ "row.transferExporting": "Preparing the export…",
3714
+ "row.transferImportPick": "This file holds {total} record(s). Untick anything you do not want to add.",
3715
+ "row.transferImportConfirm": "Import {count}",
3716
+ "row.transferImported": "Imported {imported} account(s), skipped {skipped}.",
3717
+ "row.transferImporting": "Importing…",
3718
+ "row.transferReading": "Reading the file…",
3719
+ "row.transferHint": "The exported file contains live tokens — treat it as a password. Records belonging to the other region are skipped.",
3720
+ "row.transferError": "Import/export failed: {message}",
3721
+ "row.transferVeilHint": "The account list refreshes when this finishes.",
3722
+ "row.transferUnnamed": "Unnamed account",
3723
+ "row.transferNoToken": "no token, will be skipped",
3228
3724
  "row.accountsRescan": "Detect accounts again",
3229
3725
  "row.accountsScanning": "Detecting…",
3230
3726
  "row.accountsNone": "This region reports no usable account.",
@@ -3338,6 +3834,8 @@ window.__ModuleLoader__.load({
3338
3834
  "row.sourceCli": "Trae CLI",
3339
3835
  "row.signedIn": "已登录:{accountName}",
3340
3836
  "row.tokenExpiry": "访问令牌 {expiresAt} 过期(自动续期)",
3837
+ "row.refreshTokenExpiring": "请在 {expiresAt} 前重新登录一次 Trae(还剩 {days} 天),此后将无法自动续期。",
3838
+ "row.refreshTokenLapsed": "自动续期已停止:Trae 登录凭据已于 {expiresAt} 过期。请重新登录一次 Trae 以恢复。",
3341
3839
  "row.requestFailed": "请求失败",
3342
3840
  "row.creditsAvailableLabel": "可用",
3343
3841
  "row.workCreditsLabel": "Work 积分",
@@ -3372,6 +3870,25 @@ window.__ModuleLoader__.load({
3372
3870
  "row.refreshing": "正在刷新…",
3373
3871
  "row.accountsTitle": "Trae 账号",
3374
3872
  "row.accountsHint": "本机检测到的登录账号;Token 不会显示或保存。",
3873
+ "row.transferTitle": "导入 / 导出账号",
3874
+ "row.transferExport": "导出所选",
3875
+ "row.transferImport": "从文件导入",
3876
+ "row.transferCancel": "取消",
3877
+ "row.transferSelectAll": "全选",
3878
+ "row.transferExportPick": "从这 {total} 个账号中选择要导出的。",
3879
+ "row.transferExportConfirm": "导出 {count} 个",
3880
+ "row.transferExported": "已导出 {count} 个账号到 {fileName}。",
3881
+ "row.transferExporting": "正在准备导出…",
3882
+ "row.transferImportPick": "该文件包含 {total} 条记录,取消勾选不想添加的。",
3883
+ "row.transferImportConfirm": "导入 {count} 个",
3884
+ "row.transferImported": "已导入 {imported} 个账号,跳过 {skipped} 个。",
3885
+ "row.transferImporting": "正在导入…",
3886
+ "row.transferReading": "正在读取文件…",
3887
+ "row.transferHint": "导出的文件里是有效 Token,请当作密码保管。属于另一区域的记录会被跳过。",
3888
+ "row.transferError": "导入/导出失败:{message}",
3889
+ "row.transferVeilHint": "完成后账号列表会自动刷新。",
3890
+ "row.transferUnnamed": "未命名账号",
3891
+ "row.transferNoToken": "无 Token,将被跳过",
3375
3892
  "row.accountsRescan": "重新检测账号",
3376
3893
  "row.accountsScanning": "正在检测…",
3377
3894
  "row.accountsNone": "该区域没有可列出的账号。",
package/lib/index.d.ts CHANGED
@@ -590,6 +590,13 @@ interface TraeAccountChoice {
590
590
  region: TraeRegion;
591
591
  source: 'desktop' | 'dsh' | 'cli';
592
592
  tokenExpiresAtMs: number;
593
+ /**
594
+ * When this account's refresh token lapses, when the credential carries one.
595
+ * The access token above renews itself automatically until this expires; see
596
+ * the mirror field on `TraeWebAccount` in status-paths.ts for why the card
597
+ * needs it.
598
+ */
599
+ refreshExpiresAtMs?: number;
593
600
  selected: boolean;
594
601
  }
595
602
  /**
@@ -698,6 +705,22 @@ declare class TraeCredentialStore {
698
705
  * fallback, for the same reason {@link current} refuses to fall back.
699
706
  */
700
707
  credentialOf(accountId: string): Promise<TraeCredential | undefined>;
708
+ /**
709
+ * Store imported credentials (issue #37), replacing any entry with the same
710
+ * account id and leaving every other entry alone.
711
+ *
712
+ * Why an additive write rather than the refresh path: an imported credential
713
+ * has NO desktop file behind it, so unlike a refresh result it cannot be
714
+ * rediscovered from the machine. If it were written the way a refresh is —
715
+ * as the whole document — importing account B would delete account A, and a
716
+ * later refresh would delete both. `readAll`'s desktop-first preference is
717
+ * therefore also not enough on its own: these entries survive only because
718
+ * `readOwns` reads them back and the dedupe keeps the longest-lived copy.
719
+ *
720
+ * Returns the account ids actually written, so the caller can report how many
721
+ * landed without re-reading the store.
722
+ */
723
+ importAccounts(credentials: readonly Omit<TraeCredential, 'source'>[]): Promise<string[]>;
701
724
  resolve(): Promise<TraeCredential>;
702
725
  status(): Promise<{
703
726
  state: 'signed-in' | 'signed-out';
@@ -1986,6 +2009,18 @@ interface TraeWebAccount {
1986
2009
  region: TraeRegion;
1987
2010
  source: 'desktop' | 'dsh' | 'cli';
1988
2011
  tokenExpiresAtMs: number;
2012
+ /**
2013
+ * When this account's REFRESH token expires, when the credential carries one.
2014
+ *
2015
+ * The access token above is renewed automatically — the plugin refreshes it
2016
+ * five minutes before it lapses — so by itself it answers "when does this
2017
+ * need attention", which is "never, while the refresh token lives". The
2018
+ * refresh token is the real horizon: once IT expires the automatic renewal
2019
+ * stops working and the user must sign in to Trae again. Before this field the
2020
+ * card could only say "auto-renewal", which stays true right up to the moment
2021
+ * it silently stops being true.
2022
+ */
2023
+ refreshExpiresAtMs?: number;
1989
2024
  selected: boolean;
1990
2025
  }
1991
2026
  /**
@@ -2212,6 +2247,13 @@ type TraeWebUsage = {
2212
2247
  accountId: string;
2213
2248
  accountName: string;
2214
2249
  tokenExpiresAtMs: number;
2250
+ /**
2251
+ * When the BOUND account's refresh token expires (absent when the credential
2252
+ * carries none). See {@link TraeWebAccount.refreshExpiresAtMs} — this is the
2253
+ * horizon that actually matters, because the access token above renews
2254
+ * itself until this one lapses.
2255
+ */
2256
+ refreshExpiresAtMs?: number;
2215
2257
  /** Which per-region model directory and selection this account owns. */
2216
2258
  region: TraeRegion;
2217
2259
  /** Whether this region's provider is currently offered to DSH. */