Showing 1 changed files with 226 additions and 107 deletions
+226 -107
scripts/host_manager.pl
@@ -818,8 +818,65 @@ sub app_html {
818 818
     }
819 819
     * { box-sizing: border-box; }
820 820
     body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #eef2f6; font-size: 14px; }
821
-    header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
822
-    h1 { margin: 0; font-size: 18px; font-weight: 700; letter-spacing: 0; }
821
+
822
+    /* ── Login screen ── */
823
+    #login-screen {
824
+      display: flex;
825
+      align-items: center;
826
+      justify-content: center;
827
+      min-height: 100dvh;
828
+      padding: 24px;
829
+      background: #13182a;
830
+    }
831
+    .login-card {
832
+      background: #fff;
833
+      border-radius: 16px;
834
+      padding: 44px 36px 36px;
835
+      width: 100%;
836
+      max-width: 380px;
837
+      display: grid;
838
+      gap: 24px;
839
+      box-shadow: 0 8px 40px rgba(0,0,0,.28);
840
+    }
841
+    .login-card .brand { text-align: center; display: grid; gap: 6px; }
842
+    .login-card .brand .icon {
843
+      margin: 0 auto 4px;
844
+      width: 52px; height: 52px; border-radius: 14px;
845
+      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
846
+    }
847
+    .login-card .brand .icon svg { width: 26px; height: 26px; fill: var(--accent); }
848
+    .login-card .brand h1 { margin: 0; font-size: 22px; font-weight: 700; color: var(--ink); }
849
+    .login-card .brand p { margin: 0; color: var(--muted); font-size: 13px; }
850
+    .login-card form { display: grid; gap: 16px; }
851
+    .login-card .field-label { font-size: 13px; font-weight: 600; color: var(--ink); }
852
+    /* 6 separate OTP digit boxes */
853
+    .otp-row { display: flex; gap: 8px; justify-content: center; }
854
+    .otp-row input {
855
+      width: 48px; height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
856
+      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
857
+      background: #f8fafc; caret-color: transparent; outline: none;
858
+      transition: border-color .15s, background .15s;
859
+    }
860
+    .otp-row input:focus { border-color: var(--accent); background: #fff; }
861
+    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
862
+    .login-card button.primary {
863
+      width: 100%; border: none; background: var(--accent); color: #fff;
864
+      border-radius: 10px; padding: 13px; font: inherit; font-size: 15px;
865
+      font-weight: 600; cursor: pointer; min-height: 48px;
866
+      display: flex; align-items: center; justify-content: center; gap: 8px;
867
+    }
868
+    .login-card button.primary:hover { background: #0f52b8; }
869
+    .login-card button.primary:disabled { opacity: .55; cursor: not-allowed; }
870
+    #login-error {
871
+      color: var(--bad); font-size: 13px; text-align: center;
872
+      min-height: 18px; margin-top: -8px;
873
+    }
874
+
875
+    /* ── App shell (hidden until authenticated) ── */
876
+    #app { display: none; }
877
+    header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
878
+    h1 { margin: 0; font-size: 17px; font-weight: 700; }
879
+    .header-right { display: flex; align-items: center; gap: 10px; }
823 880
     main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
824 881
     .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
825 882
     .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
@@ -846,13 +903,10 @@ sub app_html {
846 903
     .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
847 904
     .span2 { grid-column: 1 / -1; }
848 905
     label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
849
-    .auth { display: flex; gap: 8px; align-items: center; }
850
-    .auth input { width: 130px; }
851 906
     .muted { color: var(--muted); }
852 907
     .problems { padding: 10px 14px; display: grid; gap: 8px; }
853 908
     .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
854 909
     @media (max-width: 760px) {
855
-      header { align-items: stretch; flex-direction: column; }
856 910
       .grid { grid-template-columns: 1fr; }
857 911
       table { min-width: 760px; }
858 912
       .table-wrap { overflow-x: auto; }
@@ -860,78 +914,110 @@ sub app_html {
860 914
   </style>
861 915
 </head>
862 916
 <body>
863
-  <header>
864
-    <h1>Host Manager</h1>
865
-    <form class="auth" id="login-form">
866
-      <span id="auth-state" class="muted">locked</span>
867
-      <input id="otp" name="otp" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="OTP">
868
-      <button class="primary" type="submit">Login</button>
869
-      <button type="button" id="logout">Logout</button>
870
-    </form>
871
-  </header>
872
-  <main>
873
-    <section class="toolbar">
874
-      <button id="refresh" data-auth-required>Refresh</button>
875
-      <a class="linkbtn" data-auth-required href="/download/hosts.yaml">hosts.yaml</a>
876
-      <a class="linkbtn" data-auth-required href="/download/local-hosts.tsv">local-hosts.tsv</a>
877
-      <a class="linkbtn" data-auth-required href="/download/monitoring.json">monitoring.json</a>
878
-      <button id="write-tsv" data-auth-required>Write local-hosts.tsv</button>
879
-      <span id="message" class="muted"></span>
880
-    </section>
881
-
882
-    <section class="panel">
883
-      <div class="panel-head">
884
-        <h2>Overview</h2>
885
-        <div class="stats" id="stats"></div>
886
-      </div>
887
-      <div class="problems" id="problems"></div>
888
-    </section>
889 917
 
890
-    <section class="panel">
891
-      <div class="panel-head">
892
-        <h2>Hosts</h2>
893
-        <input id="filter" placeholder="filter" style="max-width: 240px">
918
+  <!-- ── Login screen ── -->
919
+  <div id="login-screen">
920
+    <div class="login-card">
921
+      <div class="brand">
922
+        <div class="icon">
923
+          <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
924
+            <path d="M20 3H4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm-1 5H5V5h14v3zm1 5H4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1zm-1 5H5v-3h14v3z"/>
925
+            <circle cx="17" cy="7" r="1"/><circle cx="19.5" cy="7" r="1"/>
926
+            <circle cx="17" cy="15.5" r="1"/><circle cx="19.5" cy="15.5" r="1"/>
927
+          </svg>
928
+        </div>
929
+        <h1>Host Manager</h1>
930
+        <p>madagascar.xdev.ro</p>
894 931
       </div>
895
-      <div class="table-wrap">
896
-        <table>
897
-          <thead>
898
-            <tr>
899
-              <th style="width: 120px">ID</th>
900
-              <th style="width: 130px">hosts_ip</th>
901
-              <th style="width: 130px">dns_ip</th>
902
-              <th>Names</th>
903
-              <th style="width: 150px">Roles</th>
904
-              <th style="width: 110px">Monitoring</th>
905
-              <th style="width: 90px">Status</th>
906
-            </tr>
907
-          </thead>
908
-          <tbody id="hosts"></tbody>
909
-        </table>
932
+      <form id="login-form">
933
+        <div class="field-label">Cod Authenticator</div>
934
+        <div class="otp-row">
935
+          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit" autocomplete="one-time-code">
936
+          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
937
+          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
938
+          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
939
+          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
940
+          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
941
+        </div>
942
+        <button class="primary" type="submit" id="login-btn">Autentifică-te</button>
943
+      </form>
944
+      <div id="login-error"></div>
945
+    </div>
946
+  </div>
947
+
948
+  <!-- ── App (shown after login) ── -->
949
+  <div id="app">
950
+    <header>
951
+      <h1>Host Manager</h1>
952
+      <div class="header-right">
953
+        <span class="muted" id="app-updated"></span>
954
+        <button type="button" id="logout">Logout</button>
910 955
       </div>
911
-    </section>
956
+    </header>
957
+    <main>
958
+      <section class="toolbar">
959
+        <button id="refresh">Refresh</button>
960
+        <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
961
+        <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
962
+        <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
963
+        <button id="write-tsv">Write local-hosts.tsv</button>
964
+        <span id="message" class="muted"></span>
965
+      </section>
966
+
967
+      <section class="panel">
968
+        <div class="panel-head">
969
+          <h2>Overview</h2>
970
+          <div class="stats" id="stats"></div>
971
+        </div>
972
+        <div class="problems" id="problems"></div>
973
+      </section>
912 974
 
913
-    <section class="panel">
914
-      <div class="panel-head">
915
-        <h2>Edit host</h2>
916
-        <span class="muted">write access requires OTP</span>
917
-      </div>
918
-      <form id="host-form" class="grid">
919
-        <label>ID<input name="id" required></label>
920
-        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
921
-        <label>hosts_ip<input name="hosts_ip" required></label>
922
-        <label>dns_ip<input name="dns_ip" required></label>
923
-        <label class="span2">Names<textarea name="names" required></textarea></label>
924
-        <label>Roles<input name="roles"></label>
925
-        <label>Sources<input name="sources"></label>
926
-        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
927
-        <label>Notes<input name="notes"></label>
928
-        <div class="span2">
929
-          <button class="primary" type="submit">Save host</button>
930
-          <button class="danger" type="button" id="delete-host">Delete host</button>
975
+      <section class="panel">
976
+        <div class="panel-head">
977
+          <h2>Hosts</h2>
978
+          <input id="filter" placeholder="filter" style="max-width: 240px">
931 979
         </div>
932
-      </form>
933
-    </section>
934
-  </main>
980
+        <div class="table-wrap">
981
+          <table>
982
+            <thead>
983
+              <tr>
984
+                <th style="width: 120px">ID</th>
985
+                <th style="width: 130px">hosts_ip</th>
986
+                <th style="width: 130px">dns_ip</th>
987
+                <th>Names</th>
988
+                <th style="width: 150px">Roles</th>
989
+                <th style="width: 110px">Monitoring</th>
990
+                <th style="width: 90px">Status</th>
991
+              </tr>
992
+            </thead>
993
+            <tbody id="hosts"></tbody>
994
+          </table>
995
+        </div>
996
+      </section>
997
+
998
+      <section class="panel">
999
+        <div class="panel-head">
1000
+          <h2>Edit host</h2>
1001
+        </div>
1002
+        <form id="host-form" class="grid">
1003
+          <label>ID<input name="id" required></label>
1004
+          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
1005
+          <label>hosts_ip<input name="hosts_ip" required></label>
1006
+          <label>dns_ip<input name="dns_ip" required></label>
1007
+          <label class="span2">Names<textarea name="names" required></textarea></label>
1008
+          <label>Roles<input name="roles"></label>
1009
+          <label>Sources<input name="sources"></label>
1010
+          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
1011
+          <label>Notes<input name="notes"></label>
1012
+          <div class="span2">
1013
+            <button class="primary" type="submit">Save host</button>
1014
+            <button class="danger" type="button" id="delete-host">Delete host</button>
1015
+          </div>
1016
+        </form>
1017
+      </section>
1018
+    </main>
1019
+  </div>
1020
+
935 1021
   <script>
936 1022
     let state = { hosts: [], problems: [], authenticated: false };
937 1023
 
@@ -945,44 +1031,37 @@ sub app_html {
945 1031
       return body;
946 1032
     }
947 1033
 
1034
+    function showLogin(errorText) {
1035
+      $('app').style.display = 'none';
1036
+      $('login-screen').style.display = 'flex';
1037
+      $('login-error').textContent = errorText || '';
1038
+      document.querySelectorAll('.otp-digit').forEach(i => { i.value = ''; i.classList.remove('filled'); });
1039
+      const first = document.querySelector('.otp-digit');
1040
+      if (first) first.focus();
1041
+    }
1042
+
1043
+    function showApp() {
1044
+      $('login-screen').style.display = 'none';
1045
+      $('app').style.display = 'block';
1046
+    }
1047
+
948 1048
     async function refresh() {
949 1049
       const session = await api('/api/session');
950 1050
       state.authenticated = session.authenticated;
951
-      updateAuthUi();
952
-      if (!state.authenticated) {
953
-        state.hosts = [];
954
-        state.problems = [];
955
-        renderLocked();
956
-        return;
957
-      }
1051
+      if (!state.authenticated) { showLogin(); return; }
1052
+      showApp();
958 1053
       const data = await api('/api/hosts');
959 1054
       state.hosts = data.hosts || [];
960 1055
       state.problems = data.problems || [];
961 1056
       render(data);
962 1057
     }
963 1058
 
964
-    function updateAuthUi() {
965
-      $('auth-state').textContent = state.authenticated ? 'authenticated' : 'locked';
966
-      document.querySelectorAll('[data-auth-required]').forEach(el => {
967
-        if (el.tagName === 'A') {
968
-          el.setAttribute('aria-disabled', state.authenticated ? 'false' : 'true');
969
-        } else {
970
-          el.disabled = !state.authenticated;
971
-        }
972
-      });
973
-    }
974
-
975
-    function renderLocked() {
976
-      $('stats').innerHTML = '<span class="stat">authentication required</span>';
977
-      $('problems').innerHTML = '<div class="muted" style="padding: 8px 0">Login with OTP to access host data, downloads, and configuration actions.</div>';
978
-      $('hosts').innerHTML = '';
979
-    }
980
-
981 1059
     function render(data) {
1060
+      $('app-updated').textContent = data.updated_at ? 'updated ' + data.updated_at : '';
1061
+
982 1062
       $('stats').innerHTML = [
983 1063
         ['hosts', data.counts.hosts],
984 1064
         ['problems', data.counts.problems],
985
-        ['updated', data.updated_at || 'unknown']
986 1065
       ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
987 1066
 
988 1067
       $('problems').innerHTML = state.problems.length
@@ -1020,7 +1099,7 @@ sub app_html {
1020 1099
       form.elements.names.value = (host.names || []).join('\n');
1021 1100
       form.elements.roles.value = (host.roles || []).join(' ');
1022 1101
       form.elements.sources.value = (host.sources || []).join(' ');
1023
-      window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
1102
+      form.scrollIntoView({ behavior: 'smooth', block: 'start' });
1024 1103
     }
1025 1104
 
1026 1105
     function formObject(form) {
@@ -1031,25 +1110,65 @@ sub app_html {
1031 1110
       return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
1032 1111
     }
1033 1112
 
1034
-    $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
1035
-    $('filter').addEventListener('input', renderHosts);
1113
+    // OTP digit boxes — auto-advance, backspace, paste
1114
+    const otpDigits = Array.from(document.querySelectorAll('.otp-digit'));
1115
+    otpDigits[0].focus();
1116
+
1117
+    otpDigits.forEach((input, idx) => {
1118
+      input.addEventListener('keydown', (e) => {
1119
+        if (e.key === 'Backspace') {
1120
+          if (input.value) { input.value = ''; input.classList.remove('filled'); }
1121
+          else if (idx > 0) { otpDigits[idx - 1].value = ''; otpDigits[idx - 1].classList.remove('filled'); otpDigits[idx - 1].focus(); }
1122
+          e.preventDefault();
1123
+        }
1124
+      });
1125
+      input.addEventListener('input', (e) => {
1126
+        const val = input.value.replace(/\D/g, '').slice(-1);
1127
+        input.value = val;
1128
+        input.classList.toggle('filled', !!val);
1129
+        if (val && idx < otpDigits.length - 1) otpDigits[idx + 1].focus();
1130
+        if (val && idx === otpDigits.length - 1) $('login-form').requestSubmit();
1131
+      });
1132
+      input.addEventListener('paste', (e) => {
1133
+        const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
1134
+        e.preventDefault();
1135
+        text.split('').slice(0, otpDigits.length).forEach((ch, i) => {
1136
+          otpDigits[i].value = ch;
1137
+          otpDigits[i].classList.add('filled');
1138
+        });
1139
+        const next = Math.min(text.length, otpDigits.length - 1);
1140
+        otpDigits[next].focus();
1141
+        if (text.length >= otpDigits.length) $('login-form').requestSubmit();
1142
+      });
1143
+    });
1144
+
1145
+    function getOtp() { return otpDigits.map(i => i.value).join(''); }
1146
+    function clearOtp() { otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); }); otpDigits[0].focus(); }
1036 1147
 
1037 1148
     $('login-form').addEventListener('submit', async (event) => {
1038 1149
       event.preventDefault();
1150
+      const btn = $('login-btn');
1151
+      btn.disabled = true;
1152
+      $('login-error').textContent = '';
1039 1153
       try {
1040
-        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: $('otp').value }) });
1041
-        $('otp').value = '';
1042
-        msg('authenticated');
1154
+        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
1043 1155
         await refresh();
1044
-      } catch (e) { msg(e.message); }
1156
+      } catch (e) {
1157
+        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
1158
+      } finally {
1159
+        btn.disabled = false;
1160
+      }
1045 1161
     });
1046 1162
 
1047 1163
     $('logout').addEventListener('click', async () => {
1048 1164
       await api('/api/logout', { method: 'POST' }).catch(() => {});
1049
-      msg('logged out');
1050
-      await refresh();
1165
+      clearOtp();
1166
+      showLogin();
1051 1167
     });
1052 1168
 
1169
+    $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
1170
+    $('filter').addEventListener('input', renderHosts);
1171
+
1053 1172
     $('host-form').addEventListener('submit', async (event) => {
1054 1173
       event.preventDefault();
1055 1174
       try {
@@ -1078,7 +1197,7 @@ sub app_html {
1078 1197
       } catch (e) { msg(e.message); }
1079 1198
     });
1080 1199
 
1081
-    refresh().catch(e => msg(e.message));
1200
+    refresh().catch(() => showLogin());
1082 1201
   </script>
1083 1202
 </body>
1084 1203
 </html>