iobroker.eos-admin 7.9.96 → 7.9.98

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 (47) hide show
  1. package/INSTALL_TEST_V7.9.98.md +39 -0
  2. package/MERGE_UPDATE_README_V7.9.98.md +29 -0
  3. package/NEXOWATT_EOS_BUILD_INFO.json +20 -18
  4. package/PUBLISH_STABLE_V7.9.98.md +33 -0
  5. package/README.md +16 -6
  6. package/README_STABILITY_V7.9.98.md +22 -0
  7. package/RELEASE_ACCEPTANCE_V7.9.98.md +48 -0
  8. package/RELEASE_NOTES_V7.9.98.md +32 -0
  9. package/adminWww/assets/Intro-DkwRiz1n-v84.js +1 -1
  10. package/adminWww/assets/bootstrap-COulQZax-v84.js +3 -3
  11. package/adminWww/assets/index-D2ymscJA-v84.js +1 -1
  12. package/adminWww/css/eos-ems-overview.css +21 -0
  13. package/adminWww/css/nexowatt-native-shell.css +174 -40
  14. package/adminWww/index.html +15 -15
  15. package/adminWww/js/eos-account-management.js +26 -13
  16. package/adminWww/js/eos-assistant.js +1 -1
  17. package/adminWww/js/eos-ems-overview.js +136 -39
  18. package/adminWww/js/eos-role-bootstrap.js +7 -7
  19. package/adminWww/js/eos-role-ui.js +2 -2
  20. package/adminWww/js/nexowatt-native-shell.js +9 -2
  21. package/adminWww/static/js/nexowatt-stable-login.js +28 -62
  22. package/adminWww/static/js/nexowatt-stable-v98.js +33 -0
  23. package/build/lib/web.js +112 -79
  24. package/build/main.js +1 -1
  25. package/io-package.json +15 -5
  26. package/package.json +14 -12
  27. package/tools/nexowatt-account-management-selftest.cjs +16 -57
  28. package/tools/nexowatt-account-password-flow-selftest.cjs +186 -0
  29. package/tools/nexowatt-clean-legacy-runtime.cjs +33 -10
  30. package/tools/nexowatt-login-layout-selftest.cjs +12 -81
  31. package/tools/nexowatt-merge-update-selftest.cjs +3 -1
  32. package/tools/nexowatt-modern-ui-selftest.cjs +2 -2
  33. package/tools/nexowatt-password-write-selftest.cjs +48 -0
  34. package/tools/nexowatt-runtime-cleanup-selftest.cjs +9 -1
  35. package/tools/nexowatt-stable-v98-selftest.cjs +14 -0
  36. package/tools/nexowatt-sync-release-version.cjs +42 -1
  37. package/tools/nexowatt-ui-overview-runtime-selftest.cjs +11 -11
  38. package/tools/nexowatt-validate-package.cjs +20 -11
  39. package/tools/nexowatt-version-sync-selftest.cjs +18 -1
  40. package/INSTALL_TEST_V7.9.96.md +0 -11
  41. package/MERGE_UPDATE_README_V7.9.96.md +0 -5
  42. package/PUBLISH_STABLE_V7.9.96.md +0 -10
  43. package/README_STABILITY_V7.9.96.md +0 -11
  44. package/RELEASE_ACCEPTANCE_V7.9.96.md +0 -10
  45. package/RELEASE_NOTES_V7.9.96.md +0 -12
  46. package/adminWww/static/js/nexowatt-stable-v96.js +0 -82
  47. package/tools/nexowatt-stable-v96-selftest.cjs +0 -13
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const vm = require('node:vm');
8
+
9
+ const root = path.resolve(__dirname, '..');
10
+ const built = fs.readFileSync(path.join(root, 'build/lib/web.js'), 'utf8');
11
+ const accountUi = fs.readFileSync(path.join(root, 'adminWww/js/eos-account-management.js'), 'utf8');
12
+ const firstLoginUi = fs.readFileSync(path.join(root, 'adminWww/js/eos-role-bootstrap.js'), 'utf8');
13
+
14
+ function methodSlice(startMarker, endMarker) {
15
+ const start = built.indexOf(startMarker);
16
+ const end = built.indexOf(endMarker, start + startMarker.length);
17
+ assert(start >= 0 && end > start, `method block not found: ${startMarker}`);
18
+ return built.slice(start, end);
19
+ }
20
+
21
+ const methods = [
22
+ methodSlice(' normalizeEosUserId(value) {', ' normalizeEosAdapterName(value) {'),
23
+ methodSlice(' async readEosCurrentUser(req) {', ' getEosConfiguredRoleGroups(...keys) {'),
24
+ methodSlice(' async getEosRequestAccess(req) {', ' getEosRoleCapabilities(role) {'),
25
+ methodSlice(' validateEosFirstLoginPassword(password, passwordRepeat, userId) {', ' getEosPasswordUserName(userId) {'),
26
+ methodSlice(' getEosPasswordUserName(userId) {', ' async destroyEosRequestSessions(req) {'),
27
+ methodSlice(' async saveEosFirstLoginPassword(req, res) {', ' isEosPasswordlessFirstLoginEnabled() {'),
28
+ methodSlice(' async resetEosAccountPassword(req, res) {', ' async getEosHistoryInstances() {'),
29
+ methodSlice(' isEosSameOriginWrite(req) {', ' async saveEosBasicSettings(req, res) {'),
30
+ ].join('\n');
31
+
32
+ const sandbox = { module: { exports: null }, URL, Set, console, EOS_PASSWORD_SERVICE_USER: 'system.user.admin' };
33
+ vm.runInNewContext(`module.exports = class Harness {\n${methods}\n}`, sandbox);
34
+ const Harness = sandbox.module.exports;
35
+
36
+ function responseMock() {
37
+ return {
38
+ statusCode: 200,
39
+ payload: null,
40
+ headers: {},
41
+ clearedCookies: [],
42
+ setHeader(name, value) { this.headers[String(name).toLowerCase()] = value; },
43
+ status(code) { this.statusCode = code; return this; },
44
+ json(value) { this.payload = value; return this; },
45
+ clearCookie(name) { this.clearedCookies.push(name); },
46
+ };
47
+ }
48
+
49
+ function requestMock(body = {}, headers = {}) {
50
+ return {
51
+ body,
52
+ headers: {
53
+ host: '192.168.10.124:8081',
54
+ origin: 'http://192.168.10.124:8081',
55
+ 'sec-fetch-site': 'same-origin',
56
+ ...headers,
57
+ },
58
+ query: {},
59
+ socket: { remoteAddress: '192.168.10.50' },
60
+ };
61
+ }
62
+
63
+ async function resetScenario(requesterRole, targetRole, targetGroups) {
64
+ const harness = new Harness();
65
+ const writes = [];
66
+ const metadata = {};
67
+ harness.getEosRequestAccess = async () => ({ userId: requesterRole === 'admin' ? 'system.user.admin' : 'system.user.installer', role: requesterRole });
68
+ harness.normalizeEosUserId = value => String(value || '').startsWith('system.user.') ? String(value) : `system.user.${value}`;
69
+ harness.getEosAccessForUserId = async () => ({ role: targetRole, groups: targetGroups });
70
+ harness.getEosInstallerGroups = () => ['system.group.installer', 'system.group.installateur'];
71
+ harness.getEosEndUserGroups = () => ['system.group.endkunde', 'system.group.user'];
72
+ harness.adapter = {
73
+ setPasswordAsync: async (user, password) => writes.push([`system.user.${user}`, password]),
74
+ checkPasswordAsync: async (user, password) => [password === 'nexowatt', `system.user.${user}`],
75
+ getForeignObjectAsync: async id => ({ _id: id, common: { enabled: true, password: '$hash' }, native: { keep: true, nexowattEosAccount: {} } }),
76
+ extendForeignObjectAsync: async (id, patch) => {
77
+ metadata.id = id;
78
+ metadata.native = patch.native;
79
+ },
80
+ log: { warn: () => undefined, info: () => undefined },
81
+ };
82
+ harness.invalidateEosPasswordClaimsForUser = () => undefined;
83
+ const res = responseMock();
84
+ await harness.resetEosAccountPassword(
85
+ requestMock({ user: targetRole === 'installer' ? 'system.user.installer-target' : 'system.user.user' }, { 'x-nexowatt-eos-account-reset': '1' }),
86
+ res,
87
+ );
88
+ return { res, writes, metadata };
89
+ }
90
+
91
+ (async () => {
92
+ // Authenticated request identities are normalized to the real ioBroker user object id.
93
+ const accessHarness = new Harness();
94
+ accessHarness.getEosAccessForUserId = async userId => ({ userId, role: userId === 'system.user.admin' ? 'admin' : 'enduser' });
95
+ const directAdmin = await accessHarness.getEosRequestAccess({ user: 'admin' });
96
+ assert.equal(directAdmin.userId, 'system.user.admin');
97
+ assert.equal(directAdmin.role, 'admin');
98
+ const objectAdmin = await accessHarness.getEosRequestAccess({ user: { id: 'system.user.admin' } });
99
+ assert.equal(objectAdmin.userId, 'system.user.admin');
100
+ assert.equal(objectAdmin.role, 'admin');
101
+
102
+ // Admin/Service can reset an explicitly managed End User and Installer account.
103
+ const adminEndUser = await resetScenario('admin', 'enduser', ['system.group.endkunde']);
104
+ assert.equal(adminEndUser.res.statusCode, 200);
105
+ assert.deepEqual(adminEndUser.writes, [['system.user.user', 'nexowatt']]);
106
+ assert.equal(adminEndUser.metadata.native.nexowattEosAccount.forcePasswordChange, true);
107
+ assert.equal(adminEndUser.metadata.native.nexowattEosAccount.passwordResetMode, 'initial-password');
108
+ assert.equal(adminEndUser.metadata.native.nexowattPasswordChangeRequired, true);
109
+
110
+ const adminInstaller = await resetScenario('admin', 'installer', ['system.group.installer']);
111
+ assert.equal(adminInstaller.res.statusCode, 200);
112
+ assert.deepEqual(adminInstaller.writes, [['system.user.installer-target', 'nexowatt']]);
113
+
114
+ // Installer may never reset another installer account.
115
+ const installerDenied = await resetScenario('installer', 'installer', ['system.group.installer']);
116
+ assert.equal(installerDenied.res.statusCode, 403);
117
+ assert.equal(installerDenied.res.payload.error, 'permissionError');
118
+ assert.equal(installerDenied.writes.length, 0);
119
+
120
+ // First-login password save must write the real account and clear every force-change flag.
121
+ const harness = new Harness();
122
+ const passwordWrites = [];
123
+ let metadata;
124
+ let sessionsDestroyed = false;
125
+ harness.getEosRequestAccess = async () => ({ userId: 'system.user.user', role: 'enduser' });
126
+ harness.getEosFirstLoginPasswordState = async () => ({ required: true, initialized: false, minLength: 10, version: 1 });
127
+ harness.getEosFirstLoginPasswordMinLength = () => 10;
128
+ harness.destroyEosRequestSessions = async () => { sessionsDestroyed = true; };
129
+ harness.adapter = {
130
+ setPasswordAsync: async (user, password) => passwordWrites.push([`system.user.${user}`, password]),
131
+ checkPasswordAsync: async (user, password) => [password === 'NexoWatt2025!', `system.user.${user}`],
132
+ getForeignObjectAsync: async id => ({ _id: id, common: { enabled: true, password: '$hash' }, native: { nexowattEosAccount: {} } }),
133
+ extendForeignObjectAsync: async (id, patch) => { metadata = { id, native: patch.native }; },
134
+ log: { info: () => undefined },
135
+ };
136
+ const acceptedPassword = harness.validateEosFirstLoginPassword(
137
+ 'NexoWatt2025!',
138
+ 'NexoWatt2025!',
139
+ 'system.user.user',
140
+ );
141
+ assert.equal(acceptedPassword.valid, true);
142
+ assert.equal(acceptedPassword.password, 'NexoWatt2025!');
143
+ const saveRes = responseMock();
144
+ await harness.saveEosFirstLoginPassword(
145
+ requestMock(
146
+ { password: 'NexoWatt2025!', passwordRepeat: 'NexoWatt2025!' },
147
+ { 'x-nexowatt-eos-first-login': '1' },
148
+ ),
149
+ saveRes,
150
+ );
151
+ assert.equal(saveRes.statusCode, 200);
152
+ assert.equal(saveRes.payload.success, true);
153
+ assert.equal(saveRes.payload.logoutRequired, true);
154
+ assert.deepEqual(passwordWrites, [['system.user.user', 'NexoWatt2025!']]);
155
+ assert.equal(metadata.native.nexowattEosAccount.passwordInitialized, true);
156
+ assert.equal(metadata.native.nexowattEosAccount.forcePasswordChange, false);
157
+ assert.equal(metadata.native.nexowattPasswordChangeRequired, false);
158
+ assert.equal(metadata.native.eosFirstLoginRequired, false);
159
+ assert.equal(sessionsDestroyed, true);
160
+ assert.deepEqual(saveRes.clearedCookies.sort(), ['access_token', 'connect.sid', 'refresh_token'].sort());
161
+
162
+ // Same-origin logic must support direct access and a reverse proxy without accepting cross-site writes.
163
+ const originHarness = new Harness();
164
+ assert.equal(originHarness.isEosSameOriginWrite(requestMock()), true);
165
+ assert.equal(originHarness.isEosSameOriginWrite(requestMock({}, {
166
+ host: '127.0.0.1:8081',
167
+ origin: 'https://eos.example.test',
168
+ 'x-forwarded-host': 'eos.example.test',
169
+ 'sec-fetch-site': 'same-origin',
170
+ })), true);
171
+ assert.equal(originHarness.isEosSameOriginWrite(requestMock({}, {
172
+ origin: 'https://attacker.example',
173
+ 'sec-fetch-site': 'cross-site',
174
+ })), false);
175
+
176
+ // Browser writes must preserve the authenticated session cookie.
177
+ assert.match(accountUi, /nexowatt\/account\/reset/);
178
+ assert.match(accountUi, /method:\s*'POST',\s*credentials:\s*'include'/);
179
+ assert.match(firstLoginUi, /nexowatt\/account\/first-password/);
180
+ assert.match(firstLoginUi, /method:\s*'POST',\s*credentials:\s*'include'/);
181
+
182
+ console.log('[NexoWatt EOS account password flow] OK: Admin/Installer permissions, initial password reset, personal password save, session and origin checks verified.');
183
+ })().catch(error => {
184
+ console.error(error && error.stack ? error.stack : error);
185
+ process.exit(1);
186
+ });
@@ -36,6 +36,24 @@ function runtimeNumber(root) {
36
36
  return value;
37
37
  }
38
38
 
39
+ function shellNumber(root) {
40
+ const info = readJson(path.join(root, 'NEXOWATT_EOS_BUILD_INFO.json'));
41
+ const candidates = [info.shellCacheVersion, info.shellCacheTag, info.label];
42
+ for (const candidate of candidates) {
43
+ const value = Number(String(candidate ?? '').replace(/^v/i, ''));
44
+ if (Number.isFinite(value) && value > 0) return value;
45
+ }
46
+ try {
47
+ const pkg = readJson(path.join(root, 'package.json'));
48
+ const patch = Number(String(pkg.version || '').split('.')[2]);
49
+ if (Number.isFinite(patch) && patch > 0) return patch;
50
+ } catch (_) {
51
+ // The build info remains the canonical source. The package fallback is
52
+ // only used for isolated cleanup fixtures.
53
+ }
54
+ throw new Error('Invalid shellCacheVersion in NEXOWATT_EOS_BUILD_INFO.json');
55
+ }
56
+
39
57
  function walkFiles(dir) {
40
58
  if (!fs.existsSync(dir)) return [];
41
59
  const result = [];
@@ -51,12 +69,14 @@ function walkFiles(dir) {
51
69
  return result;
52
70
  }
53
71
 
54
- function isLegacyVersionedRuntime(file, activeRuntime) {
72
+ function isLegacyVersionedRuntime(file, activeRuntime, activeShell) {
55
73
  const name = path.basename(file);
56
- // Stable product overlays use the release number in their filename, not the
57
- // compiled React runtime number. They are release assets and must not be
58
- // deleted merely because runtimeEntry remains on the validated v84 bundle.
59
- if (/^nexowatt-stable-v\d+\.js$/i.test(name)) return false;
74
+ // Stable product overlays use the product release number in their filename,
75
+ // while the compiled React runtime can intentionally remain on v84. Keep
76
+ // only the active stable overlay and remove stale release assets left behind
77
+ // by a flat ZIP update (for example v96 when installing v97).
78
+ const stableMatch = name.match(/^nexowatt-stable-v(\d+)\.js$/i);
79
+ if (stableMatch) return Number(stableMatch[1]) !== activeShell;
60
80
  const versionMatch = name.match(/-v(\d+)\.(?:js|js\.map|css|css\.map)$/i);
61
81
  if (versionMatch) return Number(versionMatch[1]) !== activeRuntime;
62
82
  const remoteMatch = name.match(/^remoteEntry-v(\d+)\.(?:js|js\.map)$/i);
@@ -69,23 +89,25 @@ function isLegacyVersionedRuntime(file, activeRuntime) {
69
89
 
70
90
  function collectLegacyRuntimeFiles(root = DEFAULT_ROOT) {
71
91
  const activeRuntime = runtimeNumber(root);
92
+ const activeShell = shellNumber(root);
72
93
  const scanRoots = [
73
94
  path.join(root, 'adminWww', 'assets'),
74
95
  path.join(root, 'adminWww'),
75
96
  path.join(root, 'src-admin', 'build', 'assets'),
76
97
  path.join(root, 'src-admin', 'build'),
98
+ path.join(root, 'src-admin', 'public'),
77
99
  ];
78
100
  const files = new Set();
79
101
  for (const scanRoot of scanRoots) {
80
102
  for (const file of walkFiles(scanRoot)) {
81
- if (isLegacyVersionedRuntime(file, activeRuntime)) files.add(file);
103
+ if (isLegacyVersionedRuntime(file, activeRuntime, activeShell)) files.add(file);
82
104
  }
83
105
  }
84
106
  for (const rel of LEGACY_OVERLAYS) {
85
107
  const file = path.join(root, rel);
86
108
  if (fs.existsSync(file)) files.add(file);
87
109
  }
88
- return { activeRuntime, files: [...files].sort() };
110
+ return { activeRuntime, activeShell, files: [...files].sort() };
89
111
  }
90
112
 
91
113
  function cleanLegacyRuntime(options = {}) {
@@ -93,7 +115,7 @@ function cleanLegacyRuntime(options = {}) {
93
115
  const dryRun = options.dryRun === true;
94
116
  const quiet = options.quiet === true;
95
117
  const silent = options.silent === true;
96
- const { activeRuntime, files } = collectLegacyRuntimeFiles(root);
118
+ const { activeRuntime, activeShell, files } = collectLegacyRuntimeFiles(root);
97
119
  const removed = [];
98
120
  const failed = [];
99
121
 
@@ -125,13 +147,13 @@ function cleanLegacyRuntime(options = {}) {
125
147
 
126
148
  if (!silent) {
127
149
  if (!quiet) {
128
- console.log(`[NexoWatt EOS runtime cleanup] OK (runtime v${activeRuntime}, removed ${removed.length} legacy file${removed.length === 1 ? '' : 's'})`);
150
+ console.log(`[NexoWatt EOS runtime cleanup] OK (runtime v${activeRuntime}, shell v${activeShell}, removed ${removed.length} legacy file${removed.length === 1 ? '' : 's'})`);
129
151
  } else if (removed.length) {
130
152
  console.log(`[NexoWatt EOS runtime cleanup] removed ${removed.length} legacy runtime files before validation`);
131
153
  }
132
154
  }
133
155
 
134
- return { activeRuntime, removed };
156
+ return { activeRuntime, activeShell, removed };
135
157
  }
136
158
 
137
159
  if (require.main === module) {
@@ -148,4 +170,5 @@ module.exports = {
148
170
  collectLegacyRuntimeFiles,
149
171
  cleanLegacyRuntime,
150
172
  isLegacyVersionedRuntime,
173
+ shellNumber,
151
174
  };
@@ -1,83 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
-
4
- const fs = require('fs');
5
- const path = require('path');
6
-
7
- const root = path.resolve(__dirname, '..');
8
- const read = rel => fs.readFileSync(path.join(root, rel), 'utf8');
9
- let bad = false;
10
- const fail = message => {
11
- console.error(`[NexoWatt EOS login layout] ${message}`);
12
- bad = true;
13
- };
14
-
15
- const css = read('adminWww/css/nexowatt-native-shell.css');
16
- const sourceCss = read('src-admin/public/css/nexowatt-native-shell.css');
17
- const login = read('src-admin/src/login/Login.tsx');
18
- const bootstrap = read('adminWww/js/eos-role-bootstrap.js');
19
- const index = read('adminWww/index.html');
20
- const buildInfo = JSON.parse(read('NEXOWATT_EOS_BUILD_INFO.json'));
21
- const shellTag = String(buildInfo.shellCacheTag || buildInfo.shellCacheVersion || '');
22
-
23
- if (css !== sourceCss) fail('login CSS source/build drift');
24
- if (!css.includes('outer frame is decorative only')) fail('decorative compact-frame contract is missing');
25
- if (css.includes('html.eos-login .MuiPaper-root:has(#username)')) {
26
- fail('broad :has selector still styles the outer login Paper');
27
- }
28
-
29
- const outer = css.match(/html\.eos-login main\.MuiPaper-root\s*\{([\s\S]*?)\n\}/)?.[1] || '';
30
- for (const marker of [
31
- 'position: relative',
32
- 'width: 100%',
33
- 'min-height: 100vh',
34
- 'place-items: center',
35
- 'background: transparent !important',
36
- 'box-shadow: none !important',
37
- ]) {
38
- if (!outer.includes(marker)) fail(`transparent viewport host rule missing: ${marker}`);
39
- }
40
-
41
- const decorative = css.match(/html\.eos-login main\.MuiPaper-root::before\s*\{([\s\S]*?)\n\}/)?.[1] || '';
42
- for (const marker of [
43
- "content: ''",
44
- 'width: min(520px, calc(100vw - 24px)) !important',
45
- 'height: min(640px, calc(100dvh - 24px)) !important',
46
- 'transform: translate(-50%, -50%)',
47
- 'pointer-events: none',
48
- ]) {
49
- if (!decorative.includes(marker)) fail(`decorative rear frame rule missing: ${marker}`);
50
- }
51
-
52
- for (const marker of [
53
- 'html.eos-login main.MuiPaper-root > .eos-login-card-modern',
54
- 'html.eos-login main.MuiPaper-root > .MuiPaper-root:has(#username)',
55
- 'html.eos-login main.MuiPaper-root > .MuiPaper-root:has(#password)',
56
- 'width: min(460px, calc(100vw - 56px)) !important',
57
- 'min-height: 540px !important',
58
- 'height: auto !important',
59
- 'overflow: visible !important',
60
- 'max-height: none !important',
61
- ]) {
62
- if (!css.includes(marker)) fail(`inner compact card rule missing: ${marker}`);
63
- }
64
- if (!css.includes('.eos-login-first-status:empty { display: none; min-height: 0; margin: 0; }')) {
65
- fail('empty first-login status still reserves vertical space');
66
- }
67
-
68
- if (!login.includes('Keep the familiar compact login page')) fail('native Login source lacks spacious-page contract');
69
- if (login.includes('eos-login-role-selector-native') || login.includes('data-eos-account=')) {
70
- fail('native Login source still renders role-selector buttons');
71
- }
72
- if (!bootstrap.includes('compact normal-login first activation')) fail('runtime compact first-login contract missing');
73
- if (!bootstrap.includes("card.querySelectorAll('.eos-login-role-selector').forEach(element => element.remove())")) {
74
- fail('runtime does not remove stale role-selector surfaces');
75
- }
76
- if (/data-eos-account=|selector\.innerHTML/.test(bootstrap)) fail('runtime still injects a second role-selector panel');
77
-
78
- if (!shellTag || !index.includes(`nexowatt-native-shell.css?v=${shellTag}`)) {
79
- fail('login CSS cache key is not aligned with build information');
80
- }
81
-
82
- if (bad) process.exit(1);
83
- console.log('[NexoWatt EOS login layout] OK');
3
+ const fs=require('node:fs');const path=require('node:path');const root=path.resolve(__dirname,'..');
4
+ const read=rel=>fs.readFileSync(path.join(root,rel),'utf8');let bad=false;const fail=msg=>{console.error(`[NexoWatt EOS login layout] ${msg}`);bad=true;};
5
+ const css=read('adminWww/css/nexowatt-native-shell.css');const sourceCss=read('src-admin/public/css/nexowatt-native-shell.css');const login=read('src-admin/src/login/Login.tsx');const stableLogin=read('adminWww/static/js/nexowatt-stable-login.js');const stable=read('adminWww/static/js/nexowatt-stable-v98.js');const index=read('adminWww/index.html');const info=JSON.parse(read('NEXOWATT_EOS_BUILD_INFO.json'));const bootstrap=read('adminWww/assets/bootstrap-COulQZax-v84.js');
6
+ if(css!==sourceCss)fail('login CSS source/build drift');
7
+ for(const marker of ['NexoWatt EOS 7.9.98 – stable scrolling','html.eos-app body','overflow: hidden !important','overflow-y: auto !important','width: min(456px','width: min(400px','min-height: 0 !important','overflow: visible !important','.MuiInputLabel-root { display: none !important; }','.MuiOutlinedInput-input::placeholder','margin-top: 28px !important'])if(!css.includes(marker))fail(`login marker missing: ${marker}`);
8
+ if(!login.includes("minHeight: 0")||!login.includes("maxWidth: 400")||!login.includes('placeholder={`${I18n.t(\'enterLogin\')} *`}')||!login.includes('placeholder={`${I18n.t(\'enterPassword\')} *`}'))fail('native Login source does not use clean v98 placeholders');
9
+ if(!bootstrap.includes('placeholder:')||!bootstrap.includes('I18n.t("enterLogin")+" *"')||!bootstrap.includes('I18n.t("enterPassword")+" *"'))fail('productive login bundle does not use placeholders');
10
+ if(/label:.*I18n\.t\("enterLogin"\)|label:.*I18n\.t\("enterPassword"\)/.test(bootstrap.slice(1335000,1340500)))fail('productive login bundle still uses floating labels');
11
+ if(/data-nw-login-card|data-nw-login-shell|min-height:590px|width:min\(460px|createElement\(['"]style/.test(stableLogin))fail('stable-login runtime mutates geometry');
12
+ if(/width:min\(460px|min-height:540px|createElement\(['"]style/.test(stable))fail('stable v98 runtime injects geometry');
13
+ if(!index.includes(`nexowatt-native-shell.css?v=${info.shellCacheTag}`)||!index.includes('nexowatt-stable-v98.js?v=98'))fail('v98 login assets not active');
14
+ if(bad)process.exit(1);console.log('[NexoWatt EOS login layout] OK');
@@ -16,7 +16,7 @@ for (const file of [
16
16
  'MERGE_UPDATE.ps1',
17
17
  'MERGE_UPDATE.cmd',
18
18
  'MERGE_UPDATE.sh',
19
- 'MERGE_UPDATE_README_V7.9.96.md',
19
+ 'MERGE_UPDATE_README_V7.9.98.md',
20
20
  'tools/nexowatt-apply-ems-overview.cjs',
21
21
  'tools/nexowatt-sync-release-version.cjs',
22
22
  'tools/nexowatt-version-sync-selftest.cjs',
@@ -29,6 +29,7 @@ for (const marker of [
29
29
  '$PSScriptRoot',
30
30
  'nexowatt-apply-ems-overview.cjs',
31
31
  'nexowatt-sync-release-version.cjs',
32
+ 'npm run clean:eos-runtime',
32
33
  'npm run check:eos-package',
33
34
  'npm run check:eos-stability',
34
35
  'npm pack --dry-run',
@@ -47,6 +48,7 @@ for (const marker of [
47
48
  'set -euo pipefail',
48
49
  'nexowatt-apply-ems-overview.cjs',
49
50
  'npm run sync:eos-version',
51
+ 'npm run clean:eos-runtime',
50
52
  'npm run check:eos-package',
51
53
  'npm run check:eos-stability',
52
54
  'npm pack --dry-run',
@@ -19,7 +19,7 @@ for (const marker of [
19
19
  '--nx-shadow-soft', '--nx-shadow-float', 'backdrop-filter: blur', '#app-paper', '.MuiDialog-paper',
20
20
  '.MuiButton-root', '.MuiTableRow-root', ':focus-visible', 'border-radius: 24px', 'linear-gradient',
21
21
  '@media (max-width: 720px)', '.eos-activation-steps', '.eos-account-row:hover',
22
- 'main.MuiPaper-root::before', 'width: min(460px, calc(100vw - 56px)) !important', '.eos-assist-header-root', '.eos-overview-hero', '.eos-overview-grid',
22
+ 'main.MuiPaper-root::before', 'width: min(400px, calc(100vw - 48px)) !important', '.eos-assist-header-root', '.eos-overview-hero', '.eos-overview-grid',
23
23
  '.eos-account-management-entry', 'pointer-events: auto !important', 'touch-action: manipulation', 'overflow: visible',
24
24
  ]) if (!css.includes(marker)) fail(`modern styling marker missing: ${marker}`);
25
25
  if (sourceCss !== css) fail('modern CSS source/build drift');
@@ -29,7 +29,7 @@ for (const marker of ['eos-account-management.js','nexowatt-native-shell.css','e
29
29
  for (const marker of ['NexoWatt EOS', 'Zugänge & Rollen', 'Endkunden-Zugänge', 'ensureEntrySurface']) {
30
30
  if (!accountUi.includes(marker)) fail(`modern account surface missing label: ${marker}`);
31
31
  }
32
- for (const marker of ['installIntegratedFirstLogin','v96 compact normal-login first activation','eos-passwordless-card','Idempotent writes are essential here']) {
32
+ for (const marker of ['installIntegratedFirstLogin','v98 compact normal-login first activation','eos-passwordless-card','Idempotent writes are essential here']) {
33
33
  if (!bootstrap.includes(marker)) fail(`integrated first-activation surface missing: ${marker}`);
34
34
  }
35
35
  if (/data-eos-account=|selector\.innerHTML/.test(bootstrap)) fail('compact login still injects role buttons or a second panel');
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const vm = require('node:vm');
7
+ const root = path.resolve(__dirname, '..');
8
+ const built = fs.readFileSync(path.join(root, 'build/lib/web.js'), 'utf8');
9
+ const source = fs.readFileSync(path.join(root, 'src/lib/web.ts'), 'utf8');
10
+ const begin = built.indexOf(' getEosPasswordUserName(userId) {');
11
+ const end = built.indexOf(' async destroyEosRequestSessions', begin);
12
+ assert(begin >= 0 && end > begin, 'password methods not found in built backend');
13
+ const methods = built.slice(begin, end);
14
+ const sandbox = { module: { exports: null }, EOS_PASSWORD_SERVICE_USER: 'system.user.admin' };
15
+ vm.runInNewContext(`module.exports = class Harness {\nconstructor(adapter){this.adapter=adapter;}\n${methods}\n}`, sandbox);
16
+ const Harness = sandbox.module.exports;
17
+ (async () => {
18
+ const calls = [];
19
+ const userObject = { _id: 'system.user.user', common: { password: '$2b$hash' }, native: { keep: true } };
20
+ const adapter = {
21
+ setPasswordAsync: async (user, password, options) => calls.push(['set', user, password, options]),
22
+ checkPasswordAsync: async (user, password, options) => { calls.push(['check', user, password, options]); return [true, `system.user.${user}`]; },
23
+ getForeignObjectAsync: async id => { calls.push(['get', id]); return JSON.parse(JSON.stringify(userObject)); },
24
+ extendForeignObjectAsync: async (id, patch, options) => calls.push(['extend', id, patch, options]),
25
+ setForeignObjectAsync: async () => { throw new Error('full user object replacement must not be used'); },
26
+ };
27
+ const harness = new Harness(adapter);
28
+ await harness.setEosUserPassword('system.user.user', 'NexoWatt2025!');
29
+ assert.deepEqual(calls[0].slice(0, 3), ['set', 'user', 'NexoWatt2025!']);
30
+ assert.deepEqual(calls[1].slice(0, 3), ['check', 'user', 'NexoWatt2025!']);
31
+ await harness.updateEosAccountMetadata('system.user.user', (native, account) => {
32
+ account.passwordInitialized = true;
33
+ native.nexowattPasswordChangeRequired = false;
34
+ });
35
+ const extend = calls.find(row => row[0] === 'extend');
36
+ assert(extend, 'native metadata was not extended');
37
+ assert.equal(extend[1], 'system.user.user');
38
+ assert.equal(extend[2].native.keep, true);
39
+ assert.equal(extend[2].native.nexowattEosAccount.passwordInitialized, true);
40
+ assert(!Object.prototype.hasOwnProperty.call(extend[2], 'common'), 'password/common must not be overwritten by metadata update');
41
+ for (const code of [source, built]) {
42
+ assert.match(code, /setPasswordAsync\(userName, password/);
43
+ assert.match(code, /checkPasswordAsync\(userName, password/);
44
+ assert.match(code, /extendForeignObjectAsync/);
45
+ assert.match(code, /passwordVerificationFailed/);
46
+ }
47
+ console.log('[NexoWatt EOS password write] OK: real account name, verified password API and metadata-only update');
48
+ })().catch(error => { console.error(error.stack || error); process.exit(1); });
@@ -13,13 +13,18 @@ try {
13
13
  fs.mkdirSync(path.join(temp, 'adminWww', 'js'), { recursive: true });
14
14
  fs.mkdirSync(path.join(temp, 'adminWww', 'css'), { recursive: true });
15
15
  fs.mkdirSync(path.join(temp, 'src-admin', 'public', 'js'), { recursive: true });
16
- fs.writeFileSync(path.join(temp, 'NEXOWATT_EOS_BUILD_INFO.json'), JSON.stringify({ runtimeEntry: 'v82' }));
16
+ fs.mkdirSync(path.join(temp, 'src-admin', 'public', 'static', 'js'), { recursive: true });
17
+ fs.mkdirSync(path.join(temp, 'adminWww', 'static', 'js'), { recursive: true });
18
+ fs.writeFileSync(path.join(temp, 'NEXOWATT_EOS_BUILD_INFO.json'), JSON.stringify({ runtimeEntry: 'v82', shellCacheVersion: 98 }));
17
19
 
18
20
  const keep = [
19
21
  'adminWww/assets/bootstrap-current-v82.js',
20
22
  'adminWww/assets/Objects-current-v82.js',
21
23
  'adminWww/remoteEntry-v82.js',
22
24
  'adminWww/remoteEntry.js',
25
+ 'adminWww/static/js/nexowatt-stable-v98.js',
26
+ 'src-admin/public/static/js/nexowatt-stable-v98.js',
27
+ 'adminWww/static/js/nexowatt-stable-login.js',
23
28
  ];
24
29
  const remove = [
25
30
  'adminWww/assets/bootstrap-old-v54.js',
@@ -29,6 +34,8 @@ try {
29
34
  'adminWww/js/eos-branding.js',
30
35
  'adminWww/css/eos-branding.css',
31
36
  'src-admin/public/js/eos-security-ui.js',
37
+ 'adminWww/static/js/nexowatt-stable-v97.js',
38
+ 'src-admin/public/static/js/nexowatt-stable-v95.js',
32
39
  ];
33
40
  for (const rel of [...keep, ...remove]) {
34
41
  const file = path.join(temp, rel);
@@ -38,6 +45,7 @@ try {
38
45
 
39
46
  const result = cleanLegacyRuntime({ root: temp, quiet: true, silent: true });
40
47
  assert.strictEqual(result.activeRuntime, 82);
48
+ assert.strictEqual(result.activeShell, 98);
41
49
  assert.strictEqual(result.removed.length, remove.length);
42
50
  for (const rel of keep) assert.ok(fs.existsSync(path.join(temp, rel)), `current runtime removed: ${rel}`);
43
51
  for (const rel of remove) assert.ok(!fs.existsSync(path.join(temp, rel)), `legacy file remained: ${rel}`);
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs=require('node:fs');const path=require('node:path');const root=path.resolve(__dirname,'..');const read=rel=>fs.readFileSync(path.join(root,rel),'utf8');const must=(ok,msg)=>{if(!ok){console.error(`[NexoWatt EOS stable v98] ${msg}`);process.exit(1)}};
4
+ const pkg=JSON.parse(read('package.json')),io=JSON.parse(read('io-package.json')),info=JSON.parse(read('NEXOWATT_EOS_BUILD_INFO.json')),css=read('adminWww/css/nexowatt-native-shell.css'),stable=read('adminWww/static/js/nexowatt-stable-v98.js'),login=read('adminWww/static/js/nexowatt-stable-login.js'),index=read('adminWww/index.html'),web=read('src/lib/web.ts'),built=read('build/lib/web.js'),account=read('adminWww/js/eos-account-management.js'),bootstrap=read('adminWww/assets/bootstrap-COulQZax-v84.js'),ems=read('adminWww/js/eos-ems-overview.js');
5
+ must(pkg.version==='7.9.98'&&io.version==='7.9.98'&&io.common.version==='7.9.98'&&info.version==='7.9.98','version drift');
6
+ must(info.shellCacheVersion===98&&info.shellCacheTag==='98','shell cache not v98');
7
+ must(css.includes('overflow-y: auto !important')&&css.includes('scrollbar-color: rgba(0, 229, 138'),'cockpit vertical scrollbar missing');
8
+ must(css.includes('.MuiInputLabel-root { display: none !important; }')&&bootstrap.includes('I18n.t("enterLogin")+" *"'),'clean login field contract missing');
9
+ must(stable.includes("VERSION = 'v98-scroll-login-password-write'")&&!login.includes('data-nw-login-card'),'stable runtime incorrect');
10
+ must(index.includes('nexowatt-stable-v98.js?v=98')&&index.includes('nexowatt-native-shell.css?v=98'),'v98 assets not loaded');
11
+ for(const code of [web,built])for(const marker of ['getEosPasswordUserName','setPasswordAsync(userName, password','checkPasswordAsync(userName, password','passwordVerificationFailed','updateEosAccountMetadata','extendForeignObjectAsync'])must(code.includes(marker),`password marker missing: ${marker}`);
12
+ must(account.includes('Startpasswort „nexowatt“')&&account.includes('credentials: \'include\''),'reset UI does not describe/use stable initial password flow');
13
+ must(ems.includes("REQUIRED_UI_VERSION = '0.8.198'")&&ems.includes('readUiAdapterInfo'),'working EMS diagnosis was lost');
14
+ console.log('[NexoWatt EOS stable v98] OK');
@@ -19,6 +19,14 @@ function setValue(changes, label, object, key, value) {
19
19
  }
20
20
  }
21
21
 
22
+ function setJsonValue(changes, label, object, key, value) {
23
+ const previous = object[key];
24
+ if (JSON.stringify(previous) !== JSON.stringify(value)) {
25
+ changes.push({ label, previous, value });
26
+ object[key] = value === undefined ? undefined : JSON.parse(JSON.stringify(value));
27
+ }
28
+ }
29
+
22
30
  function syncReleaseVersion(rootDir) {
23
31
  const root = path.resolve(rootDir);
24
32
  const packageFile = path.join(root, 'package.json');
@@ -62,7 +70,40 @@ function syncReleaseVersion(rootDir) {
62
70
  update(json) {
63
71
  setValue(changes, 'package-lock.json version', json, 'version', version);
64
72
  if (json.packages?.['']) {
65
- setValue(changes, 'package-lock.json packages[""] version', json.packages[''], 'version', version);
73
+ const rootPackage = json.packages[''];
74
+ setValue(changes, 'package-lock.json packages[""] version', rootPackage, 'version', version);
75
+ // A merge archive can update package.json while leaving stale release scripts,
76
+ // files and policy metadata in package-lock.json. npm reads the lock root during
77
+ // packaging, so keep every release-critical root field canonical as well.
78
+ for (const key of [
79
+ 'name',
80
+ 'description',
81
+ 'license',
82
+ 'author',
83
+ 'private',
84
+ 'engines',
85
+ 'main',
86
+ 'files',
87
+ 'scripts',
88
+ 'dependencies',
89
+ 'devDependencies',
90
+ 'overrides',
91
+ 'keywords',
92
+ 'publishConfig',
93
+ 'nexowattReleasePolicy',
94
+ 'publisher',
95
+ 'copyright',
96
+ ]) {
97
+ if (Object.prototype.hasOwnProperty.call(pkg, key)) {
98
+ setJsonValue(
99
+ changes,
100
+ `package-lock.json packages[""] ${key}`,
101
+ rootPackage,
102
+ key,
103
+ pkg[key],
104
+ );
105
+ }
106
+ }
66
107
  }
67
108
  },
68
109
  },
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- const fs=require('node:fs');const path=require('node:path');const root=path.resolve(__dirname,'..');
4
- const read=rel=>fs.readFileSync(path.join(root,rel),'utf8');const fail=msg=>{console.error(`[NexoWatt EOS UI overview runtime] ${msg}`);process.exit(1)};
5
- const runtime=read('adminWww/js/eos-ems-overview.js');const source=read('src-admin/public/js/eos-ems-overview.js');const css=read('adminWww/css/eos-ems-overview.css');const index=read('adminWww/index.html');const role=read('adminWww/js/eos-role-ui.js');const shell=read('adminWww/js/nexowatt-native-shell.js');const intro=read('src-admin/src/tabs/Intro.tsx');const app=read('src-admin/src/App.tsx');const bundle=read('adminWww/assets/index-D2ymscJA-v84.js');
3
+ const fs=require('node:fs');const path=require('node:path');const root=path.resolve(__dirname,'..');const read=rel=>fs.readFileSync(path.join(root,rel),'utf8');const fail=msg=>{console.error(`[NexoWatt EOS UI overview runtime] ${msg}`);process.exit(1)};
4
+ const runtime=read('adminWww/js/eos-ems-overview.js');const source=read('src-admin/public/js/eos-ems-overview.js');const css=read('adminWww/css/eos-ems-overview.css');const index=read('adminWww/index.html');const role=read('adminWww/js/eos-role-ui.js');const shell=read('adminWww/js/nexowatt-native-shell.js');const intro=read('src-admin/src/tabs/Intro.tsx');const introBundle=read('adminWww/assets/Intro-DkwRiz1n-v84.js');const bootstrap=read('adminWww/assets/bootstrap-COulQZax-v84.js');const generic=read('adminWww/assets/index-D2ymscJA-v84.js');
6
5
  if(runtime!==source)fail('runtime source/build drift');
7
- for(const marker of ['nexowatt-ems-overview-v1','nexowatt-ui.*.info.adminOverview.*','system.adapter.nexowatt-ui.*.alive','POLL_MS = 5000','STALE_MS = 20000','getForeignStates','Keine Kachel besitzt Schreibhoheit','currentDecisions','§14a-Fallback'])if(!runtime.includes(marker))fail(`runtime marker missing: ${marker}`);
6
+ for(const marker of ['nexowatt-ems-overview-v1','nexowatt-ui.*.info.adminOverview.*','system.adapter.nexowatt-ui.*.alive','POLL_MS = 5000','STALE_MS = 20000',"REQUIRED_UI_VERSION = '0.8.198'",'getForeignStates','Keine Kachel besitzt Schreibhoheit','currentDecisions','§14a-Fallback','nexowatt-eos-admin-socket-ready','readUiAdapterInfo','socketFromReact'])if(!runtime.includes(marker))fail(`runtime marker missing: ${marker}`);
8
7
  for(const forbidden of ['setForeignState','setState(','sendToHost','writeFile','extendObject'])if(runtime.includes(forbidden))fail(`read-only runtime contains ${forbidden}`);
9
- for(const marker of ['eos-ems-overview.css?v=96','eos-ems-overview.js?v=96','nexowatt-stable-v96.js?v=96'])if(!index.includes(marker))fail(`index marker missing: ${marker}`);
10
- if(!css.includes('#eos-ems-overview-runtime')||!css.includes('.eos-ems-overview-metrics'))fail('EMS card CSS incomplete');
11
- if(role.includes("overview.innerHTML = `")||!role.includes('never cover the real Admin Intro'))fail('obsolete role overview still active');
12
- if(!shell.includes("label: 'Installateur'")||!shell.includes("label: 'Endkunde'"))fail('native hero is not role-aware');
13
- if(!intro.includes('<NexoWattEmsOverview')||!intro.includes('getOverviewRoleConfig')||!intro.includes('admin ? this.getButtons() : null'))fail('React Intro integration incomplete');
14
- if(!app.includes('NEXOWATT_EOS_ADMIN_SOCKET = new Connection'))fail('source socket exposure missing');
15
- if(!bundle.includes('window.NEXOWATT_EOS_ADMIN_SOCKET=new M({'))fail('prebuilt socket exposure missing');
8
+ for(const marker of ['eos-ems-overview.css?v=98','eos-ems-overview.js?v=98','nexowatt-stable-v98.js?v=98'])if(!index.includes(marker))fail(`index marker missing: ${marker}`);
9
+ if(!css.includes('#eos-ems-overview-runtime')||!css.includes('.eos-ems-overview-metrics')||!css.includes('startup and compatibility messages stay compact'))fail('EMS card CSS incomplete');
10
+ if(role.includes('overview.innerHTML = `')||!role.includes('never cover the real Admin Intro'))fail('obsolete role overview still active');
11
+ if(!shell.includes("label: 'Installateur'")||!shell.includes("label: 'Endkunde'")||!shell.includes('eosNativeReactOverview'))fail('native overview role bridge incomplete');
12
+ if(!intro.includes('NEXOWATT_EOS_ADMIN_SOCKET')||!intro.includes('nexowatt-eos-admin-socket-ready'))fail('source Intro socket bridge missing');
13
+ if(!introBundle.includes('NEXOWATT_EOS_ADMIN_SOCKET')||!introBundle.includes('nexowatt-eos-admin-socket-ready'))fail('productive Intro socket bridge missing');
14
+ if(!bootstrap.includes('window.NEXOWATT_EOS_ADMIN_SOCKET=window.NEXOWATT_EOS_SOCKET=new AdminConnection'))fail('productive main AdminConnection exposure missing');
15
+ if(generic.includes('this.socket=window.NEXOWATT_EOS_ADMIN_SOCKET=new M({'))fail('generic adapter helper still overwrites main AdminConnection');
16
16
  console.log('[NexoWatt EOS UI overview runtime] OK');