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/CHANGELOG.md +79 -0
- package/README.md +1 -0
- package/lib/client.js +517 -0
- package/lib/index.d.ts +42 -0
- package/lib/index.js +526 -26
- package/package.json +1 -1
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. */
|