w-web-perm 1.1.11 → 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 (122) hide show
  1. package/README.md +158 -117
  2. package/dist/css/app.f09c7433.css +1 -0
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.8af87052.js +1 -0
  5. package/dist/js/app.8af87052.js +1 -0
  6. package/dist/js/{chunk-vendors.3e9e3ef1.js → chunk-vendors-legacy.ece5a361.js} +1 -1
  7. package/dist/js/{chunk-vendors-legacy.3e9e3ef1.js → chunk-vendors.ece5a361.js} +1 -1
  8. package/dist/w-web-perm.umd.js +2 -2
  9. package/dist/w-web-perm.umd.js.map +1 -1
  10. package/docs/WWebPerm.html +84 -4
  11. package/docs/global.html +24747 -832
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +245 -32
  14. package/docs/server_cascadeWrite.mjs.html +225 -0
  15. package/docs/server_dataChannel.mjs.html +2 -2
  16. package/docs/server_keyCheck.mjs.html +353 -0
  17. package/docs/server_lockSave.mjs.html +2 -2
  18. package/docs/server_maskLog.mjs.html +2 -2
  19. package/docs/server_procCore.mjs.html +180 -41
  20. package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
  21. package/docs/server_syncKeep.mjs.html +311 -0
  22. package/docs/server_tools_authRefsAudit.mjs.html +247 -0
  23. package/docs/server_tools_authRefsClean.mjs.html +335 -0
  24. package/docs/server_tools_fromTools.mjs.html +797 -0
  25. package/docs/server_tools_isMain.mjs.html +103 -0
  26. package/docs/server_tools_nameDupReport.mjs.html +280 -0
  27. package/docs/server_validateRows.mjs.html +15 -4
  28. package/docs/server_wrapInjected.mjs.html +3 -3
  29. package/docs/src_fetchJson.mjs.html +2 -2
  30. package/docs/src_js_cvKpToArr.mjs.html +125 -0
  31. package/docs/src_js_cvKpToObj.mjs.html +210 -0
  32. package/docs/src_js_draftSync.mjs.html +142 -0
  33. package/docs/src_js_eachOwn.mjs.html +3 -3
  34. package/docs/src_js_editorRefRows.mjs.html +356 -0
  35. package/docs/src_js_externalRows.mjs.html +338 -0
  36. package/docs/src_js_externalUi.mjs.html +230 -0
  37. package/docs/src_js_findRefRows.mjs.html +107 -0
  38. package/docs/src_js_findTouchedDup.mjs.html +150 -0
  39. package/docs/src_js_fmtLossyMsg.mjs.html +95 -0
  40. package/docs/src_js_fromAudit.mjs.html +489 -0
  41. package/docs/src_js_getAddImpact.mjs.html +269 -0
  42. package/docs/src_js_getAuthWarn.mjs.html +463 -0
  43. package/docs/src_js_getCascadePreview.mjs.html +258 -0
  44. package/docs/src_js_getDelRefCount.mjs.html +210 -0
  45. package/docs/src_js_getEffMode.mjs.html +92 -0
  46. package/docs/src_js_getKpLossy.mjs.html +155 -0
  47. package/docs/src_js_getNameErrs.mjs.html +266 -0
  48. package/docs/src_js_getOwn.mjs.html +3 -3
  49. package/docs/src_js_getPropKey.mjs.html +109 -0
  50. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  51. package/docs/src_js_isAuthMode.mjs.html +92 -0
  52. package/docs/src_js_joinNames.mjs.html +96 -0
  53. package/docs/src_js_legendFit.mjs.html +2 -2
  54. package/docs/src_js_pairRowsToSnapshot.mjs.html +156 -0
  55. package/docs/src_js_refAudit.mjs.html +359 -0
  56. package/docs/src_js_refCascade.mjs.html +572 -0
  57. package/docs/src_js_relDialogSend.mjs.html +199 -0
  58. package/docs/src_js_rowDigest.mjs.html +265 -0
  59. package/docs/src_plugins_dataReloadGuard.mjs.html +109 -0
  60. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  61. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  62. package/g_authRefsClean.mjs +79 -0
  63. package/g_fixFromType.mjs +92 -0
  64. package/g_initialData.mjs +3 -3
  65. package/g_purgeFrom.mjs +127 -0
  66. package/package.json +1 -1
  67. package/server/WWebPerm.mjs +243 -30
  68. package/server/cascadeWrite.mjs +153 -0
  69. package/server/keyCheck.mjs +281 -0
  70. package/server/procCore.mjs +178 -39
  71. package/server/procLang.mjs +271 -0
  72. package/server/syncKeep.mjs +239 -0
  73. package/server/tools/authRefsAudit.mjs +175 -0
  74. package/server/tools/authRefsClean.mjs +263 -0
  75. package/server/tools/fromTools.mjs +725 -0
  76. package/server/tools/isMain.mjs +31 -0
  77. package/server/tools/nameDupReport.mjs +208 -0
  78. package/server/validateRows.mjs +13 -2
  79. package/server/wrapInjected.mjs +1 -1
  80. package/src/components/LayoutContentGrups.vue +399 -77
  81. package/src/components/LayoutContentPemis.vue +400 -78
  82. package/src/components/LayoutContentTargets.vue +261 -25
  83. package/src/components/LayoutContentUsers.vue +336 -98
  84. package/src/components/VeCgrups.vue +119 -115
  85. package/src/components/VeCpemis.vue +117 -115
  86. package/src/components/VeCrules.vue +18 -2
  87. package/src/components/VeGrupBlngUsers.vue +240 -73
  88. package/src/components/VePemiBlngGrups.vue +247 -83
  89. package/src/js/cvKpToArr.mjs +53 -0
  90. package/src/js/cvKpToObj.mjs +138 -0
  91. package/src/js/draftSync.mjs +70 -0
  92. package/src/js/eachOwn.mjs +1 -1
  93. package/src/js/editorRefRows.mjs +284 -0
  94. package/src/js/externalRows.mjs +266 -0
  95. package/src/js/externalUi.mjs +158 -0
  96. package/src/js/findRefRows.mjs +35 -0
  97. package/src/js/findTouchedDup.mjs +78 -0
  98. package/src/js/fmtLossyMsg.mjs +23 -0
  99. package/src/js/fromAudit.mjs +417 -0
  100. package/src/js/getAddImpact.mjs +197 -0
  101. package/src/js/getAuthWarn.mjs +391 -0
  102. package/src/js/getCascadePreview.mjs +186 -0
  103. package/src/js/getDelRefCount.mjs +138 -0
  104. package/src/js/getEffMode.mjs +20 -0
  105. package/src/js/getKpLossy.mjs +83 -0
  106. package/src/js/getNameErrs.mjs +194 -0
  107. package/src/js/getOwn.mjs +1 -1
  108. package/src/js/getPropKey.mjs +37 -0
  109. package/src/js/isAuthMode.mjs +20 -0
  110. package/src/js/joinNames.mjs +24 -0
  111. package/src/js/pairRowsToSnapshot.mjs +84 -0
  112. package/src/js/refAudit.mjs +287 -0
  113. package/src/js/refCascade.mjs +500 -0
  114. package/src/js/relDialogSend.mjs +127 -0
  115. package/src/js/rowDigest.mjs +193 -0
  116. package/src/plugins/dataReloadGuard.mjs +37 -0
  117. package/src/plugins/mShare.mjs +147 -174
  118. package/src/plugins/mUI.mjs +3 -8
  119. package/src/schema/tables/users.mjs +152 -152
  120. package/dist/css/app.6918ec45.css +0 -1
  121. package/dist/js/app-legacy.963e800c.js +0 -1
  122. package/dist/js/app.963e800c.js +0 -1
package/README.md CHANGED
@@ -1,117 +1,158 @@
1
- # w-web-perm
2
- A web service for permissions.
3
-
4
- ![language](https://img.shields.io/badge/language-JavaScript-orange.svg)
5
- [![npm version](http://img.shields.io/npm/v/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
6
- [![license](https://img.shields.io/npm/l/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
7
- [![npm download](https://img.shields.io/npm/dt/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
8
- [![npm download](https://img.shields.io/npm/dm/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
9
- [![jsdelivr download](https://img.shields.io/jsdelivr/npm/hm/w-web-perm.svg)](https://www.jsdelivr.com/package/npm/w-web-perm)
10
-
11
- ## Documentation
12
- To view documentation or get support, visit [docs](https://yuda-lyu.github.io/w-web-perm/WWebPerm.html).
13
-
14
- ## Installation
15
-
16
- ### Using npm(ES6 module):
17
- ```alias
18
- npm i w-web-perm
19
- ```
20
-
21
- #### Example for server:
22
- > **Link:** [[dev source code](https://github.com/yuda-lyu/w-web-perm/blob/master/srv.mjs)]
23
- ```alias
24
- import WOrm from 'w-orm-lmdb/src/WOrmLmdb.mjs'
25
- import WWebPerm from './server/WWebPerm.mjs'
26
- import getSettings from './g_getSettings.mjs'
27
-
28
-
29
- //st
30
- let st = getSettings()
31
-
32
- let url = st.dbUrl
33
- let db = st.dbName
34
- let opt = {
35
-
36
- serverPort: 11006,
37
- subfolder: '', //mperm
38
- urlRedirect: 'https://www.google.com/', //本機測試時得先編譯, 再瀏覽: http://localhost:11006/
39
-
40
- webName: {
41
- 'eng': 'Permission Service',
42
- 'cht': '權限管理系統',
43
- },
44
- webDescription: {
45
- 'eng': 'A web service package for user permissions and management targets.',
46
- 'cht': 'A web service package for user permissions and management targets.',
47
- },
48
- webLogo: 'data:image/svg+xml;base64,...',
49
-
50
- }
51
-
52
- let getUserByToken = async (token) => {
53
- // return {} //測試無法登入
54
- if (token === '{token-for-application}') { //提供外部應用系統作為存取使用者
55
- return {
56
- id: 'id-for-application',
57
- name: 'application',
58
- email: 'application@example.com',
59
- isAdmin: 'y',
60
- }
61
- }
62
- if (token === 'sys') { //開發階段w-ui-loginout自動給予browser使用者(且位於localhost)的token為sys
63
- return {
64
- id: 'id-for-admin',
65
- name: 'tester',
66
- email: 'admin@example.com', //mappingBy為email, 開發階段時會使用email找到所建置之使用者資料
67
- isAdmin: 'y',
68
- }
69
- }
70
- console.log('invalid token') //勿印權杖值
71
- console.log('於生產環境時得加入SSO等驗證token機制')
72
- return {}
73
- }
74
-
75
- let verifyClientUser = (user, from) => {
76
- console.log('verifyClientUser/user', user)
77
- // return false //測試無法登入
78
- console.log('於生產環境時得加入限制瀏覽器使用者身份機制')
79
- return user.isAdmin === 'y' //測試僅系統管理者使用
80
- }
81
-
82
- let verifyAppUser = (user, from) => {
83
- console.log('verifyAppUser/user', user)
84
- // return false //測試無法登入
85
- console.log('於生產環境時得加入限制應用程式使用者身份機制')
86
- return user.isAdmin === 'y' //測試僅系統管理者使用
87
- }
88
-
89
- //WWebPerm
90
- let instWWebPerm = WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser, opt)
91
-
92
- instWWebPerm.on('error', (err) => {
93
- console.log(err)
94
- })
95
- ```
96
-
97
- ## Upgrade Notes
98
-
99
- ### Unreleased: objects carrying a numeric `length` key, and non-array `rows` (2026-10-20)
100
- - **`rows` must be an array (behavior change)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` now reject a `rows` argument that is not an array (a string, number, `null`, `undefined`, a plain object, or an object carrying a numeric `length` key), or whose elements are not non-empty objects (`[null]`, `[{}]`, `[""]`, `[[]]`), with `saveRowFieldInvalid` and leave the table unchanged. Previously such input was treated as an empty table: the whole table was cleared and the call reported success (in the `modeEditUsers: 'for:grups'` mode it was silently ignored). An empty array `[]` is still accepted (it clears the table). The bundled front end and `/syncAndReplaceTabs` (which already required a non-empty array) are not affected.
101
- - **Objects are iterated by their own keys**: a field or key named `length` is an ordinary key everywhere user-supplied objects are walked (write validation, the `cgrups` / `cpemis` / `crules` JSON strings evaluated by `getPerm` / `getPermUserInfor`, and the list pages). Before, lodash treated such an object as an array-like of that length: real keys were skipped (for example a group listed next to `"length": 0` was ignored, so its rules evaluated to `n`) and a huge `length` made the call spin or run out of memory.
102
- - **`/syncAndReplaceTabs` rows must be non-empty objects (behavior change)**: every element of `rows` must be an object with at least one key. A push containing a string, number, `null`, array, boolean or an empty object `{}` is rejected as a whole with `invalidRows` before anything is deleted. Before, a non-object element made the call fail with an engine message (for example `Cannot create property 'from' on string 'abc'`) that was returned to the application, and an empty object in a multi-row push was written as a blank row with a generated id. A row that carries no schema field at all (for example `{"foo":1}`; it would map to an all-empty row) is rejected the same way.
103
- - **Authorization lookups use the exact id, not a lodash path (behavior change, tightening)**: `getUserRules` (used by `getPerm` / `getPermUserInfor`), the SDK `perm().active(name)` and the edit dialogs of user groups, group permissions and permission rules now look a target, group or permission up by its exact name as an own property. Before, a name containing `.` or `[` was resolved as a path: when only `A` was granted, targets `A.0` and `A[0]` evaluated to `y` (and `A.length` to `1`), and inherited names such as `toString` could be returned. Names that are themselves keys (for example a target called `A.0` granted by a rule named `A.0`, or one called `constructor` / `__proto__`) are granted as usual. In the admin session of `modeEditUsers: 'for:grups'`, a database id containing `.` or `[` can no longer be taken over by another row's nested field. The edit dialogs also keep an item named `__proto__` when saving (before, it was shown as enabled but silently dropped from the saved JSON).
104
- - **Object-form entries are named by their key (behavior change, tightening)**: in the `cgrups` / `cpemis` / `crules` JSON of the form `{"<name>": …}`, an inner `name` field no longer renames the entry (before, `{"A":{"name":"B","isActive":"y"}}` granted `B` while the edit dialogs showed `A`). The array form `[{"name":"A","isActive":"y"}]` is unchanged. The permission-rule edit dialog also reads object-form values (`{"A":{"isActive":"y"}}`) the same way as the server, so such a rule is no longer shown as disabled (and revoked on save).
105
- - **`/syncAndReplaceTabs` blank rows**: a row that carries no content outside the server-managed fields is rejected with `invalidRows`. Server-managed fields are `from`, `userId`, `timeCreate`, `userIdUpdate` and `timeUpdate` (the server overwrites them); a field value counts as content when it is a non-empty string, a finite number (`0` included) or a boolean (`""`, `null`, `[]` and `{}` do not).
106
-
107
- ### Upgrading to w-serv-orm 1.1.11 / w-syslog 1.0.33 (2026-10-14)
108
- - **Numeric ids are stored as strings**: a numeric row id sent to `/syncAndReplaceTabs` (or to `updatePemis`, `updateGrups`, `updateUsers`) is stored as its string form (`123` is stored as `'123'`) and the success response lists it as a string. Before, the number was replaced by a generated id while the response still showed the number. A number and its string form are the same id; `NaN` and `Infinity` count as no id (a new id is generated). `updateTargets` still accepts string ids only and rejects other ids with `saveRowFieldInvalid`.
109
- - **Ids owned by another source are rejected**: if an id pushed to `/syncAndReplaceTabs` already belongs to another `from`, the whole push is rejected with `invalidRows` before anything is deleted (before, the row was skipped while the push still returned success). External applications must use ids that do not overlap with other sources.
110
- - **Logging**: with w-syslog 1.0.33, a log write failure no longer terminates the process (it is reported on stderr), and `WWebPerm` throws at construction when `opt.logFd` cannot be created.
111
- - **Bundled usage**: the statistics worker file is located on first use from the installed package (`server/staLogs/staLogsCore.shellWorker.mjs`), so `dist/w-web-perm.umd.js` and downstream bundles can run the statistics; the package must be installed with its `server` folder.
112
-
113
- ### Upgrading to w-serv-orm 1.1.10 / w-serv-hapi 1.1.6 (2026-10-14)
114
- - **Removed options**: `opt.useCheckUser`, `opt.getUserById` and `opt.useExcludeWhenNotAdmin` were removed together with w-serv-orm 1.1.9. Passing any value other than `undefined`, `null` or `false` now throws when constructing `WWebPerm` (the message comes from w-serv-orm `checkOptRemoved`) and is logged to srLog. Delete these keys; users of the data channel are authorized by `verifyConn` (`verifyClientUser`), no replacement option is needed.
115
- - **Data channel is select-only (behavior change)**: only `<table>.select` of `targets`, `pemis`, `grups` and `users` is exposed (`methodsExec: ['select']`, and `authorizeExec` allows select only). Calling raw `insert`, `save`, `del` or `delAll` now returns `invalid func[<table>.<method>]`. Clients that wrote tables directly must call `updateTargets`, `updatePemis`, `updateGrups` or `updateUsers`, which apply the save lock, serialization, validation and whole-table diff.
116
- - **Write validation**: rows to be written are validated before anything is deleted or written. A string value that XSS cleaning would rewrite (for example `<` followed by a letter) is rejected with `htmlMarkupNotAllowed`; an id longer than 1978 UTF-8 bytes is rejected with `saveRowFieldInvalid` (admin lists) or `invalidRows` (`syncAndReplaceTabs`). Unexpected database write failures return `saveWriteFail`; the ORM messages are written to srLog only.
117
- - **Process-level errors**: w-serv-hapi 1.1.6 no longer registers `unhandledRejection` or `uncaughtException` handlers, and `WWebPerm` does not register them either; the deployment decides its own process policy.
1
+ # w-web-perm
2
+ A web service for permissions.
3
+
4
+ ![language](https://img.shields.io/badge/language-JavaScript-orange.svg)
5
+ [![npm version](http://img.shields.io/npm/v/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
6
+ [![license](https://img.shields.io/npm/l/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
7
+ [![npm download](https://img.shields.io/npm/dt/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
8
+ [![npm download](https://img.shields.io/npm/dm/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
9
+ [![jsdelivr download](https://img.shields.io/jsdelivr/npm/hm/w-web-perm.svg)](https://www.jsdelivr.com/package/npm/w-web-perm)
10
+
11
+ ## Documentation
12
+ To view documentation or get support, visit [docs](https://yuda-lyu.github.io/w-web-perm/WWebPerm.html).
13
+
14
+ ## Installation
15
+
16
+ ### Using npm(ES6 module):
17
+ ```alias
18
+ npm i w-web-perm
19
+ ```
20
+
21
+ #### Example for server:
22
+ > **Link:** [[dev source code](https://github.com/yuda-lyu/w-web-perm/blob/master/srv.mjs)]
23
+ ```alias
24
+ import WOrm from 'w-orm-lmdb/src/WOrmLmdb.mjs'
25
+ import WWebPerm from './server/WWebPerm.mjs'
26
+ import getSettings from './g_getSettings.mjs'
27
+
28
+
29
+ //st
30
+ let st = getSettings()
31
+
32
+ let url = st.dbUrl
33
+ let db = st.dbName
34
+ let opt = {
35
+
36
+ serverPort: 11006,
37
+ subfolder: '', //mperm
38
+ urlRedirect: 'https://www.google.com/', //本機測試時得先編譯, 再瀏覽: http://localhost:11006/
39
+
40
+ webName: {
41
+ 'eng': 'Permission Service',
42
+ 'cht': '權限管理系統',
43
+ },
44
+ webDescription: {
45
+ 'eng': 'A web service package for user permissions and management targets.',
46
+ 'cht': 'A web service package for user permissions and management targets.',
47
+ },
48
+ webLogo: 'data:image/svg+xml;base64,...',
49
+
50
+ }
51
+
52
+ let getUserByToken = async (token) => {
53
+ // return {} //測試無法登入
54
+ if (token === '{token-for-application}') { //提供外部應用系統作為存取使用者
55
+ return {
56
+ id: 'id-for-application',
57
+ name: 'application',
58
+ email: 'application@example.com',
59
+ isAdmin: 'y',
60
+ }
61
+ }
62
+ if (token === 'sys') { //開發階段w-ui-loginout自動給予browser使用者(且位於localhost)的token為sys
63
+ return {
64
+ id: 'id-for-admin',
65
+ name: 'tester',
66
+ email: 'admin@example.com', //mappingBy為email, 開發階段時會使用email找到所建置之使用者資料
67
+ isAdmin: 'y',
68
+ }
69
+ }
70
+ console.log('invalid token') //勿印權杖值
71
+ console.log('於生產環境時得加入SSO等驗證token機制')
72
+ return {}
73
+ }
74
+
75
+ let verifyClientUser = (user, from) => {
76
+ console.log('verifyClientUser/user', user)
77
+ // return false //測試無法登入
78
+ console.log('於生產環境時得加入限制瀏覽器使用者身份機制')
79
+ return user.isAdmin === 'y' //測試僅系統管理者使用
80
+ }
81
+
82
+ let verifyAppUser = (user, from) => {
83
+ console.log('verifyAppUser/user', user)
84
+ // return false //測試無法登入
85
+ console.log('於生產環境時得加入限制應用程式使用者身份機制')
86
+ return user.isAdmin === 'y' //測試僅系統管理者使用
87
+ }
88
+
89
+ //WWebPerm
90
+ let instWWebPerm = WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser, opt)
91
+
92
+ instWWebPerm.on('error', (err) => {
93
+ console.log(err)
94
+ })
95
+ ```
96
+
97
+ ## Upgrade Notes
98
+
99
+ ### Unreleased: external rows are protected from the admin pages, and saves carry a load baseline (2026-10-22, F-23 / F-26, ADR-037)
100
+ - **External rows are protected from the admin pages (behavior change, `opt.lockExternalRows`, default `'y'`)**: a row whose `from` is a non-empty string is an *external row* (normally written by an application through `/syncAndReplaceTabs`, which only accepts a non-empty `from`); a row whose `from` is `''`, missing, or not a string (`null`, number, boolean) is an *admin row*. With `lockExternalRows: 'y'` the server enforces the following for the admin pages and for direct `updateTargets` / `updatePemis` / `updateGrups` / `updateUsers` calls alike. The whole save is refused, nothing is written and nothing is silently ignored: (1) a **new** row must have `from === ''` (a copy of an external row, a re-created row and a typed value are all refused), and an admin row cannot be turned into a row with a non-empty `from`: `saveFromNotAllowed`; (2) an external row cannot be **deleted**, cannot change its `from`, and cannot change any identity / structure field (every schema field except `id`, `from`, `order`, the assignment field and the server-managed fields `userId` / `timeCreate` / `userIdUpdate` / `timeUpdate`; for `users` this includes `name`, `email`, `isAdmin` and `isActive`): `saveExternalRowChanged`. The **assignment fields** (`users.cgrups`, `grups.cpemis`, `pemis.crules`; targets have none) stay writable from the admin pages ("Lv1"), but changing one on an external row needs the load baseline described below. The `order` of an external row belongs to the external source (a save does not renumber it), and an external row that differs from the stored one only in server-managed fields counts as unchanged. `opt.lockExternalRows: 'n'` restores the old behavior (only the `from` type check of the next item remains). The setting is returned by `getWebInfor()` as `lockExternalRows` (`'y'` / `'n'`).
101
+ - **The admin pages show external rows as read-only**: a lock icon with a tooltip in the first column, a banner above the list (rendered only when the table has external rows or there is a synchronization warning; otherwise the page is exactly as before), the `from` column read-only on every row (also with `lockExternalRows: 'n'`; before this release it could be edited in the list pages), the `isAdmin` / `isActive` check boxes of an external user disabled, "delete" skipping external rows with a notice, and "copy" resetting `from` to `''`. This is a convenience: the server is the enforcer (a paste into a read-only cell, see the known limitations below, is stopped by the page before sending and by the server anyway).
102
+ - **An administrator cannot disable or delete an external user (policy)**: identity fields, including `isActive` and `isAdmin`, belong to the external source. To disable an external user urgently, do it at the source (push the source's rows without that user, or with `isActive: 'n'`, and wait for the next synchronization) or remove the source's rows offline with `g_purgeFrom.mjs` (see the upgrade steps below). There is no local "disable override" on purpose (the next synchronization would overwrite it), and no way to "claim" an external row as an admin row (the source would then be refused for that id, `idOtherFrom`).
103
+ - **`from` must be a string (behavior change)**: whatever `lockExternalRows` says, a save that adds a row with a non-string `from` (`null`, number, boolean, ...) or changes the `from` of a stored row to a non-string is refused with `saveFromNotAllowed`. A historical row whose `from` is `null` and which is sent back unchanged is not refused, so a database that still holds such rows can be saved.
104
+ - **`updateXxx(rows, { baseline })` (RPC contract, optional second argument)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` accept `{ baseline }`, where `baseline` is `[[id, digest], ...]` (every element an array of two strings) describing the rows the edit is **based on**. Build it with `makeBaseline(rows, keys)` of `src/js/rowDigest.mjs` (`keys` is `ds[keyTable].keys` of `src/schema/index.mjs`); the digest is a content fingerprint of a row that leaves out `order` and the server-managed fields (a synchronization rewrites the time fields of every row of its source, and the save renumbers `order`, so neither may invalidate a draft). Inside the table lock the server compares the **whole table** with the baseline (changed in 2026-10-22 run11; see the next-but-one item): if any stored row has a different digest from the one the baseline lists for its `id`, or a row was added or removed, the whole save is refused with `saveStale` (reload the page and redo the edit) and nothing is written, whether or not that row is one the save would write. Differences in `order` or in the server-managed fields alone are not changes, so a synchronization that rewrites its rows without changing their content does not make a save fail. A malformed `baseline` is refused with `saveRowFieldInvalid` before anything is read or written, and every other key of the client's second argument is ignored (only `baseline` is taken). **A custom client that sends no baseline keeps the old behavior for admin rows (last writer wins, ADR-013), but cannot change the assignment fields of an external row: it gets `saveBaselineRequired`.**
105
+ - **The bundled pages bind the baseline when the edit draft is created, never at save time**: the four list pages compute it where they rebuild their rows from the data store (so a push from the server replaces draft and baseline together when there are no unsaved edits; with unsaved edits the draft and its baseline are kept, see the next item), and the two "belongs to" dialogs compute it when they open and keep it until they are opened again. A baseline taken at save time would only prove that the store is new, not that the edited content is. The "belongs to" dialogs (group page "users", permission page "groups") also changed how they save: they send the whole table as the store snapshot taken **at save time** and change only the one entry of the rows the administrator actually changed (`src/js/relDialogSend.mjs`); every other row goes back unchanged, so a user or group that someone else, or an external synchronization, added while the dialog was open is no longer deleted by the save.
106
+ - **Concurrent editing: first writer wins, and the page no longer drops a draft silently (behavior change, 2026-10-22, run11; ADR-037 Update)**: when the stored table differs in **any** row (content, ignoring `order` and the server-managed fields), or in the set of rows (a row added or removed), from the table the administrator's draft was loaded from, the whole save is refused with `saveStale` and nothing is written: no partial save, no lock; the first writer wins and the other must reload. Before this change only the rows the save would write were compared, so another administrator's edit of a *different* row did not make a save fail; now it does. The comparison is by content: an external synchronization that rewrites its rows without changing their content (the creator / time fields and `order` change) does not change the table digest, so it does not block an administrator; one that really changes a row does (the external source is the master). A save from a client that sends no baseline is not compared (admin rows: the later write wins, ADR-013; changing the assignment fields of an external row: `saveBaselineRequired`). **The bundled list pages keep an unsaved draft**: when a push brings new data while the page has unsaved edits, the draft and the baseline it was loaded with are kept (nothing is rebuilt), and, if the table now differs from that baseline, a banner explains that the data was updated by someone else or by a synchronization, that saving will be rejected, and offers a "Reload" button that discards the draft and reloads the table (`staleDraftNotice` / `staleDraftReload`); a page without unsaved edits is rebuilt from the push as before. The two "belongs to" dialogs show `staleDraftNoticeDialog` when a push arrives while they hold unsaved edits and the table differs from the one they were opened with (close and reopen to reload). Notes: (1) an external source that pushes rows **without `id`** gets new random primary keys on every push, which changes the set of rows and therefore the digest every time; keep `id` stable (as already advised) or administrators' open pages will be refused after each synchronization; (2) another administrator who only changes the order of rows (drag and drop) does not make a save fail, and the order of the later save wins; (3) `saveStale` text and the position of the reload button are covered by `test/e2e-external-rows.test.mjs` E2E-016 to E2E-024.
107
+ - **External synchronization keeps the assignments made in the admin pages**: a `users` row pushed to `/syncAndReplaceTabs` **without a `cgrups` key** keeps the `cgrups` stored for the matching row of the same `from` (matched by `id`; a row without `id` is matched by `email` / `name` when `mappingBy` is `'email'` / `'name'` and exactly one stored row of that source has the value; no match gives `''`); a pushed `cgrups` key, even `''`, is taken as given. When `mappingBy` is `'email'` / `'name'`, a row matched by `id` keeps its assignment only if its `email` / `name` is unchanged (an `id` that now points to a different login identity is treated as a different user: the assignment is cleared rather than moved), and a stored value that could not pass the normal pre-write validation is not kept either (logged as `syncAndReplaceTabs-keepAssign-skip`). `grups.cpemis` and `pemis.crules` are treated the same way (all three are assignment fields that the admin pages may write): a `grups` row without a `cpemis` key, or a `pemis` row without a `crules` key, keeps the stored value of the same entity of the same `from`; a pushed key, even `''`, is taken as given. Groups and permissions have no login key, so the entity is identified by `id`; a row without `id` (the server gives it a new random primary key) is matched by name and exactly one stored row of that source must have that name. A rename under the same `id` keeps the value while `opt.cascadeRename` is on (the references are rewritten to the new name, so it is the same entity); with `cascadeRename: false` the references do not follow, the name is the identity, and the value is not moved to the new name (logged as `syncAndReplaceTabs-keepAssign-skip` with `reason: 'identityConflict'`, as is a row without `id` that matches several stored rows with the same name). An `id` that is not stored for that source is a new entity (`''`). Stored values that could not pass the normal pre-write validation are not kept (`reason: 'notKeepable'`). **Behavior change for `cpemis` / `crules`**: before this release a push without these keys cleared what the admin pages had assigned, on every push; now it is kept. If your application relied on the push to reset the assignments, send the key explicitly (for example `cpemis: ''`). Renumbered ids are covered by the next item.
108
+ - **Warning when an external source seems to renumber its ids**: when a push for `grups` or `pemis` contains, for one `from`, a row whose name belonged to a stored row of that source with an id that is not in the push (the same name under a different id), the server logs `syncAndReplaceTabs-idRenumber` (table, source and count only) and lists `{ keyTable, from, n, time }` in `getWebInfor().syncWarns`; the admin page shows a banner, and the entry is removed by the next push of that source without the pattern. It is kept in memory only (a restart clears it) and does not change what the push does: the advice "keep ids stable (or push without ids)" above still applies, because a vanished id is a deletion and removes the references to it.
109
+ - **Two new confirmations in the admin pages**: saving a new group, permission or target whose name is still referenced by active entries (users' `cgrups`, groups' `cpemis`, permissions' `crules`; the reference was dangling until now) asks first, and says how many users (groups, permissions) will get, or be limited by, the new entity and how many of them refer to it with `AND` (a target's rule entries have no mode, so no `AND` sentence); adding it is allowed, saving only continues after "Yes". When a duplicate name involves an external row, the warning icon of the name cell says that the external source already uses the name (the server still refuses an admin row whose name an external row has, and an external push whose name an admin row has, as before).
110
+ - **Upgrade steps (stop the backend first)**: after the upgrade every row whose `from` is a non-empty string is an external row and becomes read-only in the admin pages, including rows that were typed or inherited by "copy" in the admin pages in the past. The development seed now writes `from: ''` for the users `peter`, `mary` and `john` (it was `teamA`; `g_initialData.mjs`, `src/schema/tables/users.mjs`), so **they are admin rows, not external rows**; a database created with the earlier seed still carries `teamA` on them and should be rebuilt with `g_initialData.mjs`. Recommended order: (1) stop the backend and upgrade; (2) `node g_fixFromType.mjs` lists the rows whose `from` is not a string (type distribution and the first ids; nothing is changed) and `node g_fixFromType.mjs --apply` rewrites them to `''` (admin rows) after backing up the database and the four tables; (3) `node g_purgeFrom.mjs` lists, per table, how many rows carry each `from` (non-empty ones are marked as external); (4) if an external source is gone, `node g_purgeFrom.mjs --table <targets|pemis|grups|users|all> --from <source>` lists what would be removed and which references would be cleaned (dry run), and the same command with `--apply` removes that source's rows together with the references to them (the same effect as deleting them in the admin pages, which also removes the references, see above; planned with the same function as the server's reference cascade; backup first, the written result is verified; `--from ''` is refused; `--db <folder>` and `--backup <file>` are available); the apply steps refuse to run when the database is held by another process (detected on Windows only, elsewhere check yourself); (5) start the backend. There is no tool that turns an external row into an admin row: rows that were created in the admin pages with a non-empty `from` have to be purged and re-created, or keep the old behavior with `opt.lockExternalRows: 'n'`. **Running the tools from the installed package**: `g_fixFromType.mjs`, `g_purgeFrom.mjs` and `g_authRefsClean.mjs` and their cores (`server/tools/fromTools.mjs`, `server/tools/authRefsClean.mjs`, `server/tools/authRefsAudit.mjs`, `server/tools/nameDupReport.mjs`) are part of the npm package (they do not depend on `test/`, which the package does not ship). Run them with the **working directory set to the project root of your deployment**, using the path inside `node_modules`, for example `node node_modules/w-web-perm/g_purgeFrom.mjs` or `node node_modules/w-web-perm/g_fixFromType.mjs --apply`. The data folder is relative to the working directory (`g_getSettings.mjs`: `dbUrl: './db'`, database name `wperm`, so the data lives in `./db/wperm` of the folder you run it in; use `--db <folder>` for another location; the tools stop with an error when `<folder>/wperm` does not exist, but they do not check that it already holds your data, so point them at the right folder), and the backup files they write (`backup-<time>.json`, `purgeFrom-backup-<time>.json`, `authRefsClean-backup-<time>.json`) also land in the working directory. The packages they import (`w-orm-lmdb`, `w-serv-orm`, `wsemi`) are resolved from the installed package location, so they must be installed in your project. The apply steps of the three scripts stop before any backup or write when a row would not survive the JSON backup unchanged (`NaN`, `Infinity`, `bigint`, `Date`, `undefined`, `Buffer` values). The offline audit helpers that read an export file, `server/tools/authRefsAudit.mjs` and `server/tools/nameDupReport.mjs`, are run the same way (`node node_modules/w-web-perm/server/tools/nameDupReport.mjs <export.json>`). The one exception is `test/tools/authMergeReport.mjs`, which is built on the test harness (golden data, the merge-rule model) and is **not** shipped: run it from a source checkout of the same version.
111
+ - **Known limitations**: (1) **w-aggrid-vue**: the paste path of `WAggridVue.vue` (`node_modules/w-aggrid-vue`, 2.1.9 when checked; lines 1556-1558 and 1648) decides editability by the truthiness of the column's `editable`, and the per-row function used for external rows is always truthy, so Ctrl+V can write into a cell that is read-only on that row; the page checks the rows before it sends them and shows `saveExternalRowChanged`, and the server refuses such a save anyway. (2) `syncWarns` is not live: the page reads `getWebInfor()` once at start-up (`src/main.js`), so a warning raised later shows after a reload. (3) Concurrent editing is compared for the **whole table** (see "Concurrent editing" above): after your draft was created, a change by someone else or by a synchronization to **any** row, or a row added or removed, refuses the save with `saveStale`; the draft is kept and a banner offers "Reload". Known gaps, all leaving the draft in place and writing nothing: (a) saving again within about two seconds after your own successful save, before the push of that save arrives, is refused as if someone else had changed the table (reload and save again); (b) a "belongs to" dialog that was opened but not yet edited shows no banner when the table changes, and its later save is refused by the server; (c) a cell editor that is still open (text typed but not committed with Enter) can lose the typed text when a push arrives; (d) clearing a text cell stores `null` while an untouched empty cell holds `''`, and the fingerprint tells them apart, so another administrator clearing a field that was already empty counts as a change; (e) a change of `order` alone (drag and drop) is not a conflict, and the later save's order wins. These are planned to be fixed when the pages move to the shared baseline of w-serv-orm. (4) A client that sends no baseline is not protected for admin rows (last writer wins). (5) With `modeEditUsers: 'for:grups'` the server keeps taking only `cgrups` from the rows, so an attempted change of an external user's identity fields is ignored there instead of refused (existing design; nothing is written). (6) With `lockExternalRows: 'n'` none of the protections above except the `from` type check apply, and the pages show external rows as editable again (the `from` column stays read-only and "copy" still resets `from`).
112
+
113
+ ### Unreleased: objects carrying a numeric `length` key, and non-array `rows` (2026-10-20)
114
+ - **`rows` must be an array (behavior change)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` now reject a `rows` argument that is not an array (a string, number, `null`, `undefined`, a plain object, or an object carrying a numeric `length` key), or whose elements are not non-empty objects (`[null]`, `[{}]`, `[""]`, `[[]]`), with `saveRowFieldInvalid` and leave the table unchanged. Previously such input was treated as an empty table: the whole table was cleared and the call reported success (in the `modeEditUsers: 'for:grups'` mode it was silently ignored). An empty array `[]` is still accepted (it clears the table). The bundled front end and `/syncAndReplaceTabs` (which already required a non-empty array) are not affected.
115
+ - **Objects are iterated by their own keys**: a field or key named `length` is an ordinary key everywhere user-supplied objects are walked (write validation, the `cgrups` / `cpemis` / `crules` JSON strings evaluated by `getPerm` / `getPermUserInfor`, and the list pages). Before, lodash treated such an object as an array-like of that length: real keys were skipped (for example a group listed next to `"length": 0` was ignored, so its rules evaluated to `n`) and a huge `length` made the call spin or run out of memory.
116
+ - **`/syncAndReplaceTabs` rows must be non-empty objects (behavior change)**: every element of `rows` must be an object with at least one key. A push containing a string, number, `null`, array, boolean or an empty object `{}` is rejected as a whole with `invalidRows` before anything is deleted. Before, a non-object element made the call fail with an engine message (for example `Cannot create property 'from' on string 'abc'`) that was returned to the application, and an empty object in a multi-row push was written as a blank row with a generated id. A row that carries no schema field at all (for example `{"foo":1}`; it would map to an all-empty row) is rejected the same way.
117
+ - **Authorization lookups use the exact id, not a lodash path (behavior change, tightening)**: `getUserRules` (used by `getPerm` / `getPermUserInfor`), the SDK `perm().active(name)` and the edit dialogs of user groups, group permissions and permission rules now look a target, group or permission up by its exact name as an own property. Before, a name containing `.` or `[` was resolved as a path: when only `A` was granted, targets `A.0` and `A[0]` evaluated to `y` (and `A.length` to `1`), and inherited names such as `toString` could be returned. Names that are themselves keys (for example a target called `A.0` granted by a rule named `A.0`, or one called `constructor` / `__proto__`) are granted as usual. In the admin session of `modeEditUsers: 'for:grups'`, a database id containing `.` or `[` can no longer be taken over by another row's nested field. The edit dialogs also keep an item named `__proto__` when saving (before, it was shown as enabled but silently dropped from the saved JSON).
118
+ - **Object-form entries are named by their key (behavior change, tightening)**: in the `cgrups` / `cpemis` / `crules` JSON of the form `{"<name>": …}`, an inner `name` field no longer renames the entry (before, `{"A":{"name":"B","isActive":"y"}}` granted `B` while the edit dialogs showed `A`). The array form `[{"name":"A","isActive":"y"}]` is unchanged. The permission-rule edit dialog also reads object-form values (`{"A":{"isActive":"y"}}`) the same way as the server, so such a rule is no longer shown as disabled (and revoked on save).
119
+ - **`/syncAndReplaceTabs` blank rows**: a row that carries no content outside the server-managed fields is rejected with `invalidRows`. Server-managed fields are `from`, `userId`, `timeCreate`, `userIdUpdate` and `timeUpdate` (the server overwrites them); a field value counts as content when it is a non-empty string, a finite number (`0` included) or a boolean (`""`, `null`, `[]` and `{}` do not).
120
+ - **Field values must be scalar (behavior change)**: every field of a row written by `updateTargets`, `updatePemis`, `updateGrups`, `updateUsers` or `/syncAndReplaceTabs` must be a string, number, boolean, `null` or absent. An object, array or other non-scalar value is rejected as a whole before anything is deleted (`saveRowFieldInvalid` for the admin pages, `invalidRows` for `/syncAndReplaceTabs`; the existing rows stay unchanged). No table has an object-typed field: `cgrups`, `cpemis` and `crules` are JSON **strings**. Before, a nested string such as `{ "description": { "value": "<script>" } }` slipped past the write validation and was silently rewritten when stored.
121
+ - **Edit dialogs decode authorization data exactly like the server (behavior change, tightening)**: `cgrups` / `cpemis` / `crules` may also be written in the array form `[{ "name": "A", "isActive": "y" }]` (an external application writing directly; `getPerm` / `getPermUserInfor` already honor it). The edit dialogs for user groups, group permissions and permission rules (and the two "belongs to" dialogs) used to look entries up by name, showed such entries as disabled, and saving revoked the authorization. They now decode both forms with the same function as the server, so what a dialog shows matches what is authorized, and saving keeps the authorization unchanged. Details: (1) in `cgrups` / `cpemis`, an entry with `isActive: "y"` but a missing or invalid `mode` (anything other than `"OR"` / `"AND"`) is not honored by the server; the dialogs now show it as disabled (before, object-form entries like that were shown as enabled and saving filled in `OR`, silently granting it) **[superseded on 2026-10-21 by the "Conflicting or ambiguous authorization data" item below: such an entry is now honored by the server as a restriction (it only intersects with the other entries and never grants on its own), and the dialogs show it as an enabled `AND` entry]**; (2) in the object form `{ "<name>": { "name": …, … } }` the outer key is the name and an inner `name` is ignored, also in the "belongs to" dialogs (before, saving there re-pointed the grant to the inner name); (3) the three direct editors always save the object form `{ "<name>": … }`, the two "belongs to" dialogs rewrite the stored string only when its decoded content changes; (4) see the next two items for the new merge rule (order independent) and for the case the object form cannot hold (the same name active as both `AND` and `OR`).
122
+ - **The result of merging `AND` entries no longer depends on their order (behavior change, 2026-10-21, ADR-033)**: in `cgrups` / `cpemis` / `crules` an entry that is **not listed counts as `n`**. For each target the result is: `y` ⇔ (if any active `OR` entry exists: at least one `OR` entry gives `y`) and (every active `AND` entry gives `y`); a level with no active entry at all gives `n`. Before, with no `OR` entry the first `AND` entry was the base and the other `AND` entries only constrained the targets they listed, so the stored order of two partial `AND` entries decided the result; with an `OR` entry present an `AND` entry that did not list a target did not constrain it. The same rule applies to the permissions inside a group and to the groups of a user, and the order of entries, object keys or array elements never matters. Two consequences follow (they were not spelled out in the ruling and need your confirmation): (1) repeating the same rule name inside one array-form `crules` now gives `y` only if every repeated entry is `y` (before the last entry won); (2) an `AND` group or permission that holds no rules at all counts as "all `n`": it removes every target granted by the `OR` entries (before it was ignored or zeroed everything depending on its position). **It can only take authorization away, never add it**, compared with the old rule (verified over 223,384 exhaustive cells). Users whose authorization used to rely on "an `AND` permission does not list this target" lose that target. **Before upgrading, audit your data**: export `{ users, grups, pemis, targets }` as JSON and run `node test/tools/authMergeReport.mjs --audit <export.json>`; it lists, per user, the targets the new rule takes away (`被收回`), whether the old result depended on the stored order, and groups/permissions with duplicate names. (Limitation: a rule whose name cannot be used as a property key, e.g. `{ "toString": null }`, made the old rule throw, so such a user effectively had **no** authorization; the audit skips that rule like the new server does, so those users' `被收回` figures are not the old server's real result.) The permission-rule edit dialog stays two-state (checked / unchecked): unchecking a target (stored as `n`) is the same as not listing it, and saving a dialog that wrote unlisted targets as explicit `n` no longer changes anything (under the old rule such a save could silently revoke access).
123
+ - **Data that the object form cannot hold is not rewritten (behavior change, fail-closed)**: when a `cgrups` / `cpemis` array (written directly by an external application) has the same name active as both `OR` and `AND`, the edit dialogs refuse to write it (the object form holds one mode per name, and keeping either side would add or remove authorization). One more case is refused the same way (2026-10-21, ADR-035): an active entry without a usable name. (A level whose active entries **all** have a missing or invalid `mode` was refused too, but since 2026-10-22 it can be saved: the dialogs write unchanged entries back with their original `mode`, so nothing more is granted; see the item on the edit dialogs below.) A direct editor with changes, or a "belongs to" dialog with a change on such a row, is not saved and shows a fixed message naming the affected names; the stored string is left untouched, and a dialog opened and saved without changes returns the original string. The data has to be corrected at its source. Data written by the dialogs themselves (the object form) is never affected. The dialogs no longer keep the order of the stored entries and no longer refuse an integer-like name such as a group called `1` (those protections only existed because the order used to matter; see the previous item).
124
+ - **List pages decode like the server**: the user, group and permission lists count and name the groups, permissions and rules of an array-form `cgrups` / `cpemis` / `crules` the same way as the server (before, the user list used the array index as the group name and the permission list counted 0 rules). Display only; nothing is written.
125
+ - **`/syncAndReplaceTabs` requires non-empty string names and ids (behavior change)**: for `grups` and `pemis` the `name` of every row, and for `targets` the `id` of every row, must be a non-empty string (as the admin pages already required). A missing value, an empty string, or a non-string (number, boolean, `null`) rejects the whole push with `invalidRows` before anything is deleted. Before, a group or permission with `name: ""` was stored and honored by `getPerm` / `getPermUserInfor`, but the edit dialogs treated it as invalid and removed it on the next save. `users` rows are not affected.
126
+ - **"Belongs to" dialogs open after adding a row, and pair rows by identity (bug fix, 2026-10-21)**: a user or group added in the admin pages starts with an empty `cgrups` / `cpemis` string. The two "belongs to" dialogs (group page "users", permission page "groups") decoded every row of the table and failed on the empty string, so as soon as one newly added row existed none of those dialogs could be opened. An empty or unparsable string is now treated as "no memberships" (the same as the server does); a row whose string was unparsable and whose membership you did not change is saved back unchanged (as long as that stored string was not changed by someone else while the dialog was open; the dialogs write the whole table back, so the usual last-writer-wins applies). In addition, when such a dialog saves, each of its rows is now matched to the stored user / group by its `id` (a group must also still have the same `name`) instead of by its position in the list, so a list that was re-ordered while the dialog was open can no longer write a membership to the wrong user or group. If a user or group shown in the dialog has been deleted (or a group renamed) in the meantime, nothing is saved and a message asks you to reload the page. The same re-matching by `id` applies when the direct editors (user groups, group permissions, permission rules) hand their result back to the list page, and the "select all / none / invert" buttons of the group page's user dialog now address rows by user `id` (user names may repeat). Note that these dialogs still write the whole table back: a user or group that someone else adds while the dialog is open is not part of the dialog and is removed by that save. **[Superseded on 2026-10-22: the dialogs now send the store snapshot taken at save time and change only the rows the administrator changed, and they carry a load baseline; see the item "The bundled pages bind the baseline when the edit draft is created" of the 2026-10-22 section above.]**
127
+ - **A rule name that cannot be a property key no longer fails the whole query (bug fix, 2026-10-21)**: `crules` is valid JSON even when an element of the array form has a `name` such as `{"toString": null}`, which JavaScript cannot turn into a key. Before, `getPerm` / `getPermUserInfor` threw for every user holding that permission and returned the engine message (`Cannot convert object to primitive value`) to the calling application. Now that element is ignored (it matches no target) and the rest of the rules are evaluated as usual; the same holds for the permission-rule edit dialog and the list pages. Any other failure while evaluating a user's rules is logged and answered with the fixed key `cannotGetUserRules`, never with the engine message.
128
+ - **Group and permission names must be unique (behavior change, 2026-10-21, F-02)**: authorization looks groups and permissions up by name (the server takes the first row with that name), so two rows with the same name made the result depend on the database row order. The admin pages already rejected duplicates; `/syncAndReplaceTabs` now rejects a push for `grups` or `pemis` with `invalidRows` (before anything is deleted, nothing changes) when two rows of the push share a `name`, or when a `name` is already used by a row of another `from` (rows of the same `from` are replaced by the push and do not count). Names are compared exactly: `Admin`, `admin` and `Admin ` are different names. The admin lists reject duplicates with the new key `nameDuplicate` (before: `saveRowFieldDuplicate`; users' `email` and targets' `id` are unchanged), and the server only refuses when the save creates or touches the duplicate (duplicates already in the database do not make a save sent to the API fail, so they can be repaired), and a save now always matches the stored rows of groups and permissions by `id` (before, by `name` unless the database already held two rows with the same name): a rename is an update that keeps the creator and creation time, and these single saves, which used to report success while leaving two rows with the same name or losing the rename, now give the intended result: renaming a row and adding a new row with its old name, deleting a row and adding a new row with the same name, and a chain of renames (`A`→`B` and `B`→`C`). Two rows of the submitted table with the same `id` are refused with `saveRowFieldDuplicate`. **Cleaning up existing duplicates** (nothing is deleted or renamed automatically): run `node server/tools/nameDupReport.mjs <export.json>` (read-only; the file is `{ users, grups, pemis, targets }`, each an array of rows) to list every same-name group and which `users.cgrups` / `grups.cpemis` refer to it; then rename or delete one of the rows in the admin pages. **References are matched by name and renaming does not update them (F-07 is not implemented)**: renaming or deleting one row makes every reference to that name point to the remaining row or to nothing, so list the references with the tool first and check each user's authorization afterwards (`getPermUserInfor` / `getPerm`). **The admin pages are stricter than the server**: the group / permission lists still mark every duplicate name and refuse to send the save until one of the rows is renamed or deleted, so with duplicates already in the database nothing else can be saved from the admin page until the duplicates are fixed (known gap, listed for a decision) **[resolved on 2026-10-21: the lists now use the same rule as the server — see the "Conflicting or ambiguous authorization data" item below]**.
129
+ - **The user mapping key must identify one user (behavior change, 2026-10-21, F-14)**: with `mappingBy: 'email'` or `'name'`, a login looks the user up in the permission system by that field. Before, when several active users matched, the first one in the database was taken, so the signed-in identity depended on the row order. Now more than one active match is refused (`ambiguousUserFromPerm`, logged with `mappingBy` and the number of matches but not the value; the login page redirects as for any refused login, and the data channel answers `permission denied`); no match still gives `cannotGetUserFromPerm`. To keep the key unique, `/syncAndReplaceTabs` for `users` now rejects the whole push with `invalidRows` when `mappingBy` is `'email'` or `'name'` and an active (`isActive: 'y'`) row has an empty or blank value in that field, repeats the value of another active row of the push, or uses a value held by an active row of another `from` (inactive rows are ignored; values are compared exactly). With the default `mappingBy: 'id'` nothing changes for `/syncAndReplaceTabs` (emails may stay empty or repeated there) — but note that the admin user list itself still requires a non-empty, unique email on **every** row (active or not), so such rows have to be corrected before the admin page can save users **[superseded on 2026-10-21: only an email that the current save creates or touches is checked, see the "Conflicting or ambiguous authorization data" item below]**. **If you run with `mappingBy: 'email'` or `'name'`**, run `node server/tools/nameDupReport.mjs <export.json> --mapping-by email` (or `name`) first: users that are active with an empty or repeated key cannot sign in until fixed, and the next push from the owning application is rejected until the data is corrected. The backend `updateUsers` API only checks `email` (not `name`); the admin user list page itself also rejects duplicate names before saving, so only a direct API write can store two users with the same name, and with `mappingBy: 'name'` such users are refused at login rather than at save **[superseded on 2026-10-21: with `mappingBy: 'name'` or `'email'` the save now also checks that field of the active users, see below]**.
130
+ - **Same-table writes are serialized; two small fixes (2026-10-21)**: `/syncAndReplaceTabs` and the admin pages' whole-table save now take the same per-table lock, so the checks that keep group / permission names and the user mapping key unique can no longer be interleaved with the other writer (before, each took its own lock: a push and a save landing between each other's check and write could both succeed and leave two rows with the same name; a save made while a large push is running now waits for it). In `modeEditUsers: 'for:grups'`, a user row sent with a numeric id (for example `7` for the stored id `'7'`) used to have its group assignment silently skipped while the call reported success; it is now matched. A group / permission whose name is the **empty string** (only writable by `/syncAndReplaceTabs` before 2026-10-20) is still honored by the server, but the edit dialogs treat an empty name as invalid, so saving a dialog drops it and, if it was an `AND` limit, adds authorization: `node test/tools/authMergeReport.mjs --audit <export.json>` now lists such rows ("名稱為空字串或非字串之群組/權限") — rename them in the admin pages before relying on the dialogs **[superseded on 2026-10-21: the dialogs now keep such an entry as it is instead of dropping it, see below; renaming is still advisable]**.
131
+ - **Conflicting or ambiguous authorization data is resolved strictly, and the admin pages flag it (behavior change, tightening, 2026-10-21, ADR-035)**: when stored data contradicts itself or cannot be read in a single way, the server takes the strictest reading, and the admin pages show a warning icon (a triangle with an exclamation mark) at the left of the affected cell, with a tooltip that says exactly what is wrong and what the system does about it. Three cases change what a user is authorized for:
132
+ 1. **A reference to a group or permission that does not exist** (deleted, renamed or misspelled) used to be ignored. It is now still counted with the `mode` it was written with, but grants nothing: an `OR` reference adds nothing, an `AND` reference leaves the result empty. Example: a user whose `cgrups` lists group `G1` as `OR` and group `G2` as `AND` is authorized by what both groups share; before, deleting `G2` made the user keep everything `G1` grants (the `AND` limit silently disappeared); now the user has **nothing** until the reference is removed or `G2` is restored.
133
+ 2. **A reference whose name matches several rows** (duplicate group or permission names stored before duplicates were rejected, see F-02 above) used to use the first row in the table, so the result depended on the database row order. It now uses only what all the matching rows grant in common (the intersection), whatever the order.
134
+ 3. **An entry of `cgrups` / `cpemis` whose `mode` is missing or is not exactly `"OR"` or `"AND"`** (for example `"or"`, `"XOR"`, a number, or the short form `{"G1": "y"}`) used to be ignored. It is now a **restriction entry**: it still takes part, but only as an `AND`-style limit. It is intersected with the other entries of the same level and **never grants anything on its own** (it is never the base of the intersection). When the level also has an entry with a valid `mode`, a restriction entry behaves exactly like an `AND` entry (for example, a user who has `G1` as `OR` and `G2` with a missing `mode` is authorized by what `G1` and `G2` grant in common; before, `G2` was ignored and the user kept everything `G1` grants). When the level has **no** entry with a valid `mode` (every active entry has a missing or invalid `mode`, or the level has no active entry at all), the level grants **nothing**, the same as before (for example a `cgrups` that holds only `{"G1": {"isActive": "y"}}` or only the short form `{"G1": "y"}`).
135
+
136
+ Compared with the previous release these rules **only take authorization away, with no exception** (the audit below lists any user who would be granted something new under `多給`; it is expected to be empty). One detail differs from the literal wording of the rule "treat it as `AND`": if the only active entry of a level (the groups of a user, or the permissions of a group) has a missing or invalid `mode`, reading it literally as `AND` would make it the base of the intersection and grant its content where the previous release granted nothing. The stricter reading was chosen instead (restriction entry, above), by the maintainer's own decision under the rule "when a datum can be read in several ways, take the intersection of the readings"; this one case is **pending confirmation by the project owner** and is tracked in `spec/設計要點與取捨.md` (ADR-035, decision e and Consequences). Because the edit dialogs show such an entry as an enabled `AND` entry, saving a level whose active entries **all** have a missing or invalid `mode` would turn the entry into the sole base and grant more than before; the dialogs write such an entry back with its original `mode` when the administrator did not change it, so that case can be saved without granting more (before 2026-10-22 it was refused; the warning icon stays). The same warning icons also mark: a group listed twice with both `OR` and `AND` (the editors refuse to save it), a `crules` array that lists one target twice with both checked and unchecked (it is treated as unchecked, see the item on `AND` ordering above), an empty name, and an entry without a usable name.
137
+ - **Audit your data before upgrading (nothing is changed by these commands)**: export `{ users, grups, pemis, targets }` as JSON (each an array of rows, for example the result of `select` on the four tables) and run `node test/tools/authMergeReport.mjs --audit <export.json>` (add `--json` for a machine-readable result). For every user it lists the targets taken away compared with the previous release (`被收回`), the references that cause the new rules to take something away (`觸發`: a reference that does not exist `懸空`, several rows with the same name `同名多列`, a missing or invalid mode `mode 非法`), the users who would be granted something they did not have (`多給`, expected to be empty; a non-empty result means the rules or the data need a report), the groups / permissions with duplicate names, and the groups / permissions with an empty name. Then run `node server/tools/nameDupReport.mjs <export.json> [--mapping-by id|email|name]` (read-only) to list every same-name group or permission together with the users or groups that refer to it, and the users whose mapping key is empty or repeated. If you run with `mappingBy: 'email'` or `'name'`, check the mapping field first (an active user with an empty or repeated key cannot sign in, and a later push from the owning application is rejected). Nothing is deleted or renamed automatically; clean the data in the admin pages, then check each user's authorization (`getPermUserInfor` / `getPerm`).
138
+ - **The edit dialogs no longer drop what they cannot show (behavior change, 2026-10-21)**: saving a dialog used to rewrite the stored JSON from the rows it displayed, so an active reference to a group or permission that no longer exists (which is now an `AND` limit that leaves the result empty) was silently removed and the limit with it. Now: (1) the user-groups and group-permissions dialogs list such a reference as an extra row after the normal rows (the name cannot be edited; the mode and "use" check box can, so an administrator can disable the reference to resolve it) and write it back on save; a disabled reference of that kind is not shown and is not kept. (2) An entry with a missing or invalid `mode` (including the short form `{"G1": "y"}`) is shown as an enabled `AND` entry; **if the administrator does not change that row it is written back with its original `mode` (missing stays missing)**, so saving never turns a restriction entry into a real `AND` base and what the user is authorized for does not change (even when the administrator disables the only entry with a valid `mode` in the same edit). Changing the row's mode or "use" check box writes what the dialog shows. When **every** active entry of the level has a missing or invalid `mode`, the dialog can still be saved (since 2026-10-22; it was refused before): unchanged entries are written back as they were, so nothing more is granted, and the warning icon stays. Limitation: re-selecting `AND` on such a row is treated as unchanged (it is already shown as `AND`), so an administrator cannot turn it into a real `AND` entry that way; choose `OR`, or fix the data at its source. (3) An entry whose name is the empty string is kept as it is. (4) An active entry without a usable name (for example an array element without `name`) cannot be written in the object form, so saving is refused with the fixed message of the earlier item "Data that the object form cannot hold is not rewritten" (now also covering this case), and a line at the top of the dialog points at it. (5) When several rows of the group or permission table share one name (historical duplicates), the dialog lists each row, switches all rows of that name together (they are one reference), and refuses to save if they are nevertheless not set identically; saving writes the entry once. The "belongs to" dialog of a permission locates group rows by id (not by name). (6) The "belongs to" dialogs (group page "users", permission page "groups") change only the one entry that belongs to the group or permission being edited and leave every other entry of the user / group, including the references above, exactly as they were.
139
+ - **Historical duplicates no longer block saving; users are checked only where the save creates the problem (behavior change, 2026-10-21)**: the group / permission lists used to mark every duplicate name and refuse to send the save, although the server already accepted duplicates that were already in the database (see F-02 above). They now use the server's rule: a duplicate that the current save creates or touches (a new row, a rename onto an existing name, a third row with the same name) is refused as before, while a duplicate that was already stored is only marked with the warning icon and does not stop the save, so it can be repaired. For users, the same rule applies to `email` and `name` in the list page, and the server no longer checks `email` on the **whole** table: it checks only new rows and rows whose `email` changed (non-empty and unique), so historical empty or repeated emails no longer make every save fail (also in `modeEditUsers: 'for:grups'`). With `mappingBy: 'name'` or `'email'` the server additionally checks that field of the **active** users, again only where the save creates or touches the problem (a new row, a changed value, or an inactive user turned active: non-empty after trimming, and unique among active users); with the default `mappingBy: 'id'`, `name` is still not checked. A refused save returns the usual `saveRowFieldInvalid` / `saveRowFieldDuplicate`, changes nothing, and the log records the reason but never the email or name.
140
+ - **Deleting a group or permission that others refer to with `AND` asks first (2026-10-21)**: in the group list, deleting a group that at least one user lists with `AND` (an entry whose mode is missing or invalid is a restriction entry and is counted too; the count is an upper bound) opens a confirmation box that says how many users lose their authorization; "No" keeps the group. The permission list does the same for groups that refer to the deleted permission with `AND`. Deleting one of several same-named rows (historical duplicates) can also widen what the referring users or groups are authorized for, because the result for such a name is the intersection of its rows; the same box then says how many referrers may gain authorization. Deleting a group or permission that nobody refers to with `AND`, and that is not one of several same-named rows referred to by anybody, behaves as before and shows no box. *Superseded on 2026-10-22: the box text and the behavior of a deletion changed, and a rename now updates the references; see the next two items.*
141
+ - **Renaming a group or permission updates the references to it (behavior change, 2026-10-22, F-07 phase 1)**: when `updateGrups` / `updatePemis` (the admin pages) or `/syncAndReplaceTabs` renames a group or permission, the `cgrups` of the users (or the `cpemis` of the groups) that list the old name are rewritten to the new name in the same write, so authorization is unchanged (before, the references silently became dangling: an `AND` reference that stopped matching could even widen a user's authorization). A rename is recognised by the row id (same id, different name); swaps (`A`↔`B`) and chains (`A`→`B`, `B`→`C`) are applied simultaneously. The save is refused as a whole, changing nothing (fixed message keys `renameCascadeDupGroup`, `renameCascadeNameInUse`, `renameCascadeConflict`, `renameCascadeUnsupported`; `/syncAndReplaceTabs` answers `invalidRows`, the reason is in the log) when: the renamed row shares its old name with other rows (historical duplicates) and that name is still referenced; the new name is still referenced by active entries but no group / permission carries it; one stored string contains both the old and the new name; or a string that would have to be rewritten contains a value that cannot be re-encoded safely (a JSON5 `Infinity` / `NaN` number, which `JSON.stringify` would turn into `null`). **Set `opt.cascadeRename: false` to keep the old behavior** (references are not updated and become dangling, which stays strict and flagged as above). The admin pages show a confirmation box before saving a rename with the number of users (groups) whose references will be updated; users whose `from` is not empty belong to an external application and their `cgrups` may be overwritten again by that application's next synchronization **[since 2026-10-22 only when that push carries a `cgrups` key; a push without the key keeps the stored value, see the 2026-10-22 section above]**. Rewritten strings are re-encoded as JSON (comments and quoting style are not kept; strings that need no change are untouched); the object form stays an object and the array form stays an array. The rewrite is written by the acting admin (`userIdUpdate` / `timeUpdate` of the rewritten rows).
142
+ - **Deleting a group, permission or target removes the references to it (behavior change, 2026-10-22)**: deleting a group removes it from the `cgrups` of every user, deleting a permission removes it from the `cpemis` of every group, and deleting a target (or changing its id, which is a delete plus an add) removes it from the `crules` of every permission, in the same write (admin pages and `/syncAndReplaceTabs`). Before, the references were kept and ignored ("not found, ignored"), and a group re-created with the same name silently took them over; now a re-created name starts with no references. **A reference to a deleted `AND` group is removed together with its restriction, so the referring users may gain authorization**; the confirmation box before the delete (group list, permission list) and before saving a target change says how many users / groups / permissions lose the reference and how many of them referred to it with `AND`. Deleting one of several same-named rows (historical duplicates) keeps the references (the name still resolves) and still asks as described above. "Delete only if nobody refers to it" is not implemented on purpose (it could involve many accounts, each to be fixed by hand).
143
+ - **Rows written without an `id` are matched by name (`updateGrups`, `updatePemis`, `/syncAndReplaceTabs`)**: a row with no `id` gets a new random id on every write (the old row is deleted and a new one added), so, for the purpose of references only, it is matched to an existing row of the same name (within the same source for `/syncAndReplaceTabs`): same name means the same group or permission and its references are kept; a row whose name matches no existing row is a new group or permission (and an existing row that nothing matches is a deletion, so references to it are removed).
144
+ - **Within one source (`from`), `/syncAndReplaceTabs` identifies groups and permissions by id**: a pushed row with the same id and a different name is a rename (references follow); an id that is no longer pushed is a deletion (references are removed). Rows pushed without an `id` (the server then generates a random id on every push) are matched to the source's existing rows by name, so a push that keeps the same names removes nothing; but a rename without ids cannot be recognised and counts as delete plus add. **If your application renumbers its ids on every push, references to the renumbered groups / permissions will be removed on each push, so keep ids stable** (or push without ids).
145
+ - **Cleaning up references that are already dangling (one-off, run it once when upgrading)**: `node g_authRefsClean.mjs` lists them (nothing is changed); `node g_authRefsClean.mjs --apply` backs up the database and the three tables that hold references (`--backup <file>`), then removes the dangling entries, with the same effect as the old "not found, ignored" (an `AND` reference to a missing group no longer empties the result). The offline version for an export file is `node server/tools/authRefsAudit.mjs <export.json> [--json] [--clean-out <file>]`. Dangling references that remain stay strict (counted with their `mode`, granting nothing) and flagged with the warning icon.
146
+ - **Locks**: saving groups, permissions or targets that renames or deletes something now also waits for the write lock of the table that refers to them (`users`, `grups`, `pemis`), always taken in the order targets, permissions, groups, users (one function, `withTabLocks`); saves that rename or delete nothing take only their own table's lock, as before.
147
+
148
+ ### Upgrading to w-serv-orm 1.1.11 / w-syslog 1.0.33 (2026-10-14)
149
+ - **Numeric ids are stored as strings**: a numeric row id sent to `/syncAndReplaceTabs` (or to `updatePemis`, `updateGrups`, `updateUsers`) is stored as its string form (`123` is stored as `'123'`) and the success response lists it as a string. Before, the number was replaced by a generated id while the response still showed the number. A number and its string form are the same id; `NaN` and `Infinity` count as no id (a new id is generated). `updateTargets` still accepts string ids only and rejects other ids with `saveRowFieldInvalid`.
150
+ - **Ids owned by another source are rejected**: if an id pushed to `/syncAndReplaceTabs` already belongs to another `from`, the whole push is rejected with `invalidRows` before anything is deleted (before, the row was skipped while the push still returned success). External applications must use ids that do not overlap with other sources.
151
+ - **Logging**: with w-syslog 1.0.33, a log write failure no longer terminates the process (it is reported on stderr), and `WWebPerm` throws at construction when `opt.logFd` cannot be created.
152
+ - **Bundled usage**: the statistics worker file is located on first use from the installed package (`server/staLogs/staLogsCore.shellWorker.mjs`), so `dist/w-web-perm.umd.js` and downstream bundles can run the statistics; the package must be installed with its `server` folder.
153
+
154
+ ### Upgrading to w-serv-orm 1.1.10 / w-serv-hapi 1.1.6 (2026-10-14)
155
+ - **Removed options**: `opt.useCheckUser`, `opt.getUserById` and `opt.useExcludeWhenNotAdmin` were removed together with w-serv-orm 1.1.9. Passing any value other than `undefined`, `null` or `false` now throws when constructing `WWebPerm` (the message comes from w-serv-orm `checkOptRemoved`) and is logged to srLog. Delete these keys; users of the data channel are authorized by `verifyConn` (`verifyClientUser`), no replacement option is needed.
156
+ - **Data channel is select-only (behavior change)**: only `<table>.select` of `targets`, `pemis`, `grups` and `users` is exposed (`methodsExec: ['select']`, and `authorizeExec` allows select only). Calling raw `insert`, `save`, `del` or `delAll` now returns `invalid func[<table>.<method>]`. Clients that wrote tables directly must call `updateTargets`, `updatePemis`, `updateGrups` or `updateUsers`, which apply the save lock, serialization, validation and whole-table diff.
157
+ - **Write validation**: rows to be written are validated before anything is deleted or written. A string value that XSS cleaning would rewrite (for example `<` followed by a letter) is rejected with `htmlMarkupNotAllowed`; an id longer than 1978 UTF-8 bytes is rejected with `saveRowFieldInvalid` (admin lists) or `invalidRows` (`syncAndReplaceTabs`). Unexpected database write failures return `saveWriteFail`; the ORM messages are written to srLog only.
158
+ - **Process-level errors**: w-serv-hapi 1.1.6 no longer registers `unhandledRejection` or `uncaughtException` handlers, and `WWebPerm` does not register them either; the deployment decides its own process policy.
@@ -0,0 +1 @@
1
+ .staleReloadBtn[data-v-f438a24c]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-f438a24c]:hover{background:#f2f2f2}.staleReloadBtn[data-v-7293db50]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-7293db50]:hover{background:#f2f2f2}.staleReloadBtn[data-v-589a7f30]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-589a7f30]:hover{background:#f2f2f2}.staleReloadBtn[data-v-2b7c269c]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-2b7c269c]:hover{background:#f2f2f2}.bg-gray-100[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-white[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.border[data-v-f9ffef80]{border-width:1px}.border-collapse[data-v-f9ffef80]{border-collapse:collapse}.border-solid[data-v-f9ffef80]{border-style:solid}.border-gray-200[data-v-f9ffef80]{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300[data-v-f9ffef80]{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.flex[data-v-f9ffef80]{display:-webkit-box;display:-ms-flexbox;display:flex}.focus\:ring-blue-500[data-v-f9ffef80]:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.font-medium[data-v-f9ffef80]{font-weight:500}.font-semibold[data-v-f9ffef80]{font-weight:600}.h-4[data-v-f9ffef80]{height:1rem}.hover\:bg-gray-50[data-v-f9ffef80]:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.items-center[data-v-f9ffef80]{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.justify-end[data-v-f9ffef80]{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.mb-8[data-v-f9ffef80]{margin-bottom:2rem}.ml-2[data-v-f9ffef80]{margin-left:.5rem}.mr-2[data-v-f9ffef80]{margin-right:.5rem}.mt-2[data-v-f9ffef80]{margin-top:.5rem}.overflow-x-auto[data-v-f9ffef80]{overflow-x:auto}.p-5[data-v-f9ffef80]{padding:1.25rem}.pb-2[data-v-f9ffef80]{padding-bottom:.5rem}.px-2[data-v-f9ffef80]{padding-left:.5rem;padding-right:.5rem}.px-6[data-v-f9ffef80]{padding-left:1.5rem;padding-right:1.5rem}.py-1[data-v-f9ffef80]{padding-top:.25rem;padding-bottom:.25rem}.py-3[data-v-f9ffef80]{padding-top:.75rem;padding-bottom:.75rem}.py-4[data-v-f9ffef80]{padding-top:1rem;padding-bottom:1rem}.relative[data-v-f9ffef80]{position:relative}.rounded[data-v-f9ffef80]{border-radius:.25rem}.rounded-lg[data-v-f9ffef80]{border-radius:.5rem}.shadow-md[data-v-f9ffef80]{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);-webkit-box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.space-x-4[data-v-f9ffef80]>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-8[data-v-f9ffef80]>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.text-blue-600[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-gray-500[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-700[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-900[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-left[data-v-f9ffef80]{text-align:left}.text-lg[data-v-f9ffef80]{font-size:1.125rem;line-height:1.75rem}.text-purple-600[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-sm[data-v-f9ffef80]{font-size:.875rem;line-height:1.25rem}.text-xs[data-v-f9ffef80]{font-size:.75rem;line-height:1rem}.transition-colors[data-v-f9ffef80]{-webkit-transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1);-webkit-transition-duration:.15s;transition-duration:.15s}.w-4[data-v-f9ffef80]{width:1rem}.w-full[data-v-f9ffef80]{width:100%}.staCtrl[data-v-f9ffef80]{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;gap:12px;padding:8px 0}.staCtrlRight[data-v-f9ffef80],.staCtrl[data-v-f9ffef80]{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.staCtrlRight[data-v-f9ffef80]{-webkit-box-align:center;-ms-flex-align:center;align-items:center;gap:16px}body,html{font-family:微軟正黑體,Microsoft JhengHei,MicrosoftJhengHeiRegular,Avenir,Helvetica,Arial,sans-serif;overflow-y:hidden}a,button,div,input,p,pre,span,textarea{font-family:inherit}.fade-enter-active{-webkit-animation:go 1s;animation:go 1s}.fade-leave-active{-webkit-animation:back 1s;animation:back 1s}@-webkit-keyframes go{0%{opacity:0}to{opacity:1}}@keyframes go{0%{opacity:0}to{opacity:1}}@-webkit-keyframes back{0%{opacity:1}to{opacity:0}}@keyframes back{0%{opacity:1}to{opacity:0}}
package/dist/index.tmp CHANGED
@@ -1,4 +1,4 @@
1
1
  <!doctype html><html xmlns="http://www.w3.org/1999/xhtml" lang="zh-tw"><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,minimal-ui"><meta http-equiv="X-UA-Compatible" content="ie=edge"><meta name="referrer" content="strict-origin"><title></title><link _href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" rel="stylesheet"><link _href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.2/css/all.min.css" rel="stylesheet"><script>window.___pmwperm___={
2
2
  urlRedirect: '{urlRedirect}',
3
3
  language: '{language}',
4
- }</script><script defer="defer" src="{sfd}/js/chunk-vendors.3e9e3ef1.js" type="module"></script><script defer="defer" src="{sfd}/js/app.963e800c.js" type="module"></script><link href="{sfd}/css/chunk-vendors.3c580e72.css" rel="stylesheet"><link href="{sfd}/css/app.6918ec45.css" rel="stylesheet"><script defer="defer" src="{sfd}/js/chunk-vendors-legacy.3e9e3ef1.js" nomodule></script><script defer="defer" src="{sfd}/js/app-legacy.963e800c.js" nomodule></script></head><body style="font-family:'Microsoft JhengHei','Avenir','Helvetica', 'Arial', 'sans-serif'; padding:0px; margin:0px;"><noscript><strong>We're sorry but w-component-vue doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
4
+ }</script><script defer="defer" src="{sfd}/js/chunk-vendors.ece5a361.js" type="module"></script><script defer="defer" src="{sfd}/js/app.8af87052.js" type="module"></script><link href="{sfd}/css/chunk-vendors.3c580e72.css" rel="stylesheet"><link href="{sfd}/css/app.f09c7433.css" rel="stylesheet"><script defer="defer" src="{sfd}/js/chunk-vendors-legacy.ece5a361.js" nomodule></script><script defer="defer" src="{sfd}/js/app-legacy.8af87052.js" nomodule></script></head><body style="font-family:'Microsoft JhengHei','Avenir','Helvetica', 'Arial', 'sans-serif'; padding:0px; margin:0px;"><noscript><strong>We're sorry but w-component-vue doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>