Ctrl K
ComunidadGuías…

public/login.html

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!DOCTYPE html>
<html lang="pl" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="page-title">Ham Radio Control — Logowanie</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  /* Paleta "SDR Studio Pro" (przejeta z forka 2026-10-16, patrz style.css) */
  --bg:      #070b14;
  --panel:   #0d1424;
  --panel2:  #131c31;
  --border:  rgba(56,189,248,0.14);
  --border2: rgba(56,189,248,0.30);
  --green:   #38bdf8;
  --green2:  #0284c7;
  --green-rgb: 56,189,248;
  --amber-rgb: 251,191,36;
  --red-rgb:   244,63,94;
  --dim:     rgba(148,163,184,0.65);
  --red:     #f43f5e;
  --text:    #f1f5f9;
  --amber:   #fbbf24;
  --mono:    'JetBrains Mono', 'Share Tech Mono', monospace;
  --display: 'JetBrains Mono', 'Orbitron', monospace;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: 'Inter', sans-serif;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

body::before {
  content: '';
  position: fixed; inset: 0;
  background-image:
    linear-gradient(rgba(var(--green-rgb),0.03) 1px, transparent 1px),
    linear-gradient(90deg, rgba(var(--green-rgb),0.03) 1px, transparent 1px);
  background-size: 40px 40px;
  pointer-events: none;
}

body::after {
  content: '';
  position: fixed; inset: 0;
  background: repeating-linear-gradient(0deg, transparent, transparent 2px,
    rgba(0,0,0,0.025) 2px, rgba(0,0,0,0.025) 4px);
  pointer-events: none;
}

.container {
  width: 100%;
  max-width: 400px;
  padding: 24px;
  position: relative;
  z-index: 1;
}

.logo-area {
  text-align: center;
  margin-bottom: 32px;
}

.logo-img {
  width: 72px;
  height: 72px;
  border-radius: 16px;
  margin-bottom: 14px;
  display: inline-block;
}

.logo {
  font-family: var(--display);
  font-size: 20px;
  font-weight: 900;
  letter-spacing: 4px;
  color: var(--text);
  display: block;
  margin-bottom: 6px;
}

.logo span { color: var(--green); }

.logo-sub {
  font-family: var(--mono);
  font-size: 10px;
  color: var(--dim);
  letter-spacing: 3px;
}

.on-air {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 2px;
  color: var(--green);
  margin-top: 10px;
  opacity: 0.7;
}

.on-air-dot {
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 6px rgba(var(--green-rgb),0.7);
  animation: blink 2s ease-in-out infinite;
}

@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }

.card {
  background: var(--panel);
  border: 1px solid var(--border2);
  border-radius: 8px;
  padding: 32px;
  position: relative;
}

.card::before {
  content: '';
  position: absolute;
  top: 0; left: 10%; right: 10%;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--green2), transparent);
  opacity: 0.6;
}

.card-title {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 3px;
  color: var(--dim);
  text-transform: uppercase;
  margin-bottom: 24px;
  text-align: center;
}

.field { margin-bottom: 16px; }

.field label {
  display: block;
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 2px;
  color: var(--dim);
  margin-bottom: 6px;
  text-transform: uppercase;
}

.field input {
  width: 100%;
  background: var(--panel2);
  border: 1px solid #000;
  box-shadow: inset 0 0 0 1px var(--border2);
  border-radius: 4px;
  color: var(--text);
  font-family: var(--mono);
  font-size: 14px;
  padding: 10px 14px;
  outline: none;
  transition: box-shadow 0.2s;
}

.field input:focus {
  box-shadow: inset 0 0 0 1px var(--green);
}

.field input::placeholder { color: var(--dim); }

.submit-btn {
  width: 100%;
  padding: 12px;
  background: rgba(var(--green-rgb),0.10);
  border: 1px solid #000;
  box-shadow: inset 0 0 0 1px var(--green2);
  border-radius: 4px;
  color: var(--green);
  font-family: var(--display);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 3px;
  cursor: pointer;
  transition: all 0.2s;
  margin-top: 8px;
  text-transform: uppercase;
}

.submit-btn:hover {
  background: rgba(var(--green-rgb),0.18);
  box-shadow: inset 0 0 0 1px var(--green);
}
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }

.submit-btn.loading { position: relative; color: transparent; }
.submit-btn.loading::after {
  content: '...';
  position: absolute;
  left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  color: var(--green);
  font-family: var(--mono);
}

.error-msg {
  background: rgba(var(--red-rgb),0.08);
  border: 1px solid rgba(var(--red-rgb),0.25);
  border-radius: 4px;
  padding: 10px 12px;
  font-family: var(--mono);
  font-size: 11px;
  color: var(--red);
  margin-top: 14px;
  display: none;
  text-align: center;
  line-height: 1.6;
}

.error-msg.show { display: block; }

.reset-link {
  display: block;
  text-align: center;
  margin-top: 18px;
  font-family: var(--mono);
  font-size: 9px;
  color: var(--dim);
  letter-spacing: 1px;
  text-decoration: none;
  opacity: 0.7;
  transition: color 0.2s, opacity 0.2s;
}
.reset-link:hover { color: var(--green); opacity: 1; }

.version {
  text-align: center;
  font-family: var(--mono);
  font-size: 9px;
  color: var(--dim);
  margin-top: 20px;
  letter-spacing: 1px;
  opacity: 0.4;
}

.lang-switch {
  position: fixed;
  top: 16px;
  right: 16px;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 1px;
  color: var(--dim);
  background: var(--panel2);
  border: 1px solid var(--border2);
  border-radius: 4px;
  padding: 5px 10px;
  cursor: pointer;
  z-index: 2;
}
.lang-switch:hover { color: var(--green); border-color: var(--green); }
</style>
</head>
<body>
<button type="button" class="lang-switch" id="lang-switch" onclick="toggleLoginLang()">EN</button>
<div class="container">
  <div class="logo-area">
    <img src="/logo.png" alt="HAMCTRL" class="logo-img">
    <span class="logo">HAM RADIO <span>CTRL</span></span>
    <span class="logo-sub" id="logo-sub">REMOTE CONTROL SYSTEM</span>
    <div>
      <span class="on-air"><span class="on-air-dot"></span><span id="on-air-label">SYSTEM ONLINE</span></span>
    </div>
  </div>

  <div class="card">
    <div class="card-title" id="card-title">Autoryzacja dostępu</div>

    <form id="login-form" onsubmit="doLogin(event)">
      <div class="field">
        <label id="label-username">Login</label>
        <input type="text" id="username" autocomplete="username"
               placeholder="admin" required autofocus>
      </div>
      <div class="field">
        <label id="label-password">Hasło</label>
        <input type="password" id="password" autocomplete="current-password"
               placeholder="••••••••" required
               onkeydown="if(event.key==='Enter'){event.preventDefault();doLogin(event)}">
      </div>
      <button type="submit" class="submit-btn" id="submit-btn">ZALOGUJ</button>
    </form>

    <!-- Kreator pierwszego uruchomienia: wymuszenie zmiany domyslnego hasla
         admina. Pokazywany gdy backend zwroci must_change_password=true
         (admin loguje sie wciaz haslem Admin1234!). Konto admin jest
         techniczne - nie wymaga znaku ani locatora; operatorzy zakladaja
         wlasne konta ze swoimi znakami pozniej. -->
    <form id="setup-form" onsubmit="doChangePassword(event)" style="display:none;">
      <div style="font-size:12px;color:var(--dim,#888);margin-bottom:14px;line-height:1.5;" id="setup-desc">
        Pierwsze uruchomienie. Ustaw własne hasło administratora —
        domyślne <code>Admin1234!</code> nie może zostać.
      </div>
      <div class="field">
        <label id="label-new-password">Nowe hasło (min. 8 znaków)</label>
        <input type="password" id="new-password" autocomplete="new-password"
               placeholder="••••••••" minlength="8">
      </div>
      <div class="field">
        <label id="label-new-password2">Powtórz nowe hasło</label>
        <input type="password" id="new-password2" autocomplete="new-password"
               placeholder="••••••••" minlength="8"
               onkeydown="if(event.key==='Enter'){event.preventDefault();doChangePassword(event)}">
      </div>
      <button type="submit" class="submit-btn" id="setup-btn">USTAW HASŁO I PRZEJDŹ DALEJ</button>
    </form>

    <div class="error-msg" id="error-msg"></div>

    <a href="/reset.html" class="reset-link" id="reset-link">Zapomniałem hasła</a>
  </div>

  <div class="version">HAM RADIO CTRL · Franek (Claude.ai) &amp; SQ3MZM Tom</div>
</div>

<script>
  // Ustawia window.__HAM_INITIAL_LANG__ z jezyka domyslnego instalacji
  // (wybranego w instalatorze - patrz HAMCTRL-installer.iss), zanim
  // wykona sie ponizszy skrypt. Ten sam mechanizm co w index.html -
  // synchroniczne XHR celowo, potrzebna wartosc dostepna natychmiast,
  // nie po async fetch.
  try {
    var _xhr = new XMLHttpRequest();
    _xhr.open('GET', '/api/config/lang', false);
    _xhr.send(null);
    if (_xhr.status === 200) {
      var _langResp = JSON.parse(_xhr.responseText);
      if (_langResp && _langResp.lang) window.__HAM_INITIAL_LANG__ = _langResp.lang;
    }
  } catch (e) { /* fallback do 'pl' ponizej */ }
</script>
<script>
// UWAGA: auth.js/i18n.js NIE sa ladowane na tej stronie - celowo (patrz
// komentarz przy fetch /api/auth/me ponizej). To osobna, mala warstwa PL/EN
// - ten sam mechanizm/priorytet co i18n.js w index.html (localStorage
// 'ham_lang' > jezyk domyslny instalacji > 'pl'), ale samodzielna, zeby nie
// wciagac tu calego i18n.js z jego slownikiem dla reszty aplikacji.
const LOGIN_STRINGS = {
  pl: {
    pageTitle:    'Ham Radio Control — Logowanie',
    logoSub:      'REMOTE CONTROL SYSTEM',
    onAir:        'SYSTEM ONLINE',
    cardTitleLogin: 'Autoryzacja dostępu',
    cardTitleSetup: 'Konfiguracja — zmiana hasła',
    labelUsername: 'Login',
    labelPassword: 'Hasło',
    submitBtn:    'ZALOGUJ',
    setupDesc:    'Pierwsze uruchomienie. Ustaw własne hasło administratora — domyślne <code>Admin1234!</code> nie może zostać.',
    labelNewPw:   'Nowe hasło (min. 8 znaków)',
    labelNewPw2:  'Powtórz nowe hasło',
    setupBtn:     'USTAW HASŁO I PRZEJDŹ DALEJ',
    resetLink:    'Zapomniałem hasła',
    errBadLoginFallback: 'Błędny login lub hasło',
    errConnPrefix:       'Błąd połączenia z serwerem (',
    errPwTooShort:       'Hasło musi mieć minimum 8 znaków',
    errPwMismatch:       'Hasła nie są identyczne',
    errPwDefault:        'Nowe hasło nie może być domyślnym',
    errChangeFallback:   'Nie udało się zmienić hasła',
    // Znane komunikaty z backendu (zawsze po polsku - serwer nie zna
    // jezyka klienta) - tlumaczone tu po stronie klienta dla spojnosci.
    // Nierozpoznany komunikat wyswietla sie tak jak przyszedl z serwera.
    srv_badLogin: 'Błędne dane logowania',
    srv_noAccount: 'Brak konta',
    srv_badOldPw: 'Złe stare hasło',
    srv_pwMin8:   'Hasło min. 8 znaków',
  },
  en: {
    pageTitle:    'Ham Radio Control — Login',
    logoSub:      'REMOTE CONTROL SYSTEM',
    onAir:        'SYSTEM ONLINE',
    cardTitleLogin: 'Access Authorization',
    cardTitleSetup: 'Setup — Change Password',
    labelUsername: 'Username',
    labelPassword: 'Password',
    submitBtn:    'LOG IN',
    setupDesc:    'First run. Set your own administrator password — the default <code>Admin1234!</code> cannot stay active.',
    labelNewPw:   'New password (min. 8 characters)',
    labelNewPw2:  'Repeat new password',
    setupBtn:     'SET PASSWORD AND CONTINUE',
    resetLink:    'Forgot password',
    errBadLoginFallback: 'Incorrect username or password',
    errConnPrefix:       'Server connection error (',
    errPwTooShort:       'Password must be at least 8 characters',
    errPwMismatch:       'Passwords do not match',
    errPwDefault:        'The new password cannot be the default one',
    errChangeFallback:   'Could not change the password',
    srv_badLogin: 'Incorrect username or password',
    srv_noAccount: 'No such account',
    srv_badOldPw: 'Incorrect current password',
    srv_pwMin8:   'Password must be at least 8 characters',
  },
};

let _loginLang = 'pl';

function L(key) {
  return (LOGIN_STRINGS[_loginLang] || LOGIN_STRINGS.pl)[key] ?? key;
}

// Known backend error strings (always Polish - the server doesn't know the
// client's language) get swapped for the matching client-side translation.
// Anything unrecognized (e.g. the rate-limit message, which carries a
// dynamic minute count) passes through unchanged rather than being hidden.
function translateServerError(msg) {
  if (!msg) return msg;
  const d = LOGIN_STRINGS.pl;
  const map = {
    [d.srv_badLogin]:  'srv_badLogin',
    [d.srv_noAccount]: 'srv_noAccount',
    [d.srv_badOldPw]:  'srv_badOldPw',
    [d.srv_pwMin8]:    'srv_pwMin8',
  };
  const key = map[msg];
  return key ? L(key) : msg;
}

function applyLoginLang() {
  document.getElementById('page-title').textContent = L('pageTitle');
  document.getElementById('html-root').lang = _loginLang;
  document.getElementById('logo-sub').textContent = L('logoSub');
  document.getElementById('on-air-label').textContent = L('onAir');
  const setupVisible = document.getElementById('setup-form').style.display !== 'none';
  document.getElementById('card-title').textContent = setupVisible ? L('cardTitleSetup') : L('cardTitleLogin');
  document.getElementById('label-username').textContent = L('labelUsername');
  document.getElementById('label-password').textContent = L('labelPassword');
  document.getElementById('submit-btn').textContent = L('submitBtn');
  document.getElementById('setup-desc').innerHTML = L('setupDesc');
  document.getElementById('label-new-password').textContent = L('labelNewPw');
  document.getElementById('label-new-password2').textContent = L('labelNewPw2');
  document.getElementById('setup-btn').textContent = L('setupBtn');
  document.getElementById('reset-link').textContent = L('resetLink');
  document.getElementById('lang-switch').textContent = _loginLang.toUpperCase();
}

function toggleLoginLang() {
  _loginLang = (_loginLang === 'pl') ? 'en' : 'pl';
  localStorage.setItem('ham_lang', _loginLang);
  applyLoginLang();
}

(function initLoginLang() {
  const SUPPORTED = ['pl', 'en'];
  const saved = localStorage.getItem('ham_lang');
  if (saved && SUPPORTED.includes(saved)) {
    _loginLang = saved;
  } else if (window.__HAM_INITIAL_LANG__ && SUPPORTED.includes(window.__HAM_INITIAL_LANG__)) {
    _loginLang = window.__HAM_INITIAL_LANG__;
  }
  applyLoginLang();
})();

// UWAGA: auth.js NIE jest ładowany na tej stronie — celowo.
// Sprawdzenie istniejącej sesji jest minimalne i nie przekierowuje pętlowo.
(function() {
  // Jeśli mamy token — sprawdź czy nadal ważny (cicho, bez pętli)
  const tok = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
  if (tok) {
    fetch('/api/auth/me', {
      headers: { 'Authorization': 'Bearer ' + tok }
    }).then(r => {
      if (r.ok) location.replace('/');
    }).catch(() => {});
  }
})();

async function doLogin(e) {
  if (e && e.preventDefault) e.preventDefault();
  const btn  = document.getElementById('submit-btn');
  const err  = document.getElementById('error-msg');
  const user = document.getElementById('username').value.trim();
  const pass = document.getElementById('password').value;

  if (!user || !pass) return;

  btn.disabled = true;
  btn.classList.add('loading');
  err.classList.remove('show');

  try {
    const r = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: user, password: pass }),
    });
    const res = await r.json();

    if (res.ok && res.token) {
      // Zapisz token — auth.js w index.html szuka go w localStorage
      localStorage.setItem('token', res.token);
      sessionStorage.setItem('ham_token', res.token);
      // Pierwsze uruchomienie: admin loguje sie domyslnym haslem -> zamiast
      // wpuscic do aplikacji, pokaz ekran zmiany hasla (kreator).
      if (res.must_change_password) {
        showSetupForm();
      } else {
        location.replace('/');
      }
    } else {
      showError(res.error ? translateServerError(res.error) : L('errBadLoginFallback'));
    }
  } catch(ex) {
    showError(L('errConnPrefix') + ex.message + ')');
  } finally {
    btn.disabled = false;
    btn.classList.remove('loading');
  }
}

// Przelacz widok na formularz zmiany hasla (kreator pierwszego uruchomienia)
function showSetupForm() {
  document.getElementById('login-form').style.display = 'none';
  document.getElementById('reset-link').style.display = 'none';
  document.getElementById('setup-form').style.display = 'block';
  document.getElementById('card-title').textContent = L('cardTitleSetup');
  document.getElementById('error-msg').classList.remove('show');
  document.getElementById('new-password').focus();
}

// Zmiana hasla admina przy pierwszym uruchomieniu. Token jest juz zapisany
// (z doLogin), wiec change-password dziala na zalogowanej sesji.
async function doChangePassword(ev) {
  if (ev) ev.preventDefault();
  const p1 = document.getElementById('new-password').value;
  const p2 = document.getElementById('new-password2').value;
  const btn = document.getElementById('setup-btn');

  if (p1.length < 8) { showError(L('errPwTooShort')); return; }
  if (p1 !== p2)     { showError(L('errPwMismatch')); return; }
  if (p1 === 'Admin1234!') { showError(L('errPwDefault')); return; }

  btn.disabled = true;
  btn.classList.add('loading');
  try {
    const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
    const r = await fetch('/api/auth/change-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json',
                 'Authorization': 'Bearer ' + token },
      body: JSON.stringify({ old_password: 'Admin1234!', new_password: p1 }),
    });
    const res = await r.json();
    if (res.ok && res.token) {
      // Nowy token po zmianie hasla (stary uniewazniony przez pw_ver)
      localStorage.setItem('token', res.token);
      sessionStorage.setItem('ham_token', res.token);
      location.replace('/');
    } else {
      showError(res.error ? translateServerError(res.error) : L('errChangeFallback'));
    }
  } catch(ex) {
    showError(L('errConnPrefix') + ex.message + ')');
  } finally {
    btn.disabled = false;
    btn.classList.remove('loading');
  }
}

function showError(msg) {
  const el = document.getElementById('error-msg');
  el.textContent = msg;
  el.classList.add('show');
  document.getElementById('password').value = '';
  document.getElementById('password').focus();
}
</script>
</body>
</html>