w-web-sso 1.1.12 → 1.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +16 -0
  2. package/dist/css/{app.b17b0ba0.css → app.f000459f.css} +1 -1
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/{app-legacy.bee6840f.js → app-legacy.7c006893.js} +1 -1
  5. package/dist/js/{app.bee6840f.js → app.7c006893.js} +1 -1
  6. package/dist/js/{chunk-vendors.0492b6f5.js → chunk-vendors-legacy.97013ddc.js} +5 -5
  7. package/dist/js/{chunk-vendors-legacy.0492b6f5.js → chunk-vendors.97013ddc.js} +5 -5
  8. package/dist/w-web-sso.umd.js +4 -4
  9. package/dist/w-web-sso.umd.js.map +1 -1
  10. package/docs/WWebSso.html +1 -1
  11. package/docs/global.html +1 -1
  12. package/docs/index.html +1 -1
  13. package/docs/server_WWebSso.mjs.html +16 -10
  14. package/docs/server_getIgnoredOrmKeys.mjs.html +1 -1
  15. package/docs/server_lockSave.mjs.html +1 -1
  16. package/docs/server_ormWrite.mjs.html +1 -1
  17. package/docs/server_staLogs_resolveShellWorker.mjs.html +1 -1
  18. package/docs/server_wrapTimer.mjs.html +1 -1
  19. package/docs/src_components_Common_CheckYesNo.vue.html +1 -1
  20. package/docs/src_fetchSsoMsg.mjs.html +1 -1
  21. package/docs/src_httpGetJson.mjs.html +1 -1
  22. package/docs/src_maskUrl.mjs.html +1 -1
  23. package/docs/src_plugins_staUsage.mjs.html +1 -1
  24. package/docs/src_plugins_submitGuard.mjs.html +1 -1
  25. package/package.json +2 -2
  26. package/server/WWebSso.mjs +15 -9
  27. package/server/procCore.mjs +791 -379
  28. package/server/procLang.mjs +18 -0
  29. package/server/procProtect.mjs +150 -88
  30. package/src/baselineOf.mjs +76 -0
  31. package/src/components/LayoutContentIps.vue +24 -2
  32. package/src/components/LayoutContentTokens.vue +24 -2
  33. package/src/components/LayoutContentUsers.vue +9 -3
  34. package/src/schema/tables/ips.mjs +4 -0
  35. package/src/schema/tables/tokens.mjs +4 -0
  36. package/src/writeContracts/index.mjs +9 -0
  37. package/src/writeContracts/ips.mjs +9 -0
  38. package/src/writeContracts/tokens.mjs +12 -0
  39. package/src/writeContracts/tokensSession.mjs +10 -0
  40. package/src/writeContracts/users.mjs +15 -0
@@ -1013,6 +1013,10 @@ let kpLang = {
1013
1013
  eng: 'Invalid app token permissions.',
1014
1014
  cht: '應用系統金鑰權限設定無效。',
1015
1015
  },
1016
+ tokenSessionAddNotAllowed: { //金鑰清單存檔不得新增登入權杖(session 列; ADR-092)
1017
+ eng: 'Login tokens cannot be added. Nothing was saved.',
1018
+ cht: '不可新增登入權杖,本次未儲存任何變更',
1019
+ },
1016
1020
  //應用系統金鑰之各權限名稱, 鍵為 appPerm_<權限字串> (權限全集見 src/appPerms.mjs)
1017
1021
  appPerm_readUsers: {
1018
1022
  eng: 'Read users',
@@ -1324,6 +1328,20 @@ let kpLang = {
1324
1328
  cht: `資料寫入未全部完成,部分變更可能已生效,請重新整理頁面確認後再操作`,
1325
1329
  },
1326
1330
 
1331
+ //多人同時編輯之寫入契約(原則 10): 拒收鍵 baselineMismatch / rowNotFound / baselineFormat 由 procCore.getSaveRejectKey 轉為下列語系鍵, 一律提示重新載入; 拒收時零寫入
1332
+ saveStale: { //載入後資料已被他人或系統更動(baselineMismatch)
1333
+ eng: `The data has been changed by another user or the system since you loaded this page. Nothing was saved. Please reload the page and make your changes again.`,
1334
+ cht: `資料自您載入後已被其他使用者或系統更動,本次未儲存任何變更,請重新載入頁面後再操作`,
1335
+ },
1336
+ saveRowNotFound: { //要修改或刪除之資料已不存在(rowNotFound)
1337
+ eng: `Some of the data no longer exists (it may have been deleted by another user). Nothing was saved. Please reload the page and make your changes again.`,
1338
+ cht: `部分資料已不存在(可能已被其他使用者刪除),本次未儲存任何變更,請重新載入頁面後再操作`,
1339
+ },
1340
+ saveBaselineFormat: { //前端送出之載入基準格式或版本不識(baselineFormat)
1341
+ eng: `The page data version is not recognized. Nothing was saved. Please reload the page and make your changes again.`,
1342
+ cht: `頁面資料版本無法辨識,本次未儲存任何變更,請重新載入頁面後再操作`,
1343
+ },
1344
+
1327
1345
  }
1328
1346
 
1329
1347
 
@@ -29,7 +29,6 @@ import pm2resolve from 'wsemi/src/pm2resolve.mjs'
29
29
  import pmSeries from 'wsemi/src/pmSeries.mjs'
30
30
  import getErrorMessage from 'wsemi/src/getErrorMessage.mjs'
31
31
  import ds from '../src/schema/index.mjs'
32
- import ormWrite from './ormWrite.mjs'
33
32
  import wrapTimer from './wrapTimer.mjs'
34
33
  import { maskToken, safeText } from './srLog.mjs'
35
34
 
@@ -49,7 +48,11 @@ let FORWARDED_PORT_MAX = 65535
49
48
  function proc(woItems, p, opt = {}) {
50
49
 
51
50
  //params
51
+ //procOrm / withTabLocks: 取自 w-serv-orm 實例(與 procCore 同一組, 同表只由同一鎖管理器寫入, 規格 §12); 系統直寫 users / tokens / ips 一律在該表表鎖內、
52
+ //經 procOrm 且 mustExist(只改不增), 更新者記常數 'system' (R-CONC-01, R-CONC-04, 決策 D7)
52
53
  let {
54
+ procOrm,
55
+ withTabLocks,
53
56
  srLog,
54
57
  minForAccountLoginFailed,
55
58
  numForAccountLoginFailed,
@@ -62,6 +65,14 @@ function proc(woItems, p, opt = {}) {
62
65
  minBlockForIpCallApi,
63
66
  } = opt
64
67
 
68
+ //check
69
+ if (!isfun(procOrm) || !isfun(withTabLocks)) {
70
+ throw new Error('procProtect needs procOrm and withTabLocks from the WServOrm instance')
71
+ }
72
+
73
+ //SYSTEM_ID: 無使用者可歸屬之系統寫入(計時器、自動封鎖)記於 userIdUpdate 之常數
74
+ let SYSTEM_ID = 'system'
75
+
65
76
 
66
77
  //logError: 封鎖寫入失敗之紀錄 (ADR-080); srLog 未給時略過, 同 blockAccountByToken 之 debug 守衛
67
78
  let logError = (o) => {
@@ -174,57 +185,75 @@ function proc(woItems, p, opt = {}) {
174
185
  //blockAccount
175
186
  let blockAccount = async (account) => {
176
187
 
177
- //getGenUserByAccount
188
+ //getGenUserByAccount, 取得使用者主鍵; 封鎖狀態於下方 [tokens, users] 鎖內重讀
178
189
  let u = await p.getGenUserByAccount(account)
179
190
 
180
191
  // //check, 不用檢測, 若resolve必定有, 若reject則由外部處理
181
192
  // if (!iseobj(u)) {
182
193
  // }
183
194
 
184
- //getBlockedByUser
185
- let b = await getBlockedByUser(u)
195
+ //[tokens, users] 鎖內: 重讀使用者(新鮮) → 判斷是否已封鎖 → 寫 timeBlocked → 撤銷 token, 同一把鎖完成 (S15):
196
+ //timeBlocked 為雙方欄位(後台可改、自動封鎖亦寫), 系統直寫須取表鎖, 持舊畫面之後台存檔才會被偵測而拒收 (V4); 讀到之使用者於寫入前不被刪除插隊 (X5)
197
+ await withTabLocks(['tokens', 'users'], async () => {
186
198
 
187
- //check
188
- if (b) {
189
- return //已封鎖, 禁止重複封鎖
190
- }
199
+ //帳號對應再驗: 失敗登入所針對之帳號於等鎖期間可能已被後台改名或停用, 該帳號已不屬於這位使用者則不封鎖(以鎖內新鮮狀態為準)
200
+ let usAcc = await woItems.users.select({ account, isActive: 'y' })
201
+ if (size(usAcc) !== 1 || get(usAcc[0], 'id', '') !== u.id) {
202
+ return
203
+ }
191
204
 
192
- //timeBlocked, 依照minBlockForAccountLoginFailed(min)更新封鎖時間
193
- let timeBlocked = ot().add(minBlockForAccountLoginFailed, 'minute').format('YYYY-MM-DDTHH:mm:ss.SSSZ')
194
- // console.log('timeBlocked', timeBlocked)
205
+ //重讀使用者, 已被刪除則無從封鎖(其 token 已於刪除時同鎖撤銷)
206
+ let us = await woItems.users.select({ id: u.id })
207
+ if (size(us) !== 1) {
208
+ return
209
+ }
195
210
 
196
- //封鎖使用者, 須更新使用者timeBlocked; 逐筆檢查 (ADR-080)
197
- let errBlock = null
198
- await ormWrite(woItems.users, 'save', {
199
- id: u.id,
200
- timeBlocked,
201
- })
202
- .catch((err) => {
203
- errBlock = err
204
- })
211
+ //getBlockedByUser
212
+ let b = await getBlockedByUser(us[0])
205
213
 
206
- //封鎖使用者, 須刪除該 user 名下所有 token (multi-session 強制全部登出)
207
- //(原本走 p.getTokenByKV('userId', u.id) 取單筆, 但 ADR-001 接受同帳號多 active token,
208
- //getTokenByKV 之 nts>=2 reject 'duplicate token' 對 multi-session user 必觸發 → token
209
- //完全沒刪 → 帳號被鎖但既有 session 仍可用. 改用 select 直接取陣列後全部刪除,
210
- //對齊 spec/流程_自動封鎖機制.md A32「強制登出」契約 — Round-3 audit B-01 fix.)
211
- //撤銷與刪除 / 停用使用者共用 p.revokeTokensByUserId (一次刪除並逐筆檢查); 封鎖寫入失敗仍撤銷 (依意圖 fail closed, ADR-080)
212
- let errRevoke = null
213
- await p.revokeTokensByUserId(u.id)
214
- .catch((err) => {
215
- errRevoke = err
216
- })
214
+ //check
215
+ if (b) {
216
+ return //已封鎖, 禁止重複封鎖
217
+ }
217
218
 
218
- //check, 失敗記錄後 reject; 封鎖未寫入時, 下次登入失敗仍超過門檻即再嘗試封鎖
219
- if (errBlock !== null || errRevoke !== null) {
220
- logError({
221
- event: 'fun-blockAccount',
222
- userId: u.id,
223
- errBlock: errBlock !== null ? getErrorMessage(errBlock) : '',
224
- errRevoke: errRevoke !== null ? getErrorMessage(errRevoke) : '',
225
- })
226
- return Promise.reject(`failed to block account`)
227
- }
219
+ //timeBlocked, 依照minBlockForAccountLoginFailed(min)更新封鎖時間
220
+ let timeBlocked = ot().add(minBlockForAccountLoginFailed, 'minute').format('YYYY-MM-DDTHH:mm:ss.SSSZ')
221
+ // console.log('timeBlocked', timeBlocked)
222
+
223
+ //封鎖使用者, 須更新使用者timeBlocked; 經 procOrm 且 mustExist(只改不增), 更新者記 'system'; 逐筆檢查 (ADR-080)
224
+ let errBlock = null
225
+ await procOrm(SYSTEM_ID, 'users', 'save', [{
226
+ id: u.id,
227
+ timeBlocked,
228
+ }], { mustExist: true })
229
+ .catch((err) => {
230
+ errBlock = err
231
+ })
232
+
233
+ //封鎖使用者, 須刪除該 user 名下所有 token (multi-session 強制全部登出)
234
+ //(原本走 p.getTokenByKV('userId', u.id) 取單筆, 但 ADR-001 接受同帳號多 active token,
235
+ //getTokenByKV 之 nts>=2 reject 'duplicate token' 對 multi-session user 必觸發 → token
236
+ //完全沒刪 → 帳號被鎖但既有 session 仍可用. 改用 select 直接取陣列後全部刪除,
237
+ //對齊 spec/流程_自動封鎖機制.md A32「強制登出」契約 — Round-3 audit B-01 fix.)
238
+ //撤銷與刪除 / 停用使用者共用 p.revokeTokensByUserIdLocked (持鎖版, 一次刪除並逐筆檢查); 封鎖寫入失敗仍撤銷 (依意圖 fail closed, ADR-080)
239
+ let errRevoke = null
240
+ await p.revokeTokensByUserIdLocked(u.id)
241
+ .catch((err) => {
242
+ errRevoke = err
243
+ })
244
+
245
+ //check, 失敗記錄後 reject; 封鎖未寫入時, 下次登入失敗仍超過門檻即再嘗試封鎖
246
+ if (errBlock !== null || errRevoke !== null) {
247
+ logError({
248
+ event: 'fun-blockAccount',
249
+ userId: u.id,
250
+ errBlock: errBlock !== null ? getErrorMessage(errBlock) : '',
251
+ errRevoke: errRevoke !== null ? getErrorMessage(errRevoke) : '',
252
+ })
253
+ return Promise.reject(`failed to block account`)
254
+ }
255
+
256
+ })
228
257
 
229
258
  }
230
259
 
@@ -372,55 +401,78 @@ function proc(woItems, p, opt = {}) {
372
401
  return Promise.reject(`invalid userId`)
373
402
  }
374
403
 
375
- //getGenUserByUserId
404
+ //getGenUserByUserId, 取得使用者主鍵; 封鎖狀態於下方 [tokens, users] 鎖內重讀
376
405
  let u = await p.getGenUserByUserId(userId)
377
406
 
378
407
  // //check, 不用檢測, 若resolve必定有, 若reject則由外部處理
379
408
  // if (!iseobj(u)) {
380
409
  // }
381
410
 
382
- //getBlockedByUser
383
- let b = await getBlockedByUser(u)
411
+ //[tokens, users] 鎖內: 重讀使用者(新鮮) → 判斷是否已封鎖 → 寫 timeBlocked → 撤銷 token, 同一把鎖完成 (S16; 理由同 blockAccount)
412
+ await withTabLocks(['tokens', 'users'], async () => {
384
413
 
385
- //check
386
- if (b) {
387
- return //已封鎖, 禁止重複封鎖
388
- }
414
+ //重讀 token 並再驗身分: 鎖外判定其為使用者 token 與擁有者後, 等鎖期間後台存檔可能已將其升為應用系統金鑰(受信任, 不套用個人速率封鎖)、換擁有者, 或已被登出 / 撤銷;
415
+ //身分已與鎖外判定不符則不封鎖(以鎖內新鮮狀態為準)
416
+ let tn = null
417
+ await p.getTokenByKV('token', token)
418
+ .then((r) => {
419
+ tn = r
420
+ })
421
+ .catch(() => {})
422
+ if (tn === null || get(tn, 'isApp', '') === 'y' || get(tn, 'userId', '') !== u.id) {
423
+ return
424
+ }
389
425
 
390
- //timeBlocked, 依照minBlockForTokenCallApi(min)更新封鎖時間
391
- let timeBlocked = ot().add(minBlockForTokenCallApi, 'minute').format('YYYY-MM-DDTHH:mm:ss.SSSZ')
392
- // console.log('timeBlocked', timeBlocked)
426
+ //重讀使用者, 已被刪除則無從封鎖(其 token 已於刪除時同鎖撤銷)
427
+ let us = await woItems.users.select({ id: u.id })
428
+ if (size(us) !== 1) {
429
+ return
430
+ }
393
431
 
394
- //封鎖使用者, 須更新使用者timeBlocked; 逐筆檢查 (ADR-080)
395
- let errBlock = null
396
- await ormWrite(woItems.users, 'save', {
397
- id: u.id,
398
- timeBlocked,
399
- })
400
- .catch((err) => {
401
- errBlock = err
402
- })
432
+ //getBlockedByUser
433
+ let b = await getBlockedByUser(us[0])
403
434
 
404
- //封鎖使用者, 須刪除該 user 名下所有 token (含所用 token; 與 blockAccount 同為 multi-session 強制全部登出, 共用 p.revokeTokensByUserId);
405
- //原只刪所用 token, 封鎖期間該使用者其他裝置之既有登入照常可用, 與 spec E2E-005 及登入失敗封鎖不一致 (2026-10-11 改, ADR-080)
406
- //封鎖寫入失敗仍撤銷 (依意圖 fail closed, ADR-080)
407
- let errRevoke = null
408
- await p.revokeTokensByUserId(u.id)
409
- .catch((err) => {
410
- errRevoke = err
411
- })
435
+ //check
436
+ if (b) {
437
+ return //已封鎖, 禁止重複封鎖
438
+ }
412
439
 
413
- //check, 失敗記錄後 reject
414
- if (errBlock !== null || errRevoke !== null) {
415
- logError({
416
- event: 'fun-blockAccountByToken',
417
- userId: u.id,
418
- token: maskToken(token),
419
- errBlock: errBlock !== null ? getErrorMessage(errBlock) : '',
420
- errRevoke: errRevoke !== null ? getErrorMessage(errRevoke) : '',
421
- })
422
- return Promise.reject(`failed to block account by token`)
423
- }
440
+ //timeBlocked, 依照minBlockForTokenCallApi(min)更新封鎖時間
441
+ let timeBlocked = ot().add(minBlockForTokenCallApi, 'minute').format('YYYY-MM-DDTHH:mm:ss.SSSZ')
442
+ // console.log('timeBlocked', timeBlocked)
443
+
444
+ //封鎖使用者, 須更新使用者timeBlocked; 經 procOrm 且 mustExist(只改不增), 更新者記 'system'; 逐筆檢查 (ADR-080)
445
+ let errBlock = null
446
+ await procOrm(SYSTEM_ID, 'users', 'save', [{
447
+ id: u.id,
448
+ timeBlocked,
449
+ }], { mustExist: true })
450
+ .catch((err) => {
451
+ errBlock = err
452
+ })
453
+
454
+ //封鎖使用者, 須刪除該 user 名下所有 token (含所用 token; 與 blockAccount 同為 multi-session 強制全部登出, 共用 p.revokeTokensByUserIdLocked 持鎖版);
455
+ //原只刪所用 token, 封鎖期間該使用者其他裝置之既有登入照常可用, 與 spec E2E-005 及登入失敗封鎖不一致 (2026-10-11 改, ADR-080)
456
+ //封鎖寫入失敗仍撤銷 (依意圖 fail closed, ADR-080)
457
+ let errRevoke = null
458
+ await p.revokeTokensByUserIdLocked(u.id)
459
+ .catch((err) => {
460
+ errRevoke = err
461
+ })
462
+
463
+ //check, 失敗記錄後 reject
464
+ if (errBlock !== null || errRevoke !== null) {
465
+ logError({
466
+ event: 'fun-blockAccountByToken',
467
+ userId: u.id,
468
+ token: maskToken(token),
469
+ errBlock: errBlock !== null ? getErrorMessage(errBlock) : '',
470
+ errRevoke: errRevoke !== null ? getErrorMessage(errRevoke) : '',
471
+ })
472
+ return Promise.reject(`failed to block account by token`)
473
+ }
474
+
475
+ })
424
476
 
425
477
  }
426
478
 
@@ -739,7 +791,14 @@ function proc(woItems, p, opt = {}) {
739
791
 
740
792
  //blockIpByIp
741
793
  // blockIpByIp 由 timer 單一 caller 觸發, pmSeries 保證序列化, 無 race; 詳 audit F-052 verifier 結論
794
+ // (計時器之間互不競態, 但與後台 IP 清單存檔 / 計時器登錄新 IP 並行: 讀取與寫入整段在 ips 表鎖內, 規格 §7; S17 / S18)
742
795
  let blockIpByIp = async (ip) => {
796
+ return await withTabLocks(['ips'], () => _blockIpByIp(ip))
797
+ }
798
+
799
+
800
+ //_blockIpByIp, 須於 ips 表鎖內呼叫
801
+ let _blockIpByIp = async (ip) => {
743
802
  let errTemp = null
744
803
 
745
804
  //genTimeBlocked
@@ -785,8 +844,8 @@ function proc(woItems, p, opt = {}) {
785
844
  })
786
845
  // console.log('funNew', oip)
787
846
 
788
- //insert
789
- await woItems.ips.insert(oip)
847
+ //insert, 經 procOrm 戳記更新者 'system' (R-CONC-04)
848
+ await procOrm(SYSTEM_ID, 'ips', 'insert', [oip])
790
849
  .catch((err) => {
791
850
  errTemp = err
792
851
  })
@@ -812,12 +871,12 @@ function proc(woItems, p, opt = {}) {
812
871
  let timeBlocked = genTimeBlocked()
813
872
  // console.log('timeBlocked', timeBlocked)
814
873
 
815
- //封鎖ip, 須更新ip的timeBlocked; 逐筆檢查 (ADR-080)
874
+ //封鎖ip, 須更新ip的timeBlocked; 經 procOrm 且 mustExist(只改不增), 更新者記 'system'; 逐筆檢查 (ADR-080)
816
875
  let errSave = null
817
- await ormWrite(woItems.ips, 'save', {
876
+ await procOrm(SYSTEM_ID, 'ips', 'save', [{
818
877
  id: oip.id,
819
878
  timeBlocked,
820
- })
879
+ }], { mustExist: true })
821
880
  .catch((err) => {
822
881
  errSave = err
823
882
  })
@@ -865,7 +924,8 @@ function proc(woItems, p, opt = {}) {
865
924
 
866
925
 
867
926
  //timer, 偵測新ip出現儲存至ips; 讀取或新增失敗由 wrapTimer 記錄(timer-error), 下一輪重試(原讀取失敗時重入鎖不釋放而永久停擺, ADR-085)
868
- setInterval(wrapTimer('ipDetectNew', async () => {
927
+ //讀取已登錄 IP 清單、比對與新增整段在 ips 表鎖內(S19): 與後台 IP 清單存檔、自動封鎖寫入互斥; 載入後才登錄之 IP 使持舊畫面存檔者被拒(先動先贏), 不致被整表存檔刪除 (X4)
928
+ setInterval(wrapTimer('ipDetectNew', () => withTabLocks(['ips'], async () => {
869
929
 
870
930
  //getIpsList
871
931
  let oips = await p.getIpsList()
@@ -911,10 +971,12 @@ function proc(woItems, p, opt = {}) {
911
971
  })
912
972
  // console.log('oipsNew', oipsNew)
913
973
 
914
- //insert
915
- await woItems.ips.insert(oipsNew)
974
+ //insert, 經 procOrm 戳記更新者 'system' (R-CONC-04); 無新 IP 時不呼叫
975
+ if (size(oipsNew) > 0) {
976
+ await procOrm(SYSTEM_ID, 'ips', 'insert', oipsNew)
977
+ }
916
978
 
917
- }, timerError), 2000)
979
+ }), timerError), 2000)
918
980
 
919
981
 
920
982
  //timer, 限制ip最大調用API次數
@@ -0,0 +1,76 @@
1
+ import { buildBaseline, digestRow } from 'w-serv-orm/src/baseline.mjs'
2
+ import contracts from './writeContracts/index.mjs'
3
+
4
+
5
+ //後台三個清單頁(使用者 / 金鑰 / IP)共用之基準工具 (多人同時編輯之寫入契約, 原則 10; 規則帳本 rulebook_concurrency R-CONC-05):
6
+ //前端於載入(及存檔成功後重新讀取)時, 以 buildBaseline 對「畫面所見之同一快照」自算有序基準 [[id, 列摘要]], 存檔時隨第 4 參數送後端;
7
+ //後端於表鎖內新鮮讀取、重算比對, 不符整次拒收(零寫入, 先動先贏, 回 saveStale 等語系鍵提示重新載入). 不另向後端取雜湊, RPC 回傳形狀不變.
8
+ //算不出基準(列格式異常等)回 null: 送出時 null 被後端視為格式不識而拒收 saveBaselineFormat(提示重新載入), 不降級為不帶基準.
9
+
10
+
11
+ //safeBaseline
12
+ function safeBaseline(rows, contract) {
13
+ try {
14
+ return buildBaseline(rows, contract)
15
+ }
16
+ catch (err) {
17
+ console.log('buildBaseline catch', err)
18
+ return null
19
+ }
20
+ }
21
+
22
+
23
+ //baselineOfUsers: rows 為 getUsersList 之回傳(未排序、未變動); 回傳基準陣列或 null
24
+ function baselineOfUsers(rows) {
25
+ return safeBaseline(rows, contracts.users)
26
+ }
27
+
28
+
29
+ //baselineOfIps: rows 為 getIpsList 之回傳
30
+ function baselineOfIps(rows) {
31
+ return safeBaseline(rows, contracts.ips)
32
+ }
33
+
34
+
35
+ //baselineOfTokens: rows 為 getTokensList 之回傳; 回傳 { baseline, baselineSession } 或 null
36
+ //baseline 為應用系統金鑰列(isApp === 'y', 嚴格整表比對), baselineSession 為 session 列(逐列寬鬆比對, 契約 tokensSession)
37
+ function baselineOfTokens(rows) {
38
+ let baseline = safeBaseline(rows, contracts.tokens)
39
+ let baselineSession = safeBaseline(rows, contracts.tokensSession)
40
+ if (baseline === null || baselineSession === null) {
41
+ return null
42
+ }
43
+ return { baseline, baselineSession }
44
+ }
45
+
46
+
47
+ //isSameSnapshot: 兩份列(各依主鍵)之契約 fields 摘要是否全相同(含列數); 存檔成功重新讀取後, 送出之列與伺服端列相同者保留現畫面,
48
+ //不同者(如他人於載入後有更動、伺服端正規化)須改以伺服端列取代, 令畫面所見之列與新基準來自同一快照 (規格 §8)
49
+ //不比對列序: 僅供無名次欄(orderKey)之金鑰 / IP 清單使用(顯示序由前端自排); 使用者清單存檔成功後一律以重新讀取之列取代, 不用本函數
50
+ function isSameSnapshot(rowsA, rowsB, contract) {
51
+ try {
52
+ if (!Array.isArray(rowsA) || !Array.isArray(rowsB) || rowsA.length !== rowsB.length) {
53
+ return false
54
+ }
55
+ let mp = new Map()
56
+ for (let r of rowsA) {
57
+ mp.set(r.id, digestRow(r, contract))
58
+ }
59
+ if (mp.size !== rowsA.length) {
60
+ return false
61
+ }
62
+ for (let r of rowsB) {
63
+ if (mp.get(r.id) !== digestRow(r, contract)) {
64
+ return false
65
+ }
66
+ }
67
+ return true
68
+ }
69
+ catch (err) {
70
+ return false
71
+ }
72
+ }
73
+
74
+
75
+ export { baselineOfUsers, baselineOfIps, baselineOfTokens, isSameSnapshot }
76
+ export default { baselineOfUsers, baselineOfIps, baselineOfTokens, isSameSnapshot }
@@ -239,6 +239,8 @@ import WPopup from 'w-component-vue/src/components/WPopup.vue'
239
239
  import WInputCheckbox from 'w-component-vue/src/components/WInputCheckbox.vue'
240
240
  import WAggridVue from 'w-aggrid-vue/src/components/WAggridVue.vue'
241
241
  import WTimeminute from 'w-component-vue/src/components/WTimeminute.vue'
242
+ import contracts from '../writeContracts/index.mjs'
243
+ import { baselineOfIps, isSameSnapshot } from '../baselineOf.mjs'
242
244
 
243
245
 
244
246
  export default {
@@ -269,6 +271,7 @@ export default {
269
271
  headHeight: 100,
270
272
 
271
273
  firstLoading: true,
274
+ baseline: null, //載入時(及存檔成功後重新讀取時)畫面所見之列的有序基準 [[id, 列摘要]], 存檔時送後端比對(多人同時編輯, 先動先贏); null 為未載入或算不出, 送出時被後端拒收
272
275
  errMsg: '', //清單載入失敗之訊息(getDataError), 有值時以訊息取代表格
273
276
  firstSetting: true,
274
277
  systemProcing: false, //程式端載入/重載清單資料期間為true, 用於排除非使用者操作之rowsChange
@@ -329,6 +332,7 @@ export default {
329
332
  vo.$fapi.getIpsList(token)
330
333
  .then((res) => {
331
334
  // console.log(res)
335
+ vo.baseline = baselineOfIps(res) //對畫面所見之同一快照自算基準(須在排序、變動之前; buildBaseline 不改輸入)
332
336
  res = sortBy(res, 'ip').reverse()
333
337
  vo.ips = res
334
338
  vo.markDataReload() //程式端寫入資料, 其後續 rowsChange 非使用者變更
@@ -765,8 +769,9 @@ export default {
765
769
  let lang = get(vo, '$store.state.lang', 'eng')
766
770
  // console.log('token', token)
767
771
 
768
- //updateIpsList (帶 lang, 後端 reject { key, msg } 之 msg 依 lang 翻譯)
769
- await vo.$fapi.updateIpsList(token, lang, rows)
772
+ //updateIpsList (帶 lang, 後端 reject { key, msg } 之 msg 依 lang 翻譯), 第 4 參數帶載入時之基準:
773
+ //載入後資料已被他人或系統(計時器自動登錄新 IP、自動封鎖)更動者整次拒收(零寫入, 回 saveStale 等語系鍵提示重新載入), 草稿保留(不清 isModified)
774
+ await vo.$fapi.updateIpsList(token, lang, rows, { baseline: vo.baseline })
770
775
  .catch((err) => {
771
776
  errTemp = err
772
777
  })
@@ -780,6 +785,23 @@ export default {
780
785
  return
781
786
  }
782
787
 
788
+ //儲存成功 → 重新讀取 getIpsList 作為新基準(取自伺服端, 不以自己送出之值當基準), 畫面所見之列須與新基準來自同一快照 (規格 §8):
789
+ //送出之列與伺服端列的契約欄位摘要全相同者保留現畫面(伺服器專有欄位維持原呈現, 與既有行為相同), 不同者改以伺服端列取代;
790
+ //重新讀取失敗則基準置 null(其後再存檔被後端拒收並提示重新載入, 不降級為不帶基準)
791
+ await vo.$fapi.getIpsList(token)
792
+ .then((res) => {
793
+ vo.baseline = baselineOfIps(res)
794
+ if (!isSameSnapshot(rows, res, contracts.ips)) {
795
+ res = sortBy(res, 'ip').reverse()
796
+ vo.ips = res
797
+ vo.markDataReload() //程式端寫入資料, 其後續 rowsChange 非使用者變更
798
+ }
799
+ })
800
+ .catch((err) => {
801
+ console.log('refresh getIpsList catch', err)
802
+ vo.baseline = null
803
+ })
804
+
783
805
  //isModified
784
806
  vo.isModified = false
785
807
 
@@ -376,6 +376,8 @@ import WAggridVue from 'w-aggrid-vue/src/components/WAggridVue.vue'
376
376
  import WTimeminute from 'w-component-vue/src/components/WTimeminute.vue'
377
377
  import { keysAppPerm, keysAppPermBase, keysAppPermHigh, getAppPerms, normAppPerms } from '../appPerms.mjs'
378
378
  import AppPermsTags from './AppPermsTags.vue'
379
+ import contracts from '../writeContracts/index.mjs'
380
+ import { baselineOfTokens, isSameSnapshot } from '../baselineOf.mjs'
379
381
 
380
382
 
381
383
  export default {
@@ -407,6 +409,7 @@ export default {
407
409
  headHeight: 100,
408
410
 
409
411
  firstLoading: true,
412
+ baselines: null, //載入時(及存檔成功後重新讀取時)畫面所見之列的基準 { baseline: 應用系統金鑰列〔嚴格整表比對〕, baselineSession: session 列〔逐列寬鬆比對〕 }, 存檔時送後端(多人同時編輯, 先動先贏); null 為未載入或算不出, 送出時被後端拒收
410
413
  errMsg: '', //清單載入失敗之訊息(getDataError), 有值時以訊息取代表格
411
414
  firstSetting: true,
412
415
  systemProcing: false, //程式端載入/重載清單資料期間為true, 用於排除非使用者操作之rowsChange
@@ -490,6 +493,7 @@ export default {
490
493
  vo.$fapi.getTokensList(token)
491
494
  .then((res) => {
492
495
  // console.log(res)
496
+ vo.baselines = baselineOfTokens(res) //對畫面所見之同一快照自算基準(須在排序、變動之前; buildBaseline 不改輸入)
493
497
  res = sortBy(res, 'timeCreate').reverse()
494
498
  vo.tokens = res
495
499
  vo.markDataReload() //程式端寫入資料, 其後續 rowsChange 非使用者變更
@@ -1119,8 +1123,9 @@ export default {
1119
1123
  let lang = get(vo, '$store.state.lang', 'eng')
1120
1124
  // console.log('token', token)
1121
1125
 
1122
- //updateTokensList (帶 lang, 後端 reject { key, msg } 之 msg 依 lang 翻譯)
1123
- await vo.$fapi.updateTokensList(token, lang, rows)
1126
+ //updateTokensList (帶 lang, 後端 reject { key, msg } 之 msg 依 lang 翻譯), 第 4 參數帶載入時之基準:
1127
+ //應用系統金鑰列嚴格整表比對, session 列(登入新增、續期、登出等系統頻繁增刪)逐列寬鬆比對; 不符整次拒收(零寫入, 回 saveStale 等語系鍵提示重新載入), 草稿保留(不清 isModified)
1128
+ await vo.$fapi.updateTokensList(token, lang, rows, vo.baselines === null ? { baseline: null, baselineSession: null } : { baseline: vo.baselines.baseline, baselineSession: vo.baselines.baselineSession })
1124
1129
  .catch((err) => {
1125
1130
  errTemp = err
1126
1131
  })
@@ -1134,6 +1139,23 @@ export default {
1134
1139
  return
1135
1140
  }
1136
1141
 
1142
+ //儲存成功 → 重新讀取 getTokensList 作為新基準(取自伺服端, 不以自己送出之值當基準: 寫入時 clearXSS、perms 去重排序等會改寫部分值, 且 session 列於此期間可能已續期或新增),
1143
+ //畫面所見之列須與新基準來自同一快照 (規格 §8): 送出之列與伺服端列的契約欄位摘要全相同者保留現畫面(伺服器專有欄位如 timeUpdate 維持原呈現, 與既有行為相同),
1144
+ //不同者(他人或系統於期間有更動)改以伺服端列取代; 重新讀取失敗則基準置 null(其後再存檔被後端拒收並提示重新載入, 不降級為不帶基準)
1145
+ await vo.$fapi.getTokensList(token)
1146
+ .then((res) => {
1147
+ vo.baselines = baselineOfTokens(res)
1148
+ if (!isSameSnapshot(rows, res, contracts.tokens)) {
1149
+ res = sortBy(res, 'timeCreate').reverse()
1150
+ vo.tokens = res
1151
+ vo.markDataReload() //程式端寫入資料, 其後續 rowsChange 非使用者變更
1152
+ }
1153
+ })
1154
+ .catch((err) => {
1155
+ console.log('refresh getTokensList catch', err)
1156
+ vo.baselines = null
1157
+ })
1158
+
1137
1159
  //isModified
1138
1160
  vo.isModified = false
1139
1161
 
@@ -423,6 +423,7 @@ import WInputCheckbox from 'w-component-vue/src/components/WInputCheckbox.vue'
423
423
  import WAggridVue from 'w-aggrid-vue/src/components/WAggridVue.vue'
424
424
  import WTimeminute from 'w-component-vue/src/components/WTimeminute.vue'
425
425
  import WButtonChip from 'w-component-vue/src/components/WButtonChip.vue'
426
+ import { baselineOfUsers } from '../baselineOf.mjs'
426
427
 
427
428
 
428
429
  export default {
@@ -458,6 +459,7 @@ export default {
458
459
  headHeight: 100,
459
460
 
460
461
  firstLoading: true,
462
+ baseline: null, //載入時(及存檔成功後重新讀取時)畫面所見之列的有序基準 [[id, 列摘要]], 存檔時送後端比對(多人同時編輯, 先動先贏); null 為未載入或算不出, 送出時被後端拒收
461
463
  errMsg: '', //清單載入失敗之訊息(getDataError), 有值時以訊息取代表格
462
464
  firstSetting: true,
463
465
  systemProcing: false, //程式端載入/重載清單資料期間為true, 用於排除非使用者操作之rowsChange
@@ -573,6 +575,7 @@ export default {
573
575
  vo.$fapi.getUsersList(token)
574
576
  .then((res) => {
575
577
  // console.log(res)
578
+ vo.baseline = baselineOfUsers(res) //對畫面所見之同一快照自算基準(須在排序、變動之前; buildBaseline 不改輸入)
576
579
  res = sortBy(res, 'order')
577
580
  vo.users = res
578
581
  vo.markDataReload() //程式端寫入資料, 其後續 rowsChange 非使用者變更
@@ -1550,14 +1553,14 @@ export default {
1550
1553
  let token = vo.userToken
1551
1554
  let lang = get(vo, '$store.state.lang', 'eng')
1552
1555
 
1553
- //updateUsersList
1554
- await vo.$fapi.updateUsersList(token, lang, rows)
1556
+ //updateUsersList, 第 4 參數帶載入時之基準: 載入後資料已被他人或系統更動者整次拒收(零寫入, 回 saveStale 等語系鍵提示重新載入), 草稿保留(不清 isModified)
1557
+ await vo.$fapi.updateUsersList(token, lang, rows, { baseline: vo.baseline })
1555
1558
  .catch((err) => {
1556
1559
  errTemp = err
1557
1560
  })
1558
1561
 
1559
1562
  //check
1560
- //後端 reject (token 過期 / ckKey 衝突 / 密碼策略違反 / 自我鎖死等)
1563
+ //後端 reject (token 過期 / ckKey 衝突 / 密碼策略違反 / 自我鎖死 / 基準不符等)
1561
1564
  //改用 showCheckYes 強制 admin 點擊確認讀過訊息, 避免 transient toast 漏看
1562
1565
  if (errTemp !== null) {
1563
1566
  //hide loading 因 showCheckYes 是 modal, 等待期間 loading 會疊著
@@ -1570,14 +1573,17 @@ export default {
1570
1573
 
1571
1574
  //儲存成功 → 重拉 getUsersList 同步 server-side data (新增使用者的 timeVerified
1572
1575
  //在後端被填 now, 既有使用者的 audit fields 也在後端被覆寫, 須刷新前端表格)
1576
+ //新基準取自伺服端重新讀取之列(不以自己送出之值當基準: 寫入時 clearXSS 等會改寫部分字串), 且與畫面所見之列來自同一快照 (規格 §8)
1573
1577
  await vo.$fapi.getUsersList(token)
1574
1578
  .then((res) => {
1579
+ vo.baseline = baselineOfUsers(res)
1575
1580
  res = sortBy(res, 'order')
1576
1581
  vo.users = res
1577
1582
  vo.markDataReload() //程式端寫入資料, 其後續 rowsChange 非使用者變更
1578
1583
  })
1579
1584
  .catch((err) => {
1580
1585
  console.log('refresh getUsersList catch', err)
1586
+ vo.baseline = null //重新讀取失敗: 舊基準已過期, 不沿用; 其後再存檔被後端拒收並提示重新載入(不降級為不帶基準)
1581
1587
  })
1582
1588
 
1583
1589
  //isModified
@@ -26,6 +26,10 @@ let settings = {
26
26
  name: '封鎖時間',
27
27
  type: 'STRING',
28
28
  },
29
+ userIdUpdate: { //最後修改者(伺服器專有, 由 procOrm 戳記; 系統計時器寫入記常數 'system', 見 rulebook_concurrency R-CONC-04)
30
+ name: '最新變更使用者主鍵',
31
+ type: 'STRING',
32
+ },
29
33
  }
30
34
 
31
35
  let funNew = (ndata = {}) => {
@@ -44,6 +44,10 @@ let settings = {
44
44
  name: '到期時間',
45
45
  type: 'STRING',
46
46
  },
47
+ userIdUpdate: { //最後修改者(伺服器專有, 由 procOrm 戳記; 系統計時器寫入記常數 'system', 見 rulebook_concurrency R-CONC-04); userId 為擁有者, 不被操作者覆寫
48
+ name: '最新變更使用者主鍵',
49
+ type: 'STRING',
50
+ },
47
51
  timeUpdate: {
48
52
  name: '更新時間',
49
53
  type: 'STRING',