iobroker.eos-admin 7.9.35 → 7.9.37

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.
@@ -1,165 +1,18 @@
1
1
  (function () {
2
2
  'use strict';
3
-
4
- const LOG_PREFIX = '[NexoWatt EOS hard logout]';
5
- const VERSION = '35';
6
- const MIN_POLL_MS = 15_000;
7
- const MAX_TIMER_MS = 2_147_000_000;
8
- const MIN_TTL_SEC = 5;
9
- const STORAGE_KEY = `nexowatt:eos:hardLogoutAt:${location.host}:root`;
10
- let logoutTimer = null;
11
- let pollTimer = null;
12
- let logoutStarted = false;
13
-
14
- function isLoginPage() {
15
- const path = String(window.location.pathname || '').toLowerCase();
16
- const search = String(window.location.search || '').toLowerCase();
17
- return path.endsWith('/login') || path.includes('/login/') || search.includes('login');
18
- }
19
-
20
- function clearStoredTokens() {
21
- const storages = [window.localStorage, window.sessionStorage, window._localStorage, window._sessionStorage]
22
- .filter(Boolean)
23
- .filter((storage, index, array) => array.indexOf(storage) === index);
24
- const tokenKeyPattern = /(access[_-]?token|refresh[_-]?token|token[_-]?expires|expires[_-]?in|oauth|bearer|auth|connection)/i;
25
- for (const storage of storages) {
26
- try {
27
- const keys = [];
28
- for (let i = 0; i < storage.length; i++) {
29
- const key = storage.key(i);
30
- if (key && (tokenKeyPattern.test(key) || key === STORAGE_KEY)) {
31
- keys.push(key);
32
- }
33
- }
34
- keys.forEach(key => storage.removeItem(key));
35
- } catch (e) {
36
- // ignore blocked storage
37
- }
38
- }
39
- }
40
-
41
- function clearAuthCookies() {
42
- const cookieNames = ['access_token', 'refresh_token', 'connect.sid', 'ioBroker.sid'];
43
- const paths = ['/', window.location.pathname.replace(/\/[^/]*$/, '/') || '/'];
44
- for (const name of cookieNames) {
45
- for (const path of paths) {
46
- document.cookie = `${name}=; Max-Age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=${path}; SameSite=Lax`;
47
- }
48
- }
49
- }
50
-
51
- function readDeadline() {
52
- try {
53
- const value = Number(window.localStorage.getItem(STORAGE_KEY));
54
- return Number.isFinite(value) && value > 0 ? value : 0;
55
- } catch (e) {
56
- return 0;
57
- }
58
- }
59
-
60
- function writeDeadline(deadline) {
61
- try {
62
- window.localStorage.setItem(STORAGE_KEY, String(deadline));
63
- } catch (e) {
64
- // ignore blocked storage
65
- }
66
- }
67
-
68
- function hardLogout(reason) {
69
- if (logoutStarted || isLoginPage()) return;
70
- logoutStarted = true;
71
- try { console.warn(LOG_PREFIX, reason || 'session expired'); } catch (e) {}
72
- if (logoutTimer) {
73
- clearTimeout(logoutTimer);
74
- logoutTimer = null;
75
- }
76
- if (pollTimer) {
77
- clearInterval(pollTimer);
78
- pollTimer = null;
79
- }
80
- clearStoredTokens();
81
- clearAuthCookies();
82
- const logoutUrl = new URL('logout', window.location.origin + '/');
83
- logoutUrl.searchParams.set('hard', '1');
84
- logoutUrl.searchParams.set('ts', String(Date.now()));
85
- window.location.replace(logoutUrl.href);
86
- }
87
-
88
- function scheduleHardLogout(deadline) {
89
- const ms = Math.min(Math.max(deadline - Date.now() + 250, 250), MAX_TIMER_MS);
90
- if (logoutTimer) clearTimeout(logoutTimer);
91
- logoutTimer = setTimeout(() => hardLogout('configured login timeout reached'), ms);
92
- }
93
-
94
- function applyServerExpiration(expireInSec) {
95
- const seconds = Number(expireInSec);
96
- if (!Number.isFinite(seconds)) return;
97
- if (seconds <= 0) {
98
- hardLogout('server reported expired session');
99
- return;
100
- }
101
- if (seconds < MIN_TTL_SEC) return;
102
-
103
- const now = Date.now();
104
- const candidateDeadline = now + seconds * 1000;
105
- const storedDeadline = readDeadline();
106
-
107
- // Set the hard deadline once. If the server reports an earlier expiration later,
108
- // tighten the deadline. Never extend it through refresh-token based renewal.
109
- const deadline = !storedDeadline || candidateDeadline < storedDeadline - 5000 ? candidateDeadline : storedDeadline;
110
- if (deadline !== storedDeadline) writeDeadline(deadline);
111
-
112
- if (now >= deadline) {
113
- hardLogout('stored hard deadline reached');
114
- } else {
115
- scheduleHardLogout(deadline);
116
- }
117
- }
118
-
119
- async function checkSession() {
120
- if (logoutStarted) return;
121
- if (isLoginPage()) {
122
- clearStoredTokens();
123
- clearAuthCookies();
124
- return;
125
- }
126
-
127
- const storedDeadline = readDeadline();
128
- if (storedDeadline && Date.now() >= storedDeadline) {
129
- hardLogout('stored hard deadline reached');
130
- return;
131
- }
132
-
133
- try {
134
- const response = await fetch('./session?hard=1&ts=' + Date.now(), {
135
- credentials: 'include',
136
- cache: 'no-store',
137
- headers: { Accept: 'application/json' },
138
- });
139
- if (!response.ok) return;
140
- const session = await response.json();
141
- if (typeof session.expireInSec === 'number') {
142
- applyServerExpiration(session.expireInSec);
143
- }
144
- } catch (e) {
145
- // During update/restart the endpoint can be unavailable. Do not logout just because of a network error.
146
- }
147
- }
148
-
149
- function start() {
150
- checkSession();
151
- pollTimer = setInterval(checkSession, MIN_POLL_MS);
152
- window.addEventListener('focus', checkSession, { passive: true });
153
- document.addEventListener('visibilitychange', () => {
154
- if (!document.hidden) checkSession();
155
- });
156
- }
157
-
158
- window.NEXOWATT_EOS_HARD_LOGOUT = { version: VERSION, checkSession, hardLogout, clearStoredTokens, clearAuthCookies };
159
-
160
- if (document.readyState === 'loading') {
161
- document.addEventListener('DOMContentLoaded', start, { once: true });
162
- } else {
163
- start();
164
- }
3
+ // NexoWatt EOS v36: compatibility stub.
4
+ // The custom hard-logout timer was removed because it could expire sessions
5
+ // earlier than the configured admin TTL and broke native adapter dialogs.
6
+ // Session handling is now delegated to the same OAuth/session flow as the
7
+ // upstream ioBroker Admin.
8
+ const VERSION = '36';
9
+ function noop() {}
10
+ window.NEXOWATT_EOS_HARD_LOGOUT = {
11
+ version: VERSION,
12
+ checkSession: noop,
13
+ hardLogout: noop,
14
+ clearStoredTokens: noop,
15
+ clearAuthCookies: noop,
16
+ disabled: true,
17
+ };
165
18
  })();
@@ -0,0 +1,144 @@
1
+ (() => {
2
+ 'use strict';
3
+
4
+ window.NEXOWATT_EOS_RUNTIME_FIXES_VERSION = 'v37-notification-backitup-security-text-fix';
5
+
6
+ const MOJIBAKE_MAP = new Map(Object.entries({
7
+ 'dürfen': 'dürfen', 'Dürfen': 'Dürfen',
8
+ 'für': 'für', 'Für': 'Für',
9
+ 'müssen': 'müssen', 'Müssen': 'Müssen',
10
+ 'können': 'können', 'Können': 'Können',
11
+ 'möglich': 'möglich', 'Möglich': 'Möglich',
12
+ 'Löschen': 'Löschen', 'löschen': 'löschen',
13
+ 'schützen': 'schützen', 'Schützen': 'Schützen',
14
+ 'Schützt': 'Schützt', 'schützt': 'schützt',
15
+ 'Geschützte': 'Geschützte', 'geschützte': 'geschützte',
16
+ 'Geschützter': 'Geschützter', 'geschützter': 'geschützter',
17
+ 'geschützten': 'geschützten', 'Geschützten': 'Geschützten',
18
+ 'ausgewählte': 'ausgewählte', 'Ausgewählte': 'Ausgewählte',
19
+ 'ändern': 'ändern', 'Ändern': 'Ändern',
20
+ 'über': 'über', 'Über': 'Über',
21
+ 'Wähle': 'Wähle', 'wähle': 'wähle',
22
+ 'öffnen': 'öffnen', 'Öffnen': 'Öffnen',
23
+ 'schließen': 'schließen', 'Schließen': 'Schließen',
24
+ 'Gerät': 'Gerät', 'gerät': 'gerät',
25
+ 'Geräte': 'Geräte', 'geräte': 'geräte',
26
+ 'Zugänge': 'Zugänge', 'zugänge': 'zugänge',
27
+ 'Sicherheitsgründen': 'Sicherheitsgründen',
28
+ 'Kompatibilitätsgründen': 'Kompatibilitätsgründen',
29
+ 'Benachrichtigungen': 'Benachrichtigungen',
30
+ 'ß': 'ß', 'Ä': 'Ä', 'Ö': 'Ö', 'Ü': 'Ü', 'ä': 'ä', 'ö': 'ö', 'ü': 'ü',
31
+ '–': '–', '—': '—', '„': '„', '“': '“', '”': '”', ' ': ' ', 'Â': ''
32
+ }));
33
+
34
+ const safe = fn => { try { return fn(); } catch { return undefined; } };
35
+
36
+ const repairText = value => {
37
+ let text = String(value || '');
38
+ if (!/[ÃÂâ]/.test(text)) return text;
39
+ for (const [from, to] of MOJIBAKE_MAP) {
40
+ if (text.includes(from)) text = text.split(from).join(to);
41
+ }
42
+ return text;
43
+ };
44
+
45
+ const skip = el => {
46
+ const tag = el?.tagName;
47
+ return tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'CODE' || tag === 'PRE';
48
+ };
49
+
50
+ const repairSecurityText = root => safe(() => {
51
+ const base = root && root.nodeType ? root : document.body || document.documentElement;
52
+ if (!base) return;
53
+ const walker = document.createTreeWalker(base, NodeFilter.SHOW_TEXT, {
54
+ acceptNode(node) {
55
+ return skip(node.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
56
+ }
57
+ });
58
+ let node;
59
+ while ((node = walker.nextNode())) {
60
+ const before = node.nodeValue || '';
61
+ const after = repairText(before);
62
+ if (after !== before) node.nodeValue = after;
63
+ }
64
+ const attrSelector = '[title],[aria-label],[placeholder],[value]';
65
+ const elements = base.nodeType === Node.ELEMENT_NODE && base.matches?.(attrSelector)
66
+ ? [base]
67
+ : Array.from(base.querySelectorAll?.(attrSelector) || []);
68
+ elements.forEach(el => {
69
+ if (skip(el)) return;
70
+ ['title', 'aria-label', 'placeholder', 'value'].forEach(attr => {
71
+ if (!el.hasAttribute?.(attr)) return;
72
+ const before = el.getAttribute(attr) || '';
73
+ const after = repairText(before);
74
+ if (after !== before) el.setAttribute(attr, after);
75
+ });
76
+ });
77
+ });
78
+
79
+ const isNotificationSurface = el => !!el?.closest?.([
80
+ '.MuiSnackbar-root',
81
+ '.MuiAlert-root',
82
+ '[role="alert"]',
83
+ '[role="status"]',
84
+ '.Toastify__toast',
85
+ '.eos-notification-safe'
86
+ ].join(','));
87
+
88
+ const isCloseControl = el => {
89
+ const label = `${el?.textContent || ''} ${el?.getAttribute?.('aria-label') || ''} ${el?.getAttribute?.('title') || ''}`.toLowerCase();
90
+ return /close|schlie(?:ß|ss)en|dismiss|ausblenden|ok|verstanden|x$/.test(label)
91
+ || !!el?.querySelector?.('svg[data-testid*="Close"], svg[data-testid*="Clear"], .material-icons');
92
+ };
93
+
94
+ const restoreCloseButton = button => safe(() => {
95
+ if (!button || !isCloseControl(button)) return;
96
+ button.classList.remove('eos-protected-delete-control', 'eos-security-hidden-delete', 'eos-hidden-logout', 'eos-native-logout-hidden');
97
+ button.removeAttribute('aria-disabled');
98
+ button.removeAttribute('data-eos-security-blocked');
99
+ if ('disabled' in button && button.disabled && !button.dataset.eosOriginalDisabled) button.disabled = false;
100
+ button.style.pointerEvents = 'auto';
101
+ button.style.visibility = 'visible';
102
+ button.style.opacity = '1';
103
+ if (button.style.display === 'none') button.style.display = '';
104
+ });
105
+
106
+ const repairNotifications = root => safe(() => {
107
+ const base = root && root.nodeType ? root : document;
108
+ const surfaces = Array.from(base.querySelectorAll?.([
109
+ '.MuiSnackbar-root',
110
+ '.MuiAlert-root',
111
+ '[role="alert"]',
112
+ '[role="status"]',
113
+ '.Toastify__toast'
114
+ ].join(',')) || []);
115
+ if (base.nodeType === Node.ELEMENT_NODE && isNotificationSurface(base)) surfaces.push(base);
116
+ surfaces.forEach(surface => {
117
+ surface.classList.add('eos-notification-safe');
118
+ surface.style.pointerEvents = 'auto';
119
+ surface.querySelectorAll('button,[role="button"],a,.MuiIconButton-root').forEach(restoreCloseButton);
120
+ });
121
+ });
122
+
123
+ const run = root => {
124
+ repairSecurityText(root);
125
+ repairNotifications(root);
126
+ };
127
+
128
+ const install = () => {
129
+ run(document.body || document.documentElement);
130
+ const observer = new MutationObserver(mutations => {
131
+ const roots = new Set();
132
+ for (const mutation of mutations) {
133
+ if (mutation.type === 'characterData') roots.add(mutation.target.parentElement || document.body);
134
+ mutation.addedNodes?.forEach(node => roots.add(node));
135
+ }
136
+ roots.forEach(root => run(root));
137
+ });
138
+ observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true });
139
+ [250, 1000, 3000, 8000].forEach(ms => window.setTimeout(() => run(document.body || document.documentElement), ms));
140
+ };
141
+
142
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install, { once: true });
143
+ else install();
144
+ })();
@@ -1,7 +1,7 @@
1
1
  (() => {
2
2
  'use strict';
3
3
 
4
- const VERSION = 'v31-security-text-polish';
4
+ const VERSION = 'v37-security-text-polish';
5
5
  const LEGACY_ADMIN = 'admin';
6
6
  const LEGACY_ADMIN_INSTANCE = 'admin.0';
7
7
  const ASSET_BASE = (() => {
@@ -34,14 +34,24 @@
34
34
  let text = String(value || '');
35
35
  const map = new Map(Object.entries({
36
36
  'dürfen': 'dürfen', 'Dürfen': 'Dürfen', 'für': 'für', 'Für': 'Für',
37
- 'können': 'können', 'Können': 'Können', 'möglich': 'möglich', 'Möglich': 'Möglich',
38
- 'Löschen': 'Löschen', 'löschen': 'löschen', 'schützen': 'schützen', 'Schützen': 'Schützen',
39
- 'Schützt': 'Schützt', 'schützt': 'schützt', 'Geschützte': 'Geschützte', 'geschützte': 'geschützte',
37
+ 'müssen': 'müssen', 'Müssen': 'Müssen', 'können': 'können', 'Können': 'Können',
38
+ 'möglich': 'möglich', 'Möglich': 'Möglich', 'Löschen': 'Löschen', 'löschen': 'löschen',
39
+ 'schützen': 'schützen', 'Schützen': 'Schützen', 'Schützt': 'Schützt', 'schützt': 'schützt',
40
+ 'Geschützte': 'Geschützte', 'geschützte': 'geschützte', 'Geschützter': 'Geschützter',
40
41
  'ausgewählte': 'ausgewählte', 'Ausgewählte': 'Ausgewählte', 'ändern': 'ändern', 'Ändern': 'Ändern',
41
42
  'über': 'über', 'Über': 'Über', 'Wähle': 'Wähle', 'wähle': 'wähle',
42
- 'ß': 'ß', 'Ä': 'Ä', 'Ö': 'Ö', 'Ü': 'Ü', 'ä': 'ä', 'ö': 'ö', 'ü': 'ü'
43
+ 'öffnen': 'öffnen', 'Öffnen': 'Öffnen', 'schließen': 'schließen', 'Schließen': 'Schließen',
44
+ 'Gerät': 'Gerät', 'Geräte': 'Geräte', 'Geräteliste': 'Geräteliste',
45
+ 'ß': 'ß', 'Ä': 'Ä', 'Ö': 'Ö', 'Ü': 'Ü', 'ä': 'ä', 'ö': 'ö', 'ü': 'ü', 'Â': ''
43
46
  }));
44
47
  for (const [from, to] of map) if (text.includes(from)) text = text.split(from).join(to);
48
+ // Generic Latin1-as-UTF8 repair for labels injected by older bundles. Guard against false positives.
49
+ if (/[ÃÂ]/.test(text)) {
50
+ try {
51
+ const repaired = decodeURIComponent(escape(text));
52
+ if (/[äöüÄÖÜß]/.test(repaired) && !/[ÃÂ]/.test(repaired)) text = repaired;
53
+ } catch { /* keep mapped text */ }
54
+ }
45
55
  return text;
46
56
  };
47
57
  const normalizeFlat = value => normalize(value).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
@@ -220,16 +230,39 @@
220
230
  };
221
231
 
222
232
  const applyPolicyToDom = () => {
233
+ // Text repair is safe and must also run on adapter config pages.
234
+ replaceTextNodes();
223
235
  const admin = isAdminUser();
224
236
  document.documentElement.classList.toggle('eos-security-admin-user', admin);
225
237
  document.documentElement.classList.toggle('eos-security-non-admin-user', !admin);
226
238
  document.documentElement.classList.toggle('eos-security-nonadmin', !admin);
227
- replaceTextNodes();
239
+ releaseNotificationControls();
240
+ if (isAdapterConfigSurface()) return;
228
241
  hideLegacyAdminPanels();
229
242
  hideProtectedDeleteControls();
230
243
  hideEosSecuritySettingsForNonAdmins();
231
244
  };
232
245
 
246
+ const isAdapterConfigSurface = () => document.documentElement.classList.contains('eos-adapter-config-surface') || /Instanzeinstellungen:|Instance settings:|Geräteliste|Gerät hinzufügen|Gerät bearbeiten/i.test(document.body?.textContent || '');
247
+
248
+ const releaseNotificationControls = () => {
249
+ // Never block notification/toast close actions. These controls are owned by
250
+ // the native Admin UI and must remain clickable regardless of EOS security rules.
251
+ document.querySelectorAll('.MuiSnackbar-root, .MuiAlert-root, .MuiSnackbarContent-root, [role="alert"], .Toastify__toast, .notistack-Snackbar').forEach(box => {
252
+ box.classList.add('eos-notification-safe');
253
+ box.style.pointerEvents = 'auto';
254
+ box.querySelectorAll('button, [role="button"], a, .MuiIconButton-root').forEach(control => {
255
+ control.classList.remove('eos-protected-delete-control', 'eos-security-hidden-delete');
256
+ control.removeAttribute('disabled');
257
+ control.removeAttribute('aria-disabled');
258
+ if ('disabled' in control) control.disabled = false;
259
+ control.style.pointerEvents = 'auto';
260
+ control.style.display = '';
261
+ control.style.visibility = '';
262
+ });
263
+ });
264
+ };
265
+
233
266
  const scheduleApply = () => {
234
267
  if (state.scheduled) return;
235
268
  state.scheduled = true;
@@ -267,7 +300,7 @@
267
300
 
268
301
  document.addEventListener('click', event => {
269
302
  const target = event.target?.closest?.('button,[role="button"],a,[role="menuitem"],.MuiMenuItem-root');
270
- if (!target || isAdminUser()) return;
303
+ if (!target || isAdminUser() || isAdapterConfigSurface()) return;
271
304
  const label = normalizeFlat(`${target.textContent || ''} ${target.getAttribute?.('title') || ''} ${target.getAttribute?.('aria-label') || ''}`);
272
305
  if (/loschen|delete|remove|deinstall|uninstall/.test(label)) {
273
306
  target.classList.add('eos-security-hidden-delete');
@@ -25,9 +25,9 @@
25
25
  "Password is too short (min 6 chars)": "Das Passwort ist zu kurz (mindestens 6 Zeichen)",
26
26
  "Password repeat": "Passwort wiederholen",
27
27
  "Password successfully changed for \"%s\"": "Passwort für „%s“ erfolgreich geändert",
28
- "Schützt ausgewählte Adapter für Installateur-/Endkundenbenutzer über Administrator-ACLs und EOS-UI-Regeln. dontDelete und nondeletable bleiben false, damit Updates weiter funktionieren.": "Schützt ausgewählte Adapter für Installateur- und Endkundenbenutzer über Administratorrechte und EOS-Regeln. dontDelete und nondeletable bleiben false, damit Updates weiter funktionieren.",
28
+ "Schützt ausgewählte Adapter in der EOS Oberfläche vor kritischen Aktionen durch Installateur- und Endkundenrollen. Adaptereigene Funktionen und Updates bleiben möglich.": "Schützt ausgewählte Adapter für Installateur- und Endkundenbenutzer über Administratorrechte und EOS-Regeln. dontDelete und nondeletable bleiben false, damit Updates weiter funktionieren.",
29
29
  "Set password": "Passwort festlegen",
30
- "Setzt Administrator-ACLs auf die geschützte Adapterliste. Installateur-/Endkundenbenutzer können diese Adapter nicht stoppen, aktivieren oder löschen; Administratoren können sie weiterhin updaten und warten.": "Setzt Administratorrechte auf die geschützte Adapterliste. Installateur- und Endkundenbenutzer können diese Adapter nicht stoppen, aktivieren oder löschen. Administratoren können sie weiterhin updaten und warten.",
30
+ "Blendet kritische Aktionen für Installateur- und Endkundenrollen aus. Harte ACL-Sperren werden nicht auf Runtime-Adapter gesetzt, damit BackItUp und adaptereigene Konfigurationen stabil bleiben.": "Setzt Administratorrechte auf die geschützte Adapterliste. Installateur- und Endkundenbenutzer können diese Adapter nicht stoppen, aktivieren oder löschen. Administratoren können sie weiterhin updaten und warten.",
31
31
  "Sperr-Adresse des alten Admins": "Sperr-Adresse des alten Admins",
32
32
  "Sperr-Port des alten Admins": "Sperr-Port des alten Admins",
33
33
  "The password for user \"%s\" was successfully changed": "Das Passwort für den Benutzer „%s“ wurde erfolgreich geändert",
@@ -25,9 +25,9 @@
25
25
  "Password is too short (min 6 chars)": "Password is too short (min 6 chars)",
26
26
  "Password repeat": "Password repeat",
27
27
  "Password successfully changed for \"%s\"": "Password successfully changed for \"%s\"",
28
- "Schützt ausgewählte Adapter für Installateur-/Endkundenbenutzer über Administrator-ACLs und EOS-UI-Regeln. dontDelete und nondeletable bleiben false, damit Updates weiter funktionieren.": "Protects selected adapters from installer/end-customer users via administrator ACLs and EOS UI rules. dontDelete and nondeletable remain false so updates keep working.",
28
+ "Schützt ausgewählte Adapter in der EOS Oberfläche vor kritischen Aktionen durch Installateur- und Endkundenrollen. Adaptereigene Funktionen und Updates bleiben möglich.": "Protects selected adapters from installer/end-customer users via administrator ACLs and EOS UI rules. dontDelete and nondeletable remain false so updates keep working.",
29
29
  "Set password": "Set password",
30
- "Setzt Administrator-ACLs auf die geschützte Adapterliste. Installateur-/Endkundenbenutzer können diese Adapter nicht stoppen, aktivieren oder löschen; Administratoren können sie weiterhin updaten und warten.": "Applies administrator ACLs to the protected adapter list. Installer/end-customer users cannot stop, enable or delete these adapters; administrators can still update and maintain them.",
30
+ "Blendet kritische Aktionen für Installateur- und Endkundenrollen aus. Harte ACL-Sperren werden nicht auf Runtime-Adapter gesetzt, damit BackItUp und adaptereigene Konfigurationen stabil bleiben.": "Applies administrator ACLs to the protected adapter list. Installer/end-customer users cannot stop, enable or delete these adapters; administrators can still update and maintain them.",
31
31
  "Sperr-Adresse des alten Admins": "Legacy admin lock bind address",
32
32
  "Sperr-Port des alten Admins": "Legacy admin lock port",
33
33
  "The password for user \"%s\" was successfully changed": "The password for user \"%s\" was successfully changed",
package/build/lib/web.js CHANGED
@@ -583,6 +583,16 @@ class Web {
583
583
  this.server.app.get('/version', (_req, res) => {
584
584
  res.status(200).send(this.adapter.version);
585
585
  });
586
+ // v36: Repair stale/broken URLs generated by older EOS hard-logout builds.
587
+ this.server.app.use((req, res, next) => {
588
+ const requested = String(req.originalUrl || req.url || '');
589
+ if (/(?:%2f|%252f)(?:%23|%2523)|\/login\/|\/logout\/|hard=1/i.test(requested)
590
+ && /index\.html|login|hard=1|origin=|%23|%2523/i.test(requested)) {
591
+ res.redirect(this.LOGIN_PAGE);
592
+ return;
593
+ }
594
+ next();
595
+ });
586
596
  // replace socket.io
587
597
  this.server.app.use((req, res, next) => {
588
598
  const url = req.url.split('?')[0];
@@ -633,38 +643,12 @@ class Web {
633
643
  this.server.app.use(cookieParser());
634
644
  this.server.app.use(bodyParser.urlencoded({ extended: true }));
635
645
  this.server.app.use(bodyParser.json());
636
- // NexoWatt EOS: enforce the configured login timeout as a hard logout.
637
- // We remove refresh tokens from OAuth responses so the admin client cannot silently extend sessions.
638
- const eosHardLogout = false; // v35: keep OAuth refresh flow alive; hard timeout is enforced client-side by an absolute deadline.
639
- if (eosHardLogout) {
640
- this.server.app.use('/oauth/token', (req, res, next) => {
641
- const grantType = String(req.body?.grant_type || '');
642
- if (req.method === 'POST' && grantType === 'refresh_token') {
643
- res.status(401).json({
644
- error: 'invalid_grant',
645
- error_description: 'EOS session expired. Please log in again.',
646
- eosHardLogout: true,
647
- });
648
- return;
649
- }
650
- const originalJson = res.json.bind(res);
651
- res.json = body => {
652
- if (body && typeof body === 'object' && body.access_token && body.expires_in) {
653
- const expiresIn = Math.max(1, Math.round(Number(body.expires_in) || Number(this.settings.ttl) || 3600));
654
- body.refresh_token = '';
655
- body.refresh_token_expires_in = expiresIn;
656
- body.eosHardLogout = true;
657
- }
658
- return originalJson(body);
659
- };
660
- next();
661
- });
662
- }
646
+ // v36: OAuth/session handling intentionally follows upstream ioBroker Admin.
663
647
  this.oauth2Model = (0, webserver_1.createOAuth2Server)(this.adapter, {
664
648
  app: this.server.app,
665
649
  secure: this.settings.secure,
666
650
  accessLifetime: this.settings.ttl,
667
- refreshLifetime: this.settings.ttl,
651
+ refreshLifetime: 60 * 60 * 24 * 7, // 1 week, same as upstream admin
668
652
  noBasicAuth: this.settings.noBasicAuth,
669
653
  loginPage: (req) => {
670
654
  const isDev = req.url.includes('?dev');
@@ -676,44 +660,44 @@ class Web {
676
660
  },
677
661
  });
678
662
  this.server.app.get('/session', (req, res) => {
679
- res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
680
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
681
- if (!this.settings.auth) {
682
- res.json({ expireInSec: Math.max(120, this.settings.ttl || 3600), hardLogout: true, auth: false });
683
- return;
684
- }
685
- const tokenValue = this.readAccessTokenFromRequest(req);
686
- if (!tokenValue) {
687
- res.json({ expireInSec: 0, hardLogout: true, error: 'Cannot find session token' });
688
- return;
689
- }
690
- const candidates = new Set();
691
- candidates.add(tokenValue);
692
- candidates.add(tokenValue.startsWith('a:') ? tokenValue : `a:${tokenValue}`);
693
- if (tokenValue.length > 1) candidates.add(`a:${tokenValue[1]}`);
694
- const ids = Array.from(candidates);
695
- const readNext = (index) => {
696
- const id = ids[index];
697
- if (!id) {
698
- res.json({ expireInSec: 0, hardLogout: true, error: 'Cannot find session' });
663
+ // v36: Follow upstream admin semantics again. Do not run a second
664
+ // EOS hard-logout timer here; the official OAuth/session handling
665
+ // already uses the configured access lifetime.
666
+ if (req.headers.cookie) {
667
+ const cookies = req.headers.cookie.split(';').find(c => c.trim().startsWith('access_token='));
668
+ let tokenCookie = cookies?.split('=')[1];
669
+ if (!tokenCookie && req.headers.authorization?.startsWith('Bearer ')) {
670
+ tokenCookie = req.headers.authorization.split(' ')[1];
671
+ }
672
+ else if (!tokenCookie && req.query?.token) {
673
+ tokenCookie = req.query.token;
674
+ }
675
+ if (tokenCookie) {
676
+ const candidates = new Set();
677
+ candidates.add(tokenCookie.startsWith('a:') ? tokenCookie : `a:${tokenCookie}`);
678
+ if (tokenCookie.length > 1)
679
+ candidates.add(`a:${tokenCookie[1]}`);
680
+ const ids = Array.from(candidates);
681
+ const readNext = (index) => {
682
+ const id = ids[index];
683
+ if (!id) {
684
+ res.json({ expireInSec: 0 });
685
+ return;
686
+ }
687
+ void this.adapter.getSession(id, (token) => {
688
+ if (!token?.user) {
689
+ readNext(index + 1);
690
+ }
691
+ else {
692
+ res.json({ expireInSec: Math.round((token.aExp - Date.now()) / 1000) });
693
+ }
694
+ });
695
+ };
696
+ readNext(0);
699
697
  return;
700
698
  }
701
- void this.adapter.getSession(id, (token) => {
702
- if (!token?.user) {
703
- readNext(index + 1);
704
- return;
705
- }
706
- const now = Date.now();
707
- const expirations = [Number(token.aExp), Number(token.rExp), Number(token.expire), Number(token.expires), Number(token.expiresAt)].filter(value => Number.isFinite(value) && value > 0);
708
- const expiresAt = expirations.length ? Math.min(...expirations) : now + (this.settings.ttl || 3600) * 1000;
709
- res.json({
710
- expireInSec: Math.max(0, Math.floor((expiresAt - now) / 1000)),
711
- hardLogout: true,
712
- user: token.user,
713
- });
714
- });
715
- };
716
- readNext(0);
699
+ }
700
+ res.json({ error: 'Cannot find session' });
717
701
  });
718
702
  this.server.app.get(/.*\/nexowatt\/security\/(?:context|session)$/, (req, res) => {
719
703
  void this.sendEosSecuritySession(req, res).catch(e => {
package/build/main.js CHANGED
@@ -737,7 +737,8 @@ class Admin extends adapter_core_1.Adapter {
737
737
  return this.config.eosHideLegacyAdminFromNonAdmins !== false && this.config.eosHideLegacyAdminForNonAdmins !== false && this.config.nexowattHideLegacyAdminForNonAdmins !== false;
738
738
  }
739
739
  shouldApplyAdminOnlyAclToProtectedAdapters() {
740
- return this.config.eosApplyAdminOnlyAclToProtectedAdapters !== false;
740
+ const config = this.config;
741
+ return config.eosStrictProtectedAdapterAcl === true && this.config.eosApplyAdminOnlyAclToProtectedAdapters === true;
741
742
  }
742
743
  getAdminOnlyGroup() {
743
744
  const configuredGroups = normalizeAdminOnlyGroups(this.config.eosAdminOnlyGroups || this.config.eosSecurityAdminGroups);
@@ -837,8 +838,8 @@ class Admin extends adapter_core_1.Adapter {
837
838
  obj.common.nondeletable = false;
838
839
  changed = true;
839
840
  }
841
+ const targetAcl = this.getAdminOnlyAcl();
840
842
  if (options.adminOnlyAcl) {
841
- const targetAcl = this.getAdminOnlyAcl();
842
843
  const acl = (obj.acl ||= {});
843
844
  if (acl.owner !== targetAcl.owner) {
844
845
  acl.owner = targetAcl.owner;
@@ -853,6 +854,10 @@ class Admin extends adapter_core_1.Adapter {
853
854
  changed = true;
854
855
  }
855
856
  }
857
+ else if (obj.acl && obj.acl.owner === targetAcl.owner && obj.acl.ownerGroup === targetAcl.ownerGroup && obj.acl.object === targetAcl.object) {
858
+ delete obj.acl;
859
+ changed = true;
860
+ }
856
861
  if (changed) {
857
862
  await this.setForeignObjectAsync(id, obj);
858
863
  }
@@ -863,7 +868,12 @@ class Admin extends adapter_core_1.Adapter {
863
868
  return;
864
869
  }
865
870
  const isEosAdmin = adapter === EOS_ADMIN_ADAPTER_NAME;
866
- const adminOnlyAcl = isEosAdmin || this.shouldApplyAdminOnlyAclToProtectedAdapters();
871
+ const adminOnlyAcl = isEosAdmin || (this.shouldApplyAdminOnlyAclToProtectedAdapters() && adapter !== 'backitup');
872
+ // v37 BackItUp/runtime-adapter compatibility: keep default protection UI/role based.
873
+ // Do not rewrite common/ACL of runtime adapters unless explicitly configured.
874
+ if (!isEosAdmin && !adminOnlyAcl) {
875
+ return;
876
+ }
867
877
  let changed = await this.ensureObjectProtectionPolicy(`system.adapter.${adapter}`, {
868
878
  keepDontDelete: false,
869
879
  adminOnlyAcl,
@@ -0,0 +1,13 @@
1
+ # NexoWatt EOS Admin v37
2
+
3
+ ## Fokus
4
+
5
+ Diese Version stabilisiert drei produktive Bereiche:
6
+
7
+ - Benachrichtigungsdialoge bleiben vollständig klickbar und können sauber geschlossen bzw. bestätigt werden.
8
+ - BackItUp und andere Runtime-Adapter werden vom EOS-Sicherheitswächter standardmäßig nicht mehr über Objekt-/ACL-Umschreibungen verändert. Der Schutz läuft über EOS Rollen/UI.
9
+ - EOS-Sicherheitstexte werden auch auf Adapter-Konfigurationsseiten gegen UTF-8/Latin1-Mojibake repariert.
10
+
11
+ ## Hinweise
12
+
13
+ BackItUp-Fehler wie `cannot found source "undefined" for compress` kommen typischerweise aus der BackItUp-Quell-/Zielkonfiguration. v37 sorgt dafür, dass EOS Admin BackItUp nicht durch ACL/common-Änderungen beeinflusst. Die BackItUp-Konfiguration selbst muss weiterhin im BackItUp-Adapter geprüft werden.