iobroker.eos-admin 7.9.89 → 7.9.92

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 (59) hide show
  1. package/INSTALL_TEST_V7.9.92.md +18 -0
  2. package/NEXOWATT_EOS_BUILD_INFO.json +19 -25
  3. package/PUBLISH_STABLE_V7.9.92.md +19 -0
  4. package/README_STABILITY_V7.9.92.md +14 -0
  5. package/RELEASE_ACCEPTANCE_V7.9.92.md +12 -0
  6. package/RELEASE_NOTES_V7.9.92.md +17 -0
  7. package/adminWww/assets/Instances-YdaGnS5a-v84.js +1 -1
  8. package/adminWww/css/nexowatt-native-shell.css +151 -12
  9. package/adminWww/index.html +11 -11
  10. package/adminWww/js/eos-account-management.js +1 -1
  11. package/adminWww/js/eos-assistant.js +1 -1
  12. package/adminWww/js/eos-basic-settings.js +1 -1
  13. package/adminWww/js/eos-branding-sanitizer.js +1 -1
  14. package/adminWww/js/eos-native-security.js +1 -1
  15. package/adminWww/js/eos-policy-client.js +1 -1
  16. package/adminWww/js/eos-role-bootstrap.js +180 -46
  17. package/adminWww/js/eos-role-ui.js +156 -23
  18. package/adminWww/js/nexowatt-native-shell.js +1 -1
  19. package/build/lib/web.js +39 -0
  20. package/io-package.json +15 -5
  21. package/package.json +21 -13
  22. package/tools/nexowatt-account-management-selftest.cjs +1 -1
  23. package/tools/nexowatt-ensure-release-defaults.cjs +72 -0
  24. package/tools/nexowatt-internal-reserve-selftest.cjs +1 -0
  25. package/tools/nexowatt-login-layout-selftest.cjs +81 -0
  26. package/tools/nexowatt-modern-ui-selftest.cjs +7 -5
  27. package/tools/nexowatt-release-defaults-selftest.cjs +29 -0
  28. package/tools/nexowatt-validate-package.cjs +18 -8
  29. package/INSTALL_TEST_V7.9.89.md +0 -73
  30. package/PUBLISH_STABLE_V7.9.89.md +0 -33
  31. package/README_STABILITY_V7.9.89.md +0 -26
  32. package/RELEASE_ACCEPTANCE_V7.9.89.md +0 -40
  33. package/admin/img/eos/nexowatt-192-maskable.png +0 -0
  34. package/admin/img/eos/nexowatt-24.png +0 -0
  35. package/admin/img/eos/nexowatt-512-maskable.png +0 -0
  36. package/admin/img/eos/nexowatt-eos-logo.png +0 -0
  37. package/adminWww/admin.png +0 -0
  38. package/adminWww/img/eos/nexowatt-24.png +0 -0
  39. package/adminWww/img/eos/nexowatt-eos-logo.png +0 -0
  40. package/adminWww/img/eos/nexowatt-header-128.png +0 -0
  41. package/adminWww/img/eos/nexowatt-header-192.png +0 -0
  42. package/adminWww/img/eos/nexowatt-header-256.png +0 -0
  43. package/adminWww/img/eos/nexowatt-header-512.png +0 -0
  44. package/adminWww/img/eos/nexowatt-header-64.png +0 -0
  45. package/adminWww/img/logo192.png +0 -0
  46. package/adminWww/js/eos-adapter-surface-cleanup.js +0 -141
  47. package/adminWww/js/eos-toolbar-ghost-cleanup.js +0 -102
  48. package/docs/NEXOWATT_EOS_BRANDING_DE.md +0 -29
  49. package/docs/NEXOWATT_EOS_UI_LOGO_LOGIN_V6_DE.md +0 -9
  50. package/docs/NEXOWATT_EOS_UI_PERFORMANCE_V5_DE.md +0 -25
  51. package/docs/NEXOWATT_EOS_UI_V12_HEADER_LOGO_NAV_CLEANUP_DE.md +0 -16
  52. package/docs/NEXOWATT_EOS_UI_V12_HEADER_NAV_LOGO_FIX_DE.md +0 -16
  53. package/docs/NEXOWATT_EOS_UI_V15_DE.md +0 -8
  54. package/docs/NEXOWATT_EOS_UI_V16_DE.md +0 -9
  55. package/docs/NEXOWATT_EOS_UI_V17_DE.md +0 -24
  56. package/docs/NEXOWATT_EOS_UI_V6_FIXES_DE.md +0 -13
  57. package/docs/NEXOWATT_EOS_UI_V6_LOGIN_LOGO_404_FIX_DE.md +0 -9
  58. package/docs/NEXOWATT_EOS_UI_V6_LOGO_LOGIN_FIX_DE.md +0 -10
  59. package/docs/NEXOWATT_EOS_UI_V7_RESTORE_LOGO_SCOPE_FIX_DE.md +0 -18
@@ -1,7 +1,7 @@
1
1
  (() => {
2
2
  'use strict';
3
3
 
4
- const VERSION = 'v89-integrated-first-login-bootstrap';
4
+ const VERSION = 'v91-final-first-login-submit-guard';
5
5
  const script = document.currentScript || document.querySelector('script[src*="eos-role-bootstrap.js"]');
6
6
  const entry = script?.dataset?.eosEntry || '';
7
7
  let launched = false;
@@ -85,7 +85,7 @@
85
85
  claimLauncher: 'Erstanmeldung', claimTitle: 'Konto erstmals aktivieren',
86
86
  claimIntro: 'Wähle Installateur oder Gast/Endkunde und melde dich bei der allerersten Anmeldung einmalig mit leerem Passwort an. Danach muss sofort ein persönliches Passwort vergeben werden.',
87
87
  claimAccount: 'Kontoname', claimContinue: 'Erstanmeldung starten', claimChecking: 'Konto wird geprüft …',
88
- claimHint: 'Bei der ersten Anmeldung das Passwortfeld leer lassen.', claimUnavailable: 'Die Erstanmeldung ist für dieses Konto nicht verfügbar oder wurde bereits abgeschlossen.',
88
+ claimHint: 'Bei der ersten Anmeldung das Passwortfeld leer lassen.', claimAlreadyActive: 'Für dieses Konto ist bereits ein persönliches Passwort gesetzt. Bitte Passwort eingeben.', claimStatusError: 'Der Status der Erstanmeldung konnte nicht geprüft werden.', claimUnavailable: 'Die Erstanmeldung ist für dieses Konto nicht verfügbar oder wurde bereits abgeschlossen.',
89
89
  claimPrivate: 'Die Erstanmeldung ist nur im lokalen Netzwerk möglich.', claimExpired: 'Die Aktivierung ist abgelaufen. Bitte erneut starten.',
90
90
  claimReady: 'Konto bestätigt. Lege jetzt dein persönliches Passwort fest.', claimSuccess: 'Passwort gespeichert. Du kannst dich jetzt normal anmelden.',
91
91
  claimLogin: 'Zur normalen Anmeldung', claimBack: 'Zurück',
@@ -104,7 +104,7 @@
104
104
  claimLauncher: 'First sign-in', claimTitle: 'Activate account for the first time',
105
105
  claimIntro: 'Choose Installer or Guest/End User and leave the password field empty for the very first sign-in. A personal password must be created immediately afterwards.',
106
106
  claimAccount: 'Account name', claimContinue: 'Start first sign-in', claimChecking: 'Checking account …',
107
- claimHint: 'Leave the password field empty only for the first sign-in.', claimUnavailable: 'First activation is unavailable for this account or has already been completed.',
107
+ claimHint: 'Leave the password field empty only for the first sign-in.', claimAlreadyActive: 'This account already has a personal password. Enter the password to sign in.', claimStatusError: 'The first-activation status could not be checked.', claimUnavailable: 'First activation is unavailable for this account or has already been completed.',
108
108
  claimPrivate: 'First activation is only available on the local network.', claimExpired: 'The activation has expired. Please start again.',
109
109
  claimReady: 'Account confirmed. Create your personal password now.', claimSuccess: 'Password saved. You can now sign in normally.',
110
110
  claimLogin: 'Return to normal sign-in', claimBack: 'Back',
@@ -123,7 +123,7 @@
123
123
  claimLauncher: 'Eerste aanmelding', claimTitle: 'Account voor het eerst activeren',
124
124
  claimIntro: 'Kies Installateur of Gast/Eindgebruiker en laat bij de allereerste aanmelding het wachtwoordveld leeg. Daarna moet direct een persoonlijk wachtwoord worden ingesteld.',
125
125
  claimAccount: 'Accountnaam', claimContinue: 'Eerste aanmelding starten', claimChecking: 'Account wordt gecontroleerd …',
126
- claimHint: 'Laat het wachtwoordveld alleen bij de eerste aanmelding leeg.', claimUnavailable: 'Eerste activering is voor dit account niet beschikbaar of al voltooid.',
126
+ claimHint: 'Laat het wachtwoordveld alleen bij de eerste aanmelding leeg.', claimAlreadyActive: 'Dit account heeft al een persoonlijk wachtwoord. Voer het wachtwoord in.', claimStatusError: 'De status van de eerste activering kon niet worden gecontroleerd.', claimUnavailable: 'Eerste activering is voor dit account niet beschikbaar of al voltooid.',
127
127
  claimPrivate: 'Eerste activering is alleen mogelijk in het lokale netwerk.', claimExpired: 'De activering is verlopen. Start opnieuw.',
128
128
  claimReady: 'Account bevestigd. Stel nu je persoonlijke wachtwoord in.', claimSuccess: 'Wachtwoord opgeslagen. Je kunt nu normaal aanmelden.',
129
129
  claimLogin: 'Naar normale aanmelding', claimBack: 'Terug',
@@ -150,6 +150,7 @@
150
150
  <p class="eos-first-login-intro"></p>
151
151
  <div class="eos-first-login-account"><span></span><strong></strong></div>
152
152
  <form novalidate>
153
+ <input name="username" type="text" autocomplete="username" hidden />
153
154
  <label><span class="password-label"></span><input name="password" type="password" autocomplete="new-password" maxlength="128" required /></label>
154
155
  <label><span class="repeat-label"></span><input name="passwordRepeat" type="password" autocomplete="new-password" maxlength="128" required /></label>
155
156
  <label class="eos-first-login-show"><input name="show" type="checkbox" /><span></span></label>
@@ -171,6 +172,7 @@
171
172
  overlay.querySelector('.eos-first-login-submit').textContent = t.submit;
172
173
  overlay.querySelector('.eos-first-login-logout').textContent = t.logout;
173
174
  const form = overlay.querySelector('form');
175
+ form.elements.username.value = claim.userName || 'guest';
174
176
  const password = form.elements.password;
175
177
  const repeat = form.elements.passwordRepeat;
176
178
  const show = form.elements.show;
@@ -289,9 +291,15 @@
289
291
 
290
292
  const installIntegratedFirstLogin = base => {
291
293
  const t = translations[language()];
292
- const managedAccounts = new Set(['installer', 'guest']);
294
+ const managedAccounts = new Set(['installer', 'guest', 'user']);
293
295
  let busy = false;
294
296
  let unsubscribeDom = null;
297
+ let statusTimer = 0;
298
+ let statusSequence = 0;
299
+ let submitObserver = null;
300
+ let observedSubmit = null;
301
+ let loginStateMonitor = 0;
302
+ const eligibility = { account: '', allowed: false, checking: false, loaded: false };
295
303
 
296
304
  document.documentElement.classList.add('eos-modern-login', 'eos-integrated-first-login');
297
305
  document.querySelectorAll('.eos-passwordless-launcher').forEach(element => element.remove());
@@ -312,35 +320,156 @@
312
320
  return { username, password, card, submit };
313
321
  };
314
322
  const statusNode = card => card?.querySelector?.('.eos-login-first-status');
323
+ const directChildWithin = (ancestor, node) => {
324
+ let current = node;
325
+ while (current && current.parentElement && current.parentElement !== ancestor) current = current.parentElement;
326
+ return current?.parentElement === ancestor ? current : null;
327
+ };
315
328
  const setStatus = (card, message, kind = '') => {
316
329
  const status = statusNode(card);
317
330
  if (!status) return;
318
331
  status.textContent = message || '';
319
332
  status.dataset.kind = kind;
320
333
  };
334
+ const setSubmitDisabled = (submit, disabled) => {
335
+ if (!submit) return;
336
+ const nextDisabled = !!disabled;
337
+ // Idempotent writes are essential here: the submit MutationObserver invokes
338
+ // updateLoginState again. Rewriting unchanged attributes would create a feedback loop.
339
+ if (submit.disabled !== nextDisabled) submit.disabled = nextDisabled;
340
+ if (submit.classList.contains('Mui-disabled') !== nextDisabled) submit.classList.toggle('Mui-disabled', nextDisabled);
341
+ const aria = nextDisabled ? 'true' : 'false';
342
+ if (submit.getAttribute('aria-disabled') !== aria) submit.setAttribute('aria-disabled', aria);
343
+ const tabIndex = nextDisabled ? -1 : 0;
344
+ if (submit.tabIndex !== tabIndex) submit.tabIndex = tabIndex;
345
+ if (nextDisabled) {
346
+ if (!submit.hasAttribute('disabled')) submit.setAttribute('disabled', '');
347
+ if (submit.hasAttribute('data-eos-passwordless-submit')) submit.removeAttribute('data-eos-passwordless-submit');
348
+ } else {
349
+ if (submit.hasAttribute('disabled')) submit.removeAttribute('disabled');
350
+ if (submit.getAttribute('data-eos-passwordless-submit') !== '1') submit.dataset.eosPasswordlessSubmit = '1';
351
+ }
352
+ };
353
+ const syncPasswordRequirement = (password, optional) => {
354
+ if (!password) return;
355
+ password.required = !optional;
356
+ password.setAttribute('aria-required', optional ? 'false' : 'true');
357
+ password.toggleAttribute('data-eos-first-login-optional', !!optional);
358
+ password.closest?.('.MuiFormControl-root')?.classList?.toggle('eos-first-login-password-optional', !!optional);
359
+ };
360
+ const eligibilityMatches = account => eligibility.loaded && eligibility.account === account;
361
+ const isEligibleBlankAccount = (account, password) => managedAccounts.has(account)
362
+ && !password?.value
363
+ && eligibilityMatches(account)
364
+ && eligibility.allowed === true;
321
365
  const updateLoginState = () => {
322
366
  const { username, password, card, submit } = loginElements();
323
- if (!username || !password || !card) return;
367
+ if (!username || !password || !card || !submit) return;
324
368
  const account = accountName(username);
325
369
  const blankManaged = managedAccounts.has(account) && !password.value;
326
- card.querySelectorAll('[data-eos-account]').forEach(button => button.classList.toggle('active', button.dataset.eosAccount === account));
327
- card.classList.toggle('eos-login-first-ready', blankManaged);
328
- if (submit && blankManaged && !busy) {
329
- submit.disabled = false;
330
- submit.removeAttribute('disabled');
331
- submit.dataset.eosPasswordlessSubmit = '1';
332
- } else if (submit) {
333
- delete submit.dataset.eosPasswordlessSubmit;
370
+ const ready = isEligibleBlankAccount(account, password) && !busy;
371
+ const checking = blankManaged && eligibility.account === account && eligibility.checking;
372
+ card.classList.toggle('eos-login-first-ready', ready);
373
+ card.classList.toggle('eos-login-first-checking', checking);
374
+ card.classList.toggle('eos-login-first-blocked', blankManaged && eligibilityMatches(account) && !eligibility.allowed);
375
+ syncPasswordRequirement(password, ready);
376
+ if (blankManaged) {
377
+ setSubmitDisabled(submit, !ready);
378
+ } else {
379
+ setSubmitDisabled(submit, busy || !account || !password.value);
380
+ if (!password.value) submit.removeAttribute('data-eos-passwordless-submit');
334
381
  }
335
382
  const hint = card.querySelector('.eos-login-first-hint');
336
- if (hint) hint.textContent = blankManaged ? t.claimIntro : t.claimHint;
383
+ if (hint) {
384
+ if (checking) hint.textContent = t.claimChecking;
385
+ else if (ready) hint.textContent = t.claimIntro;
386
+ else if (blankManaged && eligibilityMatches(account)) hint.textContent = t.claimAlreadyActive;
387
+ else hint.textContent = t.claimHint;
388
+ }
389
+ };
390
+ const ensureSubmitObserver = submit => {
391
+ if (!submit || observedSubmit === submit) return;
392
+ submitObserver?.disconnect();
393
+ observedSubmit = submit;
394
+ submitObserver = new MutationObserver(() => queueMicrotask(updateLoginState));
395
+ submitObserver.observe(submit, {
396
+ attributes: true,
397
+ attributeFilter: ['disabled', 'class', 'aria-disabled', 'tabindex'],
398
+ });
399
+ };
400
+ const requestEligibility = async account => {
401
+ const normalized = String(account || '').trim().toLowerCase().replace(/^system\.user\./, '');
402
+ const sequence = ++statusSequence;
403
+ eligibility.account = normalized;
404
+ eligibility.allowed = false;
405
+ eligibility.loaded = false;
406
+ eligibility.checking = managedAccounts.has(normalized);
407
+ updateLoginState();
408
+ if (!managedAccounts.has(normalized)) {
409
+ eligibility.checking = false;
410
+ eligibility.loaded = true;
411
+ updateLoginState();
412
+ return false;
413
+ }
414
+ try {
415
+ const response = await fetch(new URL('nexowatt/account/passwordless-status', base).href, {
416
+ method: 'POST',
417
+ credentials: 'same-origin',
418
+ cache: 'no-store',
419
+ headers: {
420
+ 'Content-Type': 'application/json',
421
+ Accept: 'application/json',
422
+ 'X-NexoWatt-EOS-Passwordless-Status': '1',
423
+ },
424
+ body: JSON.stringify({ user: normalized }),
425
+ });
426
+ const data = await response.json().catch(() => ({}));
427
+ if (sequence !== statusSequence) return false;
428
+ eligibility.allowed = response.ok && data?.eligible === true;
429
+ eligibility.loaded = true;
430
+ eligibility.checking = false;
431
+ const { card } = loginElements();
432
+ setStatus(card, response.ok ? '' : t.claimStatusError, response.ok ? '' : 'error');
433
+ updateLoginState();
434
+ return eligibility.allowed;
435
+ } catch (_) {
436
+ if (sequence !== statusSequence) return false;
437
+ eligibility.allowed = false;
438
+ eligibility.loaded = true;
439
+ eligibility.checking = false;
440
+ const { card } = loginElements();
441
+ setStatus(card, t.claimStatusError, 'error');
442
+ updateLoginState();
443
+ return false;
444
+ }
445
+ };
446
+ const scheduleEligibilityCheck = () => {
447
+ window.clearTimeout(statusTimer);
448
+ statusTimer = window.setTimeout(() => {
449
+ const { username, password } = loginElements();
450
+ const account = accountName(username);
451
+ if (password?.value || !managedAccounts.has(account)) {
452
+ statusSequence += 1;
453
+ eligibility.account = account;
454
+ eligibility.allowed = false;
455
+ eligibility.checking = false;
456
+ eligibility.loaded = true;
457
+ updateLoginState();
458
+ return;
459
+ }
460
+ void requestEligibility(account);
461
+ }, 120);
337
462
  };
338
463
  const startClaim = async requestedAccount => {
339
464
  const { username, password, card, submit } = loginElements();
340
- const account = String(requestedAccount || accountName(username)).trim().toLowerCase();
465
+ const account = String(requestedAccount || accountName(username)).trim().toLowerCase().replace(/^system\.user\./, '');
341
466
  if (!managedAccounts.has(account) || password?.value || busy) return false;
467
+ if (!eligibilityMatches(account) || !eligibility.allowed) {
468
+ const allowed = await requestEligibility(account);
469
+ if (!allowed) return false;
470
+ }
342
471
  busy = true;
343
- if (submit) submit.disabled = true;
472
+ if (submit) setSubmitDisabled(submit, true);
344
473
  setStatus(card, t.claimChecking, '');
345
474
  try {
346
475
  const response = await fetch(new URL('nexowatt/account/passwordless-claim', base).href, {
@@ -357,6 +486,8 @@
357
486
  showPasswordlessClaimPassword(data, base);
358
487
  return true;
359
488
  } catch (error) {
489
+ eligibility.allowed = false;
490
+ eligibility.loaded = true;
360
491
  setStatus(card, error?.message || t.claimUnavailable, 'error');
361
492
  return false;
362
493
  } finally {
@@ -366,38 +497,36 @@
366
497
  };
367
498
  const ensureIntegratedLogin = () => {
368
499
  document.querySelectorAll('.eos-passwordless-launcher').forEach(element => element.remove());
369
- const { username, password, card } = loginElements();
500
+ const { username, password, card, submit } = loginElements();
370
501
  if (!username || !password || !card) return;
371
502
  card.classList.add('eos-login-card-modern');
372
- let selector = card.querySelector('.eos-login-role-selector');
373
- if (!selector) {
374
- selector = document.createElement('section');
375
- selector.className = 'eos-login-role-selector';
376
- selector.setAttribute('aria-label', t.claimAccount);
377
- selector.innerHTML = `
378
- <div class="eos-login-role-heading"><strong>${t.claimAccount}</strong><span>${t.claimHint}</span></div>
379
- <div class="eos-login-role-buttons">
380
- <button type="button" data-eos-account="admin"><span>◆</span><strong>Admin / Service</strong></button>
381
- <button type="button" data-eos-account="installer"><span>⚙</span><strong>Installateur</strong></button>
382
- <button type="button" data-eos-account="guest"><span>⌂</span><strong>Gast / Endkunde</strong></button>
383
- </div>
384
- <p class="eos-login-first-hint"></p>
385
- <div class="eos-login-first-status" aria-live="polite"></div>`;
386
- const usernameControl = username.closest('.MuiFormControl-root') || username.parentElement;
387
- card.insertBefore(selector, usernameControl || card.firstChild);
388
- selector.querySelectorAll('[data-eos-account]').forEach(button => button.addEventListener('click', () => {
389
- setInputValue(username, button.dataset.eosAccount || '');
390
- setInputValue(password, '');
391
- window.setTimeout(() => password.focus(), 0);
392
- updateLoginState();
393
- }));
394
- }
503
+ // v91 compact normal-login first activation: do not add role buttons or a second panel.
504
+ // The account is entered in the existing Loginname field; a blank password is accepted only
505
+ // after the backend confirms that this exact managed account is eligible.
506
+ card.querySelectorAll('.eos-login-role-selector').forEach(element => element.remove());
507
+ card.dataset.eosCompactFirstLogin = '1';
395
508
  if (!username.dataset.eosFirstLoginBound) {
396
509
  username.dataset.eosFirstLoginBound = '1';
397
- username.addEventListener('input', updateLoginState);
398
- password.addEventListener('input', updateLoginState);
510
+ username.addEventListener('input', scheduleEligibilityCheck);
511
+ username.addEventListener('change', scheduleEligibilityCheck);
512
+ password.addEventListener('input', scheduleEligibilityCheck);
513
+ password.addEventListener('change', scheduleEligibilityCheck);
399
514
  }
515
+ ensureSubmitObserver(submit);
400
516
  updateLoginState();
517
+ scheduleEligibilityCheck();
518
+ };
519
+ const startLoginStateMonitor = () => {
520
+ if (loginStateMonitor) return;
521
+ loginStateMonitor = window.setInterval(() => {
522
+ const { username, password, submit } = loginElements();
523
+ if (!username || !password || !submit) {
524
+ ensureIntegratedLogin();
525
+ return;
526
+ }
527
+ ensureSubmitObserver(submit);
528
+ updateLoginState();
529
+ }, 350);
401
530
  };
402
531
 
403
532
  document.addEventListener('click', event => {
@@ -407,7 +536,8 @@
407
536
  if (!managedAccounts.has(account) || password?.value) return;
408
537
  event.preventDefault();
409
538
  event.stopImmediatePropagation();
410
- void startClaim(account);
539
+ if (isEligibleBlankAccount(account, password)) void startClaim(account);
540
+ else void requestEligibility(account);
411
541
  }, { capture: true });
412
542
  document.addEventListener('keydown', event => {
413
543
  if (event.key !== 'Enter') return;
@@ -417,7 +547,7 @@
417
547
  if (!managedAccounts.has(account) || password?.value) return;
418
548
  event.preventDefault();
419
549
  event.stopImmediatePropagation();
420
- void startClaim(account);
550
+ if (isEligibleBlankAccount(account, password)) void startClaim(account);
421
551
  }, { capture: true });
422
552
 
423
553
  const connectCoordinator = () => {
@@ -429,12 +559,16 @@
429
559
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureIntegratedLogin, { once: true });
430
560
  else ensureIntegratedLogin();
431
561
  connectCoordinator();
432
- [150, 350, 750, 1400, 2600].forEach(delay => window.setTimeout(ensureIntegratedLogin, delay));
562
+ startLoginStateMonitor();
563
+ window.addEventListener('focus', updateLoginState);
564
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) updateLoginState(); });
565
+ [100, 250, 500, 900, 1500, 2600, 5000].forEach(delay => window.setTimeout(ensureIntegratedLogin, delay));
433
566
 
434
567
  window.NEXOWATT_EOS_FIRST_LOGIN = Object.freeze({
435
568
  version: VERSION,
436
569
  start: account => startClaim(account),
437
570
  refresh: ensureIntegratedLogin,
571
+ check: account => requestEligibility(account),
438
572
  });
439
573
  };
440
574
 
@@ -1,7 +1,7 @@
1
1
  (() => {
2
2
  'use strict';
3
3
 
4
- const VERSION = 'v89-role-safe-overview-and-reserve-visibility';
4
+ const VERSION = 'v91-final-clickable-overview-live-reserve-filter';
5
5
  window.NEXOWATT_EOS_ROLE_UI_VERSION = VERSION;
6
6
 
7
7
  const state = {
@@ -12,6 +12,9 @@
12
12
  lastTarget: '',
13
13
  unsubscribePolicy: null,
14
14
  unsubscribeDom: null,
15
+ reserveObserver: null,
16
+ reservePaper: null,
17
+ reserveScheduled: false,
15
18
  };
16
19
  const abort = new AbortController();
17
20
 
@@ -94,13 +97,23 @@
94
97
  });
95
98
 
96
99
  const textOf = el => [el?.textContent || '', ...['href','title','aria-label','data-name','data-tab','data-id','data-instance','id'].map(a => el?.getAttribute?.(a) || '')].join(' ');
97
- const navEntries = () => Array.from(document.querySelectorAll('a[href*="#tab-"],a[href*="#/tab-"],a[href="#easy"],a[href="/#easy"],a[href$="/#easy"]')).map(anchor => {
98
- const href = anchor.getAttribute('href') || anchor.href || '';
99
- const tab = /#\/?easy(?:[/?&]|$)/i.test(href) ? 'easy' : tabFromHref(href);
100
- if (!tab) return null;
101
- const root = anchor.closest('.MuiListItem-root,.MuiButtonBase-root,li,[role="button"],[class*="DrawerItem"],[class*="dragWrapper"]') || anchor;
102
- return { anchor, root, tab, label: textOf(root) || textOf(anchor) };
103
- }).filter(Boolean);
100
+ const navEntries = () => {
101
+ const candidates = Array.from(document.querySelectorAll(
102
+ '[data-eos-tab],a[href*="#tab-"],a[href*="#/tab-"],a[href="#easy"],a[href="/#easy"],a[href$="/#easy"]',
103
+ ));
104
+ const seen = new Set();
105
+ return candidates.map(control => {
106
+ const href = control.getAttribute?.('href') || control.href || '';
107
+ const declared = normalize(control.getAttribute?.('data-eos-tab') || '');
108
+ const tab = declared || (/^#\/?easy(?:[/?&]|$)/i.test(href) ? 'easy' : tabFromHref(href));
109
+ if (!tab) return null;
110
+ const root = control.closest?.('.MuiListItem-root,.MuiButtonBase-root,li,[role="button"],[class*="DrawerItem"],[class*="dragWrapper"]') || control;
111
+ const key = `${tab}|${textOf(root)}`;
112
+ if (seen.has(key)) return null;
113
+ seen.add(key);
114
+ return { anchor: control, root, tab, label: textOf(root) || textOf(control) };
115
+ }).filter(Boolean);
116
+ };
104
117
  const firstAllowedTab = role => navEntries().find(entry => entry.tab === 'tab-intro' && isRouteAllowed(role, entry.tab, entry.label))?.tab
105
118
  || navEntries().find(entry => isRouteAllowed(role, entry.tab, entry.label))?.tab || defaultTab(role);
106
119
 
@@ -172,6 +185,28 @@
172
185
  if (window.location.hash !== targetHash) window.location.hash = targetHash;
173
186
  window.dispatchEvent(new CustomEvent('eos-role-navigate', { detail: { tab } }));
174
187
  };
188
+ const navigateToTab = (tab, label = '') => {
189
+ if (!tab) return false;
190
+ if (tab === 'basic-settings') {
191
+ window.NEXOWATT_EOS_BASIC_SETTINGS?.open?.();
192
+ return true;
193
+ }
194
+ if (!isRouteAllowed(state.role, tab, label)) return false;
195
+ const entry = navEntries().find(candidate =>
196
+ candidate.tab === tab
197
+ && isRouteAllowed(state.role, candidate.tab, candidate.label)
198
+ && !candidate.anchor.closest?.('#eos-role-safe-overview'),
199
+ );
200
+ if (entry?.anchor) {
201
+ entry.anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
202
+ window.setTimeout(() => {
203
+ if (currentRoute() !== tab) setHashTab(tab);
204
+ }, 80);
205
+ return true;
206
+ }
207
+ setHashTab(tab);
208
+ return true;
209
+ };
175
210
  const redirectIfNeeded = () => safe(() => {
176
211
  if (isLoginView() || state.role === 'unknown') return;
177
212
  const route = currentRoute();
@@ -240,29 +275,117 @@
240
275
  <div class="eos-overview-role"><span class="eos-overview-status-dot"></span>${installer ? 'Installateur' : 'Gast / Endkunde'}</div>
241
276
  </header>
242
277
  <div class="eos-overview-grid">${actions.map(([label, description, tab, icon]) => `
243
- <button type="button" class="eos-overview-action" data-eos-role-tab="${tab}">
278
+ <a class="eos-overview-action" data-eos-role-tab="${tab}" href="${tab === 'basic-settings' ? '#tab-intro' : `#${tab}`}">
244
279
  <span class="eos-overview-action-icon" aria-hidden="true">${iconFor(icon)}</span>
245
280
  <span><strong>${label}</strong><small>${description}</small></span><i aria-hidden="true">›</i>
246
- </button>`).join('')}</div>`;
281
+ </a>`).join('')}</div>`;
282
+ overview.querySelectorAll('a[data-eos-role-tab]').forEach(link => {
283
+ link.addEventListener('click', event => {
284
+ event.preventDefault();
285
+ event.stopPropagation();
286
+ const targetTab = link.getAttribute('data-eos-role-tab') || '';
287
+ if (targetTab) navigateToTab(targetTab, link.textContent || '');
288
+ });
289
+ link.addEventListener('keydown', event => {
290
+ if (event.key !== 'Enter' && event.key !== ' ') return;
291
+ event.preventDefault();
292
+ navigateToTab(link.getAttribute('data-eos-role-tab') || '', link.textContent || '');
293
+ });
294
+ });
247
295
  });
248
296
 
249
297
  const hideOfficialReserveSurfaces = () => safe(() => {
250
- if (state.role === 'admin') return;
251
298
  const paper = appPaper();
252
299
  if (!paper) return;
253
- const selectors = [
300
+ const rowSelector = [
301
+ 'tr', '.MuiTableRow-root', '.MuiAccordion-root', '.MuiCard-root', '.MuiListItem-root',
302
+ '[role="row"]', '[data-instance-row]', '[data-testid="adapter-card"]', '[data-testid="instance-card"]',
303
+ ].join(',');
304
+ const reserveId = /^(?:system\.adapter\.)?(?:admin|backitup)\.\d+$/i;
305
+ const normalizeInstance = value => String(value || '').trim().replace(/^system\.adapter\./i, '');
306
+ const identityValues = node => {
307
+ if (!node) return [];
308
+ const attrs = ['data-id', 'data-instance', 'data-name', 'data-adapter', 'data-instance-id', 'id'];
309
+ const values = attrs.map(name => node.getAttribute?.(name) || '');
310
+ node.querySelectorAll?.('[data-id],[data-instance],[data-name],[data-adapter],[data-instance-id]').forEach(child => {
311
+ attrs.forEach(name => values.push(child.getAttribute?.(name) || ''));
312
+ });
313
+ return values.map(normalizeInstance).filter(Boolean);
314
+ };
315
+ const rowTextContainsReserve = row => {
316
+ const text = String(row?.textContent || '')
317
+ .replace(/eos-admin\.\d+/gi, '')
318
+ .replace(/nexowatt-backup\.\d+/gi, '')
319
+ .replace(/eos-backup\.\d+/gi, '')
320
+ .replace(/system\.adapter\.(?:eos-admin|nexowatt-backup|eos-backup)\.\d+/gi, '');
321
+ return /(?:^|[\s|,;])(?:system\.adapter\.)?(?:admin|backitup)\.\d+(?=$|[\s|,;])/i.test(text.trim());
322
+ };
323
+ const rowIsReserve = row => identityValues(row).some(value => reserveId.test(value)) || rowTextContainsReserve(row);
324
+ const setHidden = (row, hidden) => {
325
+ if (!row || row === paper || row.id === 'app-paper' || row.classList?.contains('eos-role-safe-overview')) return;
326
+ row.classList.toggle('eos-role-hidden', hidden);
327
+ row.classList.toggle('eos-official-reserve-hidden', hidden);
328
+ if (hidden) {
329
+ row.setAttribute('aria-hidden', 'true');
330
+ row.setAttribute('data-eos-official-reserve-hidden', '1');
331
+ } else {
332
+ row.removeAttribute('aria-hidden');
333
+ row.removeAttribute('data-eos-official-reserve-hidden');
334
+ }
335
+ };
336
+ const rowFor = node => node?.matches?.(rowSelector) ? node : node?.closest?.(rowSelector);
337
+
338
+ // Reused virtual rows must be unhidden when their identity changes.
339
+ paper.querySelectorAll('[data-eos-official-reserve-hidden="1"]').forEach(node => {
340
+ const row = rowFor(node) || node;
341
+ if (state.role === 'admin' || !rowIsReserve(row)) setHidden(row, false);
342
+ });
343
+ if (state.role === 'admin') return;
344
+
345
+ paper.querySelectorAll(rowSelector).forEach(row => setHidden(row, rowIsReserve(row)));
346
+ paper.querySelectorAll([
254
347
  '[data-id^="system.adapter.admin"]','[data-id^="system.adapter.backitup"]',
255
348
  '[data-instance^="admin."]','[data-instance^="backitup."]',
349
+ '[data-instance-id^="admin."]','[data-instance-id^="backitup."]',
256
350
  '[data-name="admin"]','[data-name="backitup"]',
257
351
  'a[href*="#tab-admin"]','a[href*="#tab-backitup"]',
258
352
  'a[href*="system.adapter.admin"]','a[href*="system.adapter.backitup"]',
259
- ];
260
- paper.querySelectorAll(selectors.join(',')).forEach(node => {
261
- const root = node.closest('tr,.MuiTableRow-root,.MuiCard-root,.MuiPaper-root,li,[role="row"],[data-testid="adapter-card"]') || node;
262
- if (root.id === 'app-paper' || root.classList.contains('eos-role-safe-overview')) return;
263
- root.classList.add('eos-role-hidden', 'eos-official-reserve-hidden');
264
- root.setAttribute('aria-hidden', 'true');
353
+ ].join(',')).forEach(node => setHidden(rowFor(node) || node, true));
354
+
355
+ // Admin 7 table cells may have no metadata. Match only an exact visible instance ID and
356
+ // hide the complete Accordion/Card/Table row, never the NexoWatt replacements.
357
+ const walker = document.createTreeWalker(paper, NodeFilter.SHOW_TEXT);
358
+ let textNode;
359
+ while ((textNode = walker.nextNode())) {
360
+ const value = String(textNode.textContent || '').trim();
361
+ if (reserveId.test(value)) setHidden(rowFor(textNode.parentElement), true);
362
+ }
363
+ });
364
+
365
+ const ensureReserveObserver = () => safe(() => {
366
+ const paper = appPaper();
367
+ if (!paper || state.role === 'admin') {
368
+ state.reserveObserver?.disconnect?.();
369
+ state.reserveObserver = null;
370
+ state.reservePaper = null;
371
+ return;
372
+ }
373
+ if (state.reserveObserver && state.reservePaper === paper) return;
374
+ state.reserveObserver?.disconnect?.();
375
+ state.reservePaper = paper;
376
+ state.reserveObserver = new MutationObserver(mutations => {
377
+ if (!mutations.some(mutation => mutation.addedNodes?.length)) return;
378
+ if (state.reserveScheduled) return;
379
+ state.reserveScheduled = true;
380
+ const run = () => {
381
+ state.reserveScheduled = false;
382
+ hideOfficialReserveSurfaces();
383
+ };
384
+ if ('requestAnimationFrame' in window) requestAnimationFrame(run); else setTimeout(run, 16);
265
385
  });
386
+ // This observer is deliberately scoped to #app-paper. It catches rows created or recycled
387
+ // after the shared coordinator has already processed the high-load services view.
388
+ state.reserveObserver.observe(paper, { childList: true, subtree: true });
266
389
  });
267
390
 
268
391
  const applySensitiveDialogPolicy = () => safe(() => {
@@ -297,6 +420,7 @@
297
420
  redirectIfNeeded();
298
421
  ensureSafeOverview();
299
422
  hideOfficialReserveSurfaces();
423
+ ensureReserveObserver();
300
424
  suppressPermissionErrors();
301
425
  window.NEXOWATT_EOS_ACCOUNT_MANAGEMENT?.refreshEntry?.();
302
426
  };
@@ -325,9 +449,15 @@
325
449
  if (!coordinator?.subscribe) { setTimeout(startObserver, 200); return; }
326
450
  if (state.unsubscribeDom) return;
327
451
  state.unsubscribeDom = coordinator.subscribe(mutations => {
328
- if (isHighLoadSurface() && mutations.length && mutations.every(m => isInsideAppPaper(m.target) || isInsideAppPaper(m.addedNodes?.[0]))) return;
452
+ if (isHighLoadSurface() && mutations.length && mutations.every(m => isInsideAppPaper(m.target) || isInsideAppPaper(m.addedNodes?.[0]))) {
453
+ // Keep virtualized service/adapter tables light, but still enforce the exact internal
454
+ // reserve visibility rule whenever rows arrive after the initial render.
455
+ hideOfficialReserveSurfaces();
456
+ suppressPermissionErrors();
457
+ return;
458
+ }
329
459
  scheduleApply();
330
- });
460
+ }, { includeTableMutations: true });
331
461
  };
332
462
 
333
463
  document.addEventListener('click', event => {
@@ -341,7 +471,7 @@
341
471
  if (state.role === 'installer') window.NEXOWATT_EOS_BASIC_SETTINGS?.open?.();
342
472
  return;
343
473
  }
344
- const target = event.target?.closest?.('[data-eos-admin-only-control="1"],[data-eos-system-settings-control="1"],button[data-eos-role-tab],a[href*="#tab-"],a[href*="#/tab-"]');
474
+ const target = event.target?.closest?.('[data-eos-admin-only-control="1"],[data-eos-system-settings-control="1"],[data-eos-role-tab],a[href*="#tab-"],a[href*="#/tab-"]');
345
475
  if (!target) return;
346
476
  if (target.matches('[data-eos-admin-only-control="1"]')) { event.preventDefault(); event.stopImmediatePropagation(); return; }
347
477
  if (target.matches('[data-eos-system-settings-control="1"]') && state.role === 'installer') {
@@ -350,8 +480,8 @@
350
480
  const roleTab = target.getAttribute('data-eos-role-tab');
351
481
  if (roleTab) {
352
482
  event.preventDefault();
353
- if (roleTab === 'basic-settings') window.NEXOWATT_EOS_BASIC_SETTINGS?.open?.();
354
- else setHashTab(roleTab);
483
+ event.stopImmediatePropagation();
484
+ navigateToTab(roleTab, target.textContent || '');
355
485
  return;
356
486
  }
357
487
  const tab = tabFromHref(target.getAttribute('href') || target.href || '');
@@ -368,6 +498,9 @@
368
498
  defaultTab,
369
499
  isOfficialReserveTab,
370
500
  isCustomerBackupTab,
501
+ navigateToTab,
502
+ ensureSafeOverview,
503
+ hideOfficialReserveSurfaces,
371
504
  refresh: scheduleApply,
372
505
  });
373
506
  const start = () => {
@@ -1,7 +1,7 @@
1
1
  (() => {
2
2
  'use strict';
3
3
 
4
- const VERSION = 'v89-nexowatt-native-shell-modern-overview';
4
+ const VERSION = 'v91-nexowatt-native-shell-modern-overview';
5
5
  const previous = window.NEXOWATT_NATIVE_SHELL;
6
6
  if (previous?.version === VERSION) return;
7
7
  previous?.destroy?.();