@jx3box/jx3box-ui 2.3.14 → 2.3.16

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.
@@ -41,6 +41,16 @@ PC 公共库存在马甲切换:
41
41
 
42
42
  否则用户切换马甲时可能拿回旧 token,或者后续请求继续优先使用旧 `__token`。
43
43
 
44
+ 马甲切换时必须先将目标马甲的 token 同步到 `localStorage.__token`;如果当前标签页存在
45
+ `sessionStorage.__token`,也要一起更新,然后才能调用 refresh 接口。退出登录(包括
46
+ `token_version` 触发的强制退出)时必须同时清理这两个 `__token`。否则业务页可能从普通
47
+ `uid` 读取新账号,而接口仍携带旧账号的高优先级 token,最终在同一页面显示两个 UID。
48
+
49
+ 新增马甲跳转登录页前也要先清理两个 `__token`,因为账号页登录成功默认只更新普通
50
+ `token/uid`。公共头拿到 `/user/my/info` 后,如果接口 UID 与本地 UID 不一致、当前又不是
51
+ 显式 `?__token=` 入口,应清除遗留覆盖 token 并刷新一次完成自愈。显式 URL token 入口则
52
+ 强制执行一次 refresh,把普通用户缓存归一到 URL token 对应的账号;UID 发生变化时刷新页面。
53
+
44
54
  ## 自动续期开关
45
55
 
46
56
  默认开启。可通过 localStorage 关闭:
@@ -51,6 +61,9 @@ localStorage.setItem("jx3box:auto_refresh_token", "false");
51
61
 
52
62
  值为 `"false"` 或 `"0"` 时不自动续期;其它情况按默认开启处理。
53
63
 
64
+ 该开关只控制后台自动续期。马甲切换和显式 URL token 身份归一属于用户主动登录流程,使用
65
+ `force` 刷新,不受此开关影响。
66
+
54
67
  ## 失败语义
55
68
 
56
69
  自动续期失败不主动登出,也不阻断公共头渲染。后续业务请求如果发现 token 过期或无权限,仍按宿主项目既有鉴权失败逻辑处理。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jx3box/jx3box-ui",
3
- "version": "2.3.14",
3
+ "version": "2.3.16",
4
4
  "description": "JX3BOX Vue3 UI",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -84,7 +84,7 @@ import miniprogram from "@jx3box/jx3box-common/data/miniprogram.json";
84
84
  // 数据
85
85
  import { getGlobalConfig } from "../service/header";
86
86
  import { getConfig, getMyAccountStatus } from "../service/cms";
87
- import { refreshTokenIfNeeded } from "./utils/auth-token-refresh";
87
+ import { clearActiveAuthToken, refreshTokenIfNeeded } from "./utils/auth-token-refresh";
88
88
  import User from "@jx3box/jx3box-common/js/user.js";
89
89
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
90
90
 
@@ -194,7 +194,7 @@ export default {
194
194
 
195
195
  if (User.isLogin()) {
196
196
  this.loadAsset();
197
- this.refreshAuthToken();
197
+ this.refreshAuthToken({ force: !!token, reloadOnIdentityChange: !!token });
198
198
  this.initAccountReadyState();
199
199
  }
200
200
 
@@ -209,23 +209,27 @@ export default {
209
209
  // 先保存最新的token_version
210
210
  localStorage.setItem("token_version", global_token_version);
211
211
  // 然后执行登出操作
212
- User.destroy().then(() => {
213
- this.$refs.user?.logout();
214
- // 清除马甲所有马甲信息
215
- let keys = Object.keys(localStorage);
216
- let alternate = keys.filter((key) => key.startsWith("jx3box-alternate-"));
217
-
218
- alternate.forEach((key) => {
219
- localStorage.removeItem(key);
212
+ User.destroy()
213
+ .finally(() => {
214
+ clearActiveAuthToken();
215
+ })
216
+ .then(() => {
217
+ this.$refs.user?.logout();
218
+ // 清除马甲所有马甲信息
219
+ let keys = Object.keys(localStorage);
220
+ let alternate = keys.filter((key) => key.startsWith("jx3box-alternate-"));
221
+
222
+ alternate.forEach((key) => {
223
+ localStorage.removeItem(key);
224
+ });
225
+
226
+ if (
227
+ location.pathname.startsWith("/dashboard") ||
228
+ location.pathname.startsWith("/publish")
229
+ ) {
230
+ location.href = this.siteRoot;
231
+ }
220
232
  });
221
-
222
- if (
223
- location.pathname.startsWith("/dashboard") ||
224
- location.pathname.startsWith("/publish")
225
- ) {
226
- location.href = this.siteRoot;
227
- }
228
- });
229
233
  }
230
234
  } else {
231
235
  // 非登录状态也更新token_version,确保用户下次登录时使用新版本
@@ -236,10 +240,23 @@ export default {
236
240
  });
237
241
  },
238
242
 
239
- refreshAuthToken: function () {
240
- refreshTokenIfNeeded().catch(() => {
241
- // 自动续期失败不影响公共头渲染,后续鉴权请求会按既有逻辑处理登录态。
242
- });
243
+ refreshAuthToken: function ({ force = false, reloadOnIdentityChange = false } = {}) {
244
+ const previousUid = User.getInfo()?.uid;
245
+ return refreshTokenIfNeeded({ force })
246
+ .then((refreshed) => {
247
+ if (
248
+ refreshed &&
249
+ reloadOnIdentityChange &&
250
+ String(previousUid || "") !== String(User.getInfo()?.uid || "")
251
+ ) {
252
+ location.reload();
253
+ }
254
+ return refreshed;
255
+ })
256
+ .catch(() => {
257
+ // 自动续期失败不影响公共头渲染,后续鉴权请求会按既有逻辑处理登录态。
258
+ return false;
259
+ });
243
260
  },
244
261
 
245
262
  handleVisibilityChange: function () {
@@ -56,7 +56,7 @@ export default {
56
56
  methods: {
57
57
  showAvatar: function (val) {
58
58
  const size = typeof this.size === "number" ? this.size : this.styles[this.size];
59
- return showAvatar(val, size);
59
+ return showAvatar(val, size * 2);
60
60
  },
61
61
  authorLink,
62
62
  },
@@ -47,7 +47,7 @@ import { showAvatar } from "@jx3box/jx3box-common/js/utils";
47
47
  import dayjs from "dayjs";
48
48
  import User from "@jx3box/jx3box-common/js/user";
49
49
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
50
- import { refreshAuth } from "../../service/cms";
50
+ import { clearActiveAuthToken, refreshTokenIfNeeded, syncActiveAuthToken } from "../utils/auth-token-refresh";
51
51
  import i18nMixin from "../../i18n/mixin";
52
52
  export default {
53
53
  name: "AlternateSwitch",
@@ -162,8 +162,11 @@ export default {
162
162
  type: "warning",
163
163
  }
164
164
  )
165
- .then(() => {
166
- User.update(item).then(async () => {
165
+ .then(async () => {
166
+ await User.update(item);
167
+ // 请求层优先读取 __token;必须与刚切换的马甲 token 同步,避免继续请求旧账号。
168
+ syncActiveAuthToken(item.token);
169
+ try {
167
170
  localStorage.setItem(
168
171
  "jx3box-alternate-" + item.uid,
169
172
  JSON.stringify({
@@ -171,10 +174,11 @@ export default {
171
174
  created_at: Number(localStorage.getItem("created_at")),
172
175
  })
173
176
  );
174
- await refreshAuth();
177
+ await refreshTokenIfNeeded({ force: true });
178
+ } finally {
175
179
  location.reload();
176
180
  this.visible = false;
177
- });
181
+ }
178
182
  })
179
183
  .catch(() => {});
180
184
  },
@@ -202,6 +206,8 @@ export default {
202
206
  this.$message.error(this.$jx3boxT("jx3boxUi.commonHeader.alternate.maxFive", "最多只能添加5个马甲"));
203
207
  return;
204
208
  }
209
+ // 新账号登录只会写普通 token;先清掉当前账号的高优先级 token,避免登录完成后仍请求旧账号。
210
+ clearActiveAuthToken();
205
211
  // 跳转至登录页
206
212
  location.href = JX3BOX.__Links.account.login + "?alternate=1";
207
213
  },
@@ -129,6 +129,7 @@ import { getMenu } from "../../service/header";
129
129
  import Bus from "../../utils/bus";
130
130
  import alternate from "./alternate.vue";
131
131
  import i18nMixin from "../../i18n/mixin";
132
+ import { clearActiveAuthToken, hasActiveAuthTokenOverride } from "../utils/auth-token-refresh";
132
133
 
133
134
  const { __Links, __Root, __imgPath, __OriginRoot } = JX3BOX;
134
135
  export default {
@@ -211,6 +212,9 @@ export default {
211
212
  showAvatar,
212
213
  logout: function (mute = false) {
213
214
  User.destroy()
215
+ .finally(() => {
216
+ clearActiveAuthToken();
217
+ })
214
218
  .then(() => {
215
219
  this.$emit("logout");
216
220
 
@@ -239,9 +243,29 @@ export default {
239
243
  showUserName: function (val) {
240
244
  return val || this.$jx3boxT("jx3boxUi.commonHeader.anonymous", "匿名");
241
245
  },
246
+ repairMismatchedIdentity: function (data = {}) {
247
+ const cachedUid = Number(User.getInfo()?.uid || 0);
248
+ const requestUid = Number(data.ID || 0);
249
+ const hasUrlToken = new URLSearchParams(location.search).has("__token");
250
+
251
+ if (
252
+ cachedUid &&
253
+ requestUid &&
254
+ cachedUid !== requestUid &&
255
+ hasActiveAuthTokenOverride() &&
256
+ !hasUrlToken
257
+ ) {
258
+ clearActiveAuthToken();
259
+ location.reload();
260
+ return true;
261
+ }
262
+ return false;
263
+ },
242
264
  loadMyInfo: function () {
243
265
  getMyInfo()
244
266
  .then((data) => {
267
+ // 普通 uid 与接口身份不一致时,优先清除遗留的高优先级 token 并重新请求一次。
268
+ if (this.repairMismatchedIdentity(data)) return;
245
269
  this.user = data;
246
270
  this.isSuperAuthor = !!data.sign;
247
271
  this.isTeammate = this.user?.is_teammate;
@@ -11,6 +11,42 @@ function canUseLocalStorage() {
11
11
  return typeof localStorage !== "undefined";
12
12
  }
13
13
 
14
+ function canUseSessionStorage() {
15
+ return typeof sessionStorage !== "undefined";
16
+ }
17
+
18
+ export function syncActiveAuthToken(token = "") {
19
+ if (!token) {
20
+ return false;
21
+ }
22
+
23
+ if (canUseLocalStorage()) {
24
+ localStorage.setItem("__token", token);
25
+ }
26
+
27
+ // URL token 会被 common 缓存在 sessionStorage,存在时也必须随账号切换一起更新。
28
+ if (canUseSessionStorage() && sessionStorage.getItem("__token")) {
29
+ sessionStorage.setItem("__token", token);
30
+ }
31
+
32
+ return true;
33
+ }
34
+
35
+ export function hasActiveAuthTokenOverride() {
36
+ const localToken = canUseLocalStorage() ? localStorage.getItem("__token") : "";
37
+ const sessionToken = canUseSessionStorage() ? sessionStorage.getItem("__token") : "";
38
+ return !!(localToken || sessionToken);
39
+ }
40
+
41
+ export function clearActiveAuthToken() {
42
+ if (canUseLocalStorage()) {
43
+ localStorage.removeItem("__token");
44
+ }
45
+ if (canUseSessionStorage()) {
46
+ sessionStorage.removeItem("__token");
47
+ }
48
+ }
49
+
14
50
  function decodeBase64Url(value = "") {
15
51
  const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/");
16
52
  const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
@@ -120,8 +156,8 @@ async function applyRefreshResult(data = {}) {
120
156
 
121
157
  await User.update(profile);
122
158
 
123
- // @jx3box/common User.update only writes token; keep __token in sync because common API reads it first.
124
- localStorage.setItem("__token", profile.token);
159
+ // @jx3box/common User.update only writes token; keep higher-priority request tokens in sync too.
160
+ syncActiveAuthToken(profile.token);
125
161
  updateCurrentAlternate(profile);
126
162
  return true;
127
163
  }
@@ -129,7 +165,7 @@ async function applyRefreshResult(data = {}) {
129
165
  export async function refreshTokenIfNeeded(options = {}) {
130
166
  const { force = false } = options;
131
167
 
132
- if (!User.isLogin() || !isAutoTokenRefreshEnabled()) {
168
+ if (!User.isLogin() || (!force && !isAutoTokenRefreshEnabled())) {
133
169
  return false;
134
170
  }
135
171
 
@@ -3,6 +3,8 @@ const path = require("path");
3
3
 
4
4
  const root = path.resolve(__dirname, "..");
5
5
  const commonHeader = fs.readFileSync(path.join(root, "src/CommonHeader.vue"), "utf8");
6
+ const alternate = fs.readFileSync(path.join(root, "src/header/alternate.vue"), "utf8");
7
+ const userInfo = fs.readFileSync(path.join(root, "src/header/userInfo.vue"), "utf8");
6
8
  const cmsService = fs.readFileSync(path.join(root, "service/cms.js"), "utf8");
7
9
  const refreshUtil = fs.readFileSync(path.join(root, "src/utils/auth-token-refresh.js"), "utf8");
8
10
  const docs = fs.readFileSync(path.join(root, "docs/agents/auth-token-refresh.md"), "utf8");
@@ -32,16 +34,54 @@ assert(
32
34
  "minimum refresh interval should be 1 day"
33
35
  );
34
36
 
37
+ assert(
38
+ refreshUtil.includes("(!force && !isAutoTokenRefreshEnabled())"),
39
+ "explicit account switching and URL-token normalization should bypass the automatic refresh toggle"
40
+ );
41
+
35
42
  assert(
36
43
  refreshUtil.includes('localStorage.getItem("__token") || localStorage.getItem("token")'),
37
44
  "refresh should read __token before token"
38
45
  );
39
46
 
40
47
  assert(
41
- refreshUtil.includes("localStorage.setItem(\"__token\", profile.token)"),
48
+ refreshUtil.includes("syncActiveAuthToken(profile.token)"),
42
49
  "refresh should keep __token in sync"
43
50
  );
44
51
 
52
+ assert(
53
+ refreshUtil.includes('sessionStorage.getItem("__token")') &&
54
+ refreshUtil.includes('sessionStorage.setItem("__token", token)'),
55
+ "refresh should update an existing URL token stored in sessionStorage"
56
+ );
57
+
58
+ assert(
59
+ alternate.includes("syncActiveAuthToken(item.token)") &&
60
+ alternate.indexOf("syncActiveAuthToken(item.token)") < alternate.indexOf("refreshTokenIfNeeded({ force: true })"),
61
+ "alternate switching should synchronize the selected token before refreshing auth"
62
+ );
63
+
64
+ assert(
65
+ alternate.includes("clearActiveAuthToken()") &&
66
+ alternate.indexOf("clearActiveAuthToken()") < alternate.indexOf('location.href = JX3BOX.__Links.account.login'),
67
+ "adding an alternate should clear the previous account override token before login"
68
+ );
69
+
70
+ assert(
71
+ userInfo.includes("clearActiveAuthToken()") && commonHeader.includes("clearActiveAuthToken()"),
72
+ "manual and token-version logout should clear higher-priority request tokens"
73
+ );
74
+
75
+ assert(
76
+ userInfo.includes("repairMismatchedIdentity") && userInfo.includes("hasActiveAuthTokenOverride()"),
77
+ "header profile should repair a cached uid that conflicts with the authenticated request uid"
78
+ );
79
+
80
+ assert(
81
+ commonHeader.includes("reloadOnIdentityChange") && commonHeader.includes("refreshTokenIfNeeded({ force })"),
82
+ "URL token entry should normalize the cached identity and reload when the uid changes"
83
+ );
84
+
45
85
  assert(
46
86
  refreshUtil.includes("jx3box-alternate-${profile.uid}"),
47
87
  "refresh should update the current alternate account cache"
@@ -0,0 +1,79 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ function loadComponentOptions() {
7
+ const filename = path.resolve(__dirname, "../src/filters/clientBy.vue");
8
+ const source = fs.readFileSync(filename, "utf8");
9
+ const script = source.match(/<script>([\s\S]*?)<\/script>/)?.[1] || "";
10
+ const executable = script
11
+ .replace(/^\s*import\s+[^;]+;\s*$/gm, "")
12
+ .replace(/export\s+default\s+/, "return ");
13
+ return Function("i18nMixin", executable)({});
14
+ }
15
+
16
+ function createVm({ type = "", autoDetect = false } = {}) {
17
+ const events = [];
18
+ return {
19
+ vm: {
20
+ type,
21
+ autoDetect,
22
+ client: type || "",
23
+ $emit(name, payload) {
24
+ events.push({ name, payload });
25
+ },
26
+ $forceUpdate() {},
27
+ },
28
+ events,
29
+ };
30
+ }
31
+
32
+ const component = loadComponentOptions();
33
+
34
+ test("clientBy keeps automatic site detection enabled by default", () => {
35
+ assert.equal(component.props.autoDetect.default, true);
36
+ });
37
+
38
+ test("controlled mode keeps all selected and does not emit while mounting", () => {
39
+ const { vm, events } = createVm({ type: "all", autoDetect: false });
40
+
41
+ component.mounted.call(vm);
42
+
43
+ assert.equal(vm.client, "all");
44
+ assert.deepEqual(events, []);
45
+ });
46
+
47
+ test("external type updates only synchronize selection", () => {
48
+ const { vm, events } = createVm({ type: "all", autoDetect: false });
49
+
50
+ component.watch.type.call(vm, "origin");
51
+
52
+ assert.equal(vm.client, "origin");
53
+ assert.deepEqual(events, []);
54
+ });
55
+
56
+ test("user selection emits the existing filter payload", () => {
57
+ const { vm, events } = createVm({ type: "all", autoDetect: false });
58
+
59
+ component.methods.filter.call(vm, "std");
60
+
61
+ assert.equal(vm.client, "std");
62
+ assert.deepEqual(events, [{ name: "filter", payload: { type: "client", val: "std" } }]);
63
+ });
64
+
65
+ test("automatic mode only detects origin from hostname", () => {
66
+ const previousLocation = global.location;
67
+ global.location = { hostname: "origin.jx3box.com" };
68
+ const { vm, events } = createVm({ autoDetect: true });
69
+ vm.filter = component.methods.filter;
70
+
71
+ try {
72
+ component.mounted.call(vm);
73
+ } finally {
74
+ global.location = previousLocation;
75
+ }
76
+
77
+ assert.equal(vm.client, "origin");
78
+ assert.deepEqual(events, [{ name: "filter", payload: { type: "client", val: "origin" } }]);
79
+ });