public/login.html
Rama main · réplica de lectura del repositorio original.
<!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) & 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>