Replaced one-pager with multiple pages and fixed security bugs

This commit is contained in:
Jan Meinl
2026-05-16 21:10:55 +02:00
parent 802906f9d4
commit 68034dea7d
25 changed files with 2311 additions and 1217 deletions
+8 -2
View File
@@ -22,13 +22,14 @@ async function api(method, path, body) {
}
const API = {
login: (u, p) => api("POST", "/api/login", { username: u, password: p }),
login: (u, p) => api("POST", "/api/login", { username: String(u || "").toLowerCase().trim(), password: p }),
logout: () => api("POST", "/api/logout"),
me: () => api("GET", "/api/me"),
updateMe: (b) => api("PATCH", "/api/me", b),
listUsers: () => api("GET", "/api/users"),
createUser: (b) => api("POST", "/api/users", b),
updateUser: (id, b) => api("PATCH", `/api/users/${id}`, b),
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
listCompetitions: () => api("GET", "/api/competitions"),
@@ -60,7 +61,12 @@ const API = {
createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b),
updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b),
deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`),
exportPenaltiesURL: (id) => apiURL(`/api/competitions/${id}/penalties.csv`),
applyPenalties: (id, b) => api("POST", `/api/competitions/${id}/penalties/apply`, b),
exportPenaltiesCSV: async (id) => {
const res = await fetch(apiURL(`/api/competitions/${id}/penalties.csv`), { credentials: "include" });
if (!res.ok) throw new Error("export_failed");
return res.blob();
},
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
};
-1163
View File
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
// Shared helpers used by all pages.
const el = (tag, attrs, ...children) => {
const node = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
if (k === "class") node.className = attrs[k];
else if (k === "style") Object.assign(node.style, attrs[k]);
else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]);
else if (k === "checked" || k === "disabled" || k === "selected") {
if (attrs[k]) node.setAttribute(k, "");
} else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) {
node.setAttribute(k, attrs[k]);
}
}
}
for (const c of children.flat()) {
if (c === null || c === undefined || c === false) continue;
node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c);
}
return node;
};
function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function naturalCompare(a, b) {
if (a == null) a = "";
if (b == null) b = "";
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
}
// Page navigation helpers (true multi-page navigation, not SPA routing).
const PAGES = {
login: "login.html",
competitions: "competitions.html",
competition: "competition.html",
forcePassword: "force-password.html",
};
function navigate(page, params) {
let url = PAGES[page] || page;
if (params) {
const qs = new URLSearchParams(params).toString();
if (qs) url += "?" + qs;
}
location.assign(url);
}
// Bootstraps a page: loads the current user, enforces auth rules.
// options:
// requireAuth: true — redirect to login if no session
// forbidIfMustChange: true — redirect to force-password.html
// onlyIfMustChange: true — redirect AWAY if user must NOT change
async function bootstrapAuth(options) {
options = options || {};
let user = null;
try {
user = await API.me();
} catch (e) {
user = null;
}
if (options.requireAuth && !user) {
navigate("login");
return null;
}
if (!user) return null;
setLang(user.language || CURRENT_LANG);
if (user.must_change_password && options.forbidIfMustChange) {
navigate("forcePassword");
return null;
}
if (!user.must_change_password && options.onlyIfMustChange) {
navigate("competitions");
return null;
}
return user;
}
// Standard topbar shown on authenticated pages.
function renderTopbar(user, opts) {
opts = opts || {};
const langSelect = el("select",
{ onchange: async (e) => {
const lang = e.target.value;
try { await API.updateMe({ language: lang }); } catch (_) {}
user.language = lang;
setLang(lang);
location.reload();
} },
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
);
const logoutBtn = el("button", { class: "ghost", onclick: async () => {
try { await API.logout(); } catch (_) {}
navigate("login");
} }, t("logout"));
const brand = el("a", { href: PAGES.competitions, class: "brand" }, "Penalty Tracker");
const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal(user) },
user.display_name || user.username);
return el("div", { class: "topbar" },
brand,
el("div", { class: "nav" },
opts.extra || null,
profileBtn,
langSelect,
logoutBtn,
)
);
}
// Self-contained profile modal: language and password only. Username/display
// name are read-only here (only system admin can change them).
function openProfileModal(user) {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
const lang = el("select", null,
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
);
const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
const usernameField = el("input", { type: "text", value: user.username, disabled: true });
const displayField = el("input", { type: "text", value: user.display_name || "", disabled: true });
const modal = el("div", { class: "modal" },
el("h3", null, t("profile")),
el("div", { class: "field" }, el("label", null, t("username")), usernameField,
el("div", { class: "muted small" }, t("profile_username_readonly"))),
el("div", { class: "field" }, el("label", null, t("display_name")), displayField,
el("div", { class: "muted small" }, t("profile_displayname_readonly"))),
el("div", { class: "field" }, el("label", null, t("language")), lang),
el("div", { class: "field" }, el("label", null, t("new_password")), password),
err, ok,
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
err.style.display = "none"; ok.style.display = "none";
const body = {};
if (lang.value !== user.language) body.language = lang.value;
if (password.value) body.password = password.value;
if (Object.keys(body).length === 0) { backdrop.remove(); return; }
try {
const u = await API.updateMe(body);
if (u.language !== user.language) {
user.language = u.language;
setLang(u.language);
}
ok.textContent = t("saved");
ok.style.display = "block";
setTimeout(() => { backdrop.remove(); location.reload(); }, 600);
} catch (e) {
err.textContent = (e.data && e.data.error) || e.message || "error";
err.style.display = "block";
}
} }, t("save")),
),
);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
}
// Read URL search params as an object.
function queryParams() {
const out = {};
const sp = new URLSearchParams(location.search);
for (const [k, v] of sp.entries()) out[k] = v;
return out;
}
// Initialize language from saved/browser preference before authenticated state
// is known.
(function initInitialLang() {
const userLang = navigator.language ? navigator.language.slice(0, 2) : "en";
if (typeof I18N_AVAILABLE !== "undefined" && I18N_AVAILABLE.includes(userLang)) {
setLang(userLang);
} else {
setLang("en");
}
})();
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Competition</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/competition.js"></script>
</body>
</html>
+1232
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Competitions</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/competitions.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
(async function () {
const root = document.getElementById("app");
const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
if (!user) return;
const state = { competitions: [], users: [] };
async function loadCompetitions() {
state.competitions = await API.listCompetitions();
}
async function loadUsers() {
if (user.is_system_admin) state.users = await API.listUsers();
}
function openCompetitionModal() {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const nameInput = el("input", { type: "text" });
const allowInput = el("input", { type: "checkbox" });
backdrop.appendChild(el("div", { class: "modal" },
el("h3", null, t("new_competition")),
el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput),
el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")),
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
if (!nameInput.value.trim()) return;
try {
await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked });
backdrop.remove();
await loadCompetitions();
render();
} catch (e) { alert(e.message); }
} }, t("create")),
),
));
document.body.appendChild(backdrop);
}
function openUserModal() {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const username = el("input", { type: "text",
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
const password = el("input", { type: "password" });
const displayName = el("input", { type: "text" });
const langSelect = el("select", null,
...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l]))
);
const isAdmin = el("input", { type: "checkbox" });
backdrop.appendChild(el("div", { class: "modal" },
el("h3", null, t("add_user")),
el("div", { class: "field" }, el("label", null, t("username")), username),
el("div", { class: "field" }, el("label", null, t("password")), password),
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
el("div", { class: "field" }, el("label", null, t("language")), langSelect),
el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
if (!username.value.trim() || !password.value) return;
try {
await API.createUser({
username: username.value.trim().toLowerCase(),
password: password.value,
display_name: displayName.value,
language: langSelect.value,
is_system_admin: isAdmin.checked,
});
backdrop.remove();
await loadUsers();
render();
} catch (err) { alert(err.message); }
} }, t("create")),
),
));
document.body.appendChild(backdrop);
}
function renderUsersAdmin() {
const card = el("div", { class: "card" });
card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } },
el("h2", { style: { margin: 0 } }, t("users")),
el("button", { onclick: openUserModal }, t("add_user")),
));
if (state.users.length === 0) {
card.appendChild(el("div", { class: "muted" }, "—"));
return card;
}
const table = el("table");
table.appendChild(el("thead", null,
el("tr", null,
el("th", null, t("username")),
el("th", null, t("display_name")),
el("th", null, t("language")),
el("th", null, t("is_admin")),
el("th", null, t("must_change_password")),
el("th", null, t("actions")),
)
));
const tbody = el("tbody");
for (const u of state.users) {
const row = el("tr", null,
el("td", null, u.username),
el("td", null, u.display_name),
el("td", null, I18N_NAMES[u.language] || u.language),
el("td", null, u.is_system_admin ? t("yes") : t("no")),
el("td", null, u.must_change_password ? el("span", { class: "badge warn" }, t("yes")) : t("no")),
el("td", null,
el("button", { class: "action-btn", onclick: () => openEditUserModal(u) }, t("edit")),
!u.must_change_password && el("button", { class: "action-btn", onclick: async () => {
if (!confirm(t("confirm_force_password"))) return;
await API.updateUser(u.id, { must_change_password: true });
await loadUsers();
render();
} }, t("force_password_change")),
u.id !== user.id && el("button", { class: "action-btn danger", onclick: async () => {
if (!confirm(t("confirm_delete"))) return;
await API.deleteUser(u.id);
await loadUsers();
render();
} }, t("delete")),
),
);
tbody.appendChild(row);
}
table.appendChild(tbody);
card.appendChild(el("div", { class: "table-wrap" }, table));
return card;
}
function openEditUserModal(u) {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const username = el("input", { type: "text", value: u.username,
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
const displayName = el("input", { type: "text", value: u.display_name || "" });
const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
const isAdmin = el("input", { type: "checkbox", checked: !!u.is_system_admin });
backdrop.appendChild(el("div", { class: "modal" },
el("h3", null, t("edit") + ": " + u.username),
el("div", { class: "field" }, el("label", null, t("username")), username),
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
el("div", { class: "field" }, el("label", null, t("new_password")), password),
u.id !== user.id && el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
const body = {};
const newUsername = username.value.trim().toLowerCase();
if (newUsername && newUsername !== u.username) body.username = newUsername;
if (displayName.value !== (u.display_name || "")) body.display_name = displayName.value;
if (password.value) body.password = password.value;
if (u.id !== user.id && isAdmin.checked !== !!u.is_system_admin) body.is_system_admin = isAdmin.checked;
if (Object.keys(body).length === 0) { backdrop.remove(); return; }
try {
await API.updateUser(u.id, body);
backdrop.remove();
await loadUsers();
render();
} catch (e) { alert((e.data && e.data.error) || e.message); }
} }, t("save")),
),
));
document.body.appendChild(backdrop);
}
function render() {
clearNode(root);
root.appendChild(renderTopbar(user));
const container = el("div", { class: "container" });
container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } },
el("h2", { style: { margin: 0 } }, t("competitions")),
user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")),
));
if (state.competitions.length === 0) {
container.appendChild(el("div", { class: "muted" }, t("no_competitions")));
} else {
const grid = el("div", { class: "grid" });
for (const c of state.competitions) {
grid.appendChild(el("div", { class: "card" },
el("h2", null, c.name),
el("div", { class: "muted", style: { marginBottom: "0.5rem" } },
el("span", { class: "badge accent" }, t(c.role)),
),
el("button", { class: "primary", onclick: () => navigate("competition", { id: c.id }) }, t("open")),
));
}
container.appendChild(grid);
}
if (user.is_system_admin) {
container.appendChild(renderUsersAdmin());
}
root.appendChild(container);
}
await loadCompetitions();
await loadUsers();
render();
})();
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Change password</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/force-password.js"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
(async function () {
const root = document.getElementById("app");
const user = await bootstrapAuth({ requireAuth: true, onlyIfMustChange: true });
if (!user) return;
const pw1 = el("input", { type: "password", autocomplete: "new-password" });
const pw2 = el("input", { type: "password", autocomplete: "new-password" });
const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
const logoutBtn = el("button", { class: "ghost", onclick: async () => {
try { await API.logout(); } catch (_) {}
navigate("login");
} }, t("logout"));
async function submit(e) {
e.preventDefault();
err.style.display = "none";
if (pw1.value.length < 6) {
err.textContent = t("password_too_short");
err.style.display = "block";
return;
}
if (pw1.value !== pw2.value) {
err.textContent = t("passwords_dont_match");
err.style.display = "block";
return;
}
try {
const u = await API.updateMe({ password: pw1.value });
if (u && !u.must_change_password) navigate("competitions");
} catch (e) {
err.textContent = (e.data && e.data.error) || e.message || "error";
err.style.display = "block";
}
}
const form = el("form", { onsubmit: submit, class: "col" },
el("h1", null, t("change_password")),
el("p", { class: "muted" }, t("force_password_explain")),
el("div", { class: "field" }, el("label", null, t("new_password")), pw1),
el("div", { class: "field" }, el("label", null, t("repeat_password")), pw2),
err,
el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "0.5rem" } },
logoutBtn,
el("button", { type: "submit", class: "primary" }, t("save")),
),
);
root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form)));
pw1.focus();
})();
+86
View File
@@ -52,6 +52,49 @@ const I18N_DATA = {
username_taken: "Username already taken",
prior_penalties: "Prior penalties for this pilot and rule",
none: "None",
applied: "Applied",
apply_by_task: "Apply by task",
apply_by_task_explain: "Confirm all open penalties for a task at once. Penalties are only marked applied after confirmation.",
apply_n_open: "Apply {n} open",
confirm_apply_task: "Mark all {n} open penalties for task '{task}' as applied?",
summary: "Summary",
penalty_summary: "Penalty summary",
rule: "Rule",
rule_not_found: "rule not found",
close: "Close",
count: "#",
count_hint: "Number of prior penalties for this pilot and rule",
prior_count: "Prior count (this pilot & rule)",
search_penalties: "Search penalties…",
filter_all: "All",
filter_open: "Open only",
filter_applied: "Applied only",
total: "Total", open: "Open",
repeat_password: "Repeat password",
password_too_short: "Password must be at least 6 characters",
passwords_dont_match: "Passwords do not match",
too_many_attempts: "Too many login attempts — please wait a few minutes",
profile_username_readonly: "Username can only be changed by a system administrator",
profile_displayname_readonly: "Display name can only be changed by a system administrator",
must_change_password: "Must change password",
confirm_force_password: "Force this user to change their password on next request?",
force_password_change: "Force password change",
force_password_explain: "An administrator has required you to set a new password before you can continue.",
user_not_found: "User not found",
show_incidents: "Show incidents",
hide_incidents: "Hide incidents",
apply_select_task: "Pick a task to start applying its open penalties.",
no_open_penalties: "No open penalties to apply.",
no_task: "(no task)",
start_apply: "Start",
step_x_of_y: "Pilot {x} of {y}",
pilot: "Pilot",
next: "Next",
to_overview: "To overview",
apply_overview: "Overview",
apply_overview_explain: "{n} penalty/penalties will be marked applied on save.",
nothing_to_apply: "Nothing to apply.",
confirm_save_partial: "Save and apply {n} penalty/penalties reviewed so far?",
},
de: {
login_title: "Anmelden",
@@ -100,6 +143,49 @@ const I18N_DATA = {
username_taken: "Benutzername bereits vergeben",
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
none: "Keine",
applied: "Angewendet",
apply_by_task: "Pro Task anwenden",
apply_by_task_explain: "Bestätige alle offenen Strafen einer Aufgabe gemeinsam. Erst nach Bestätigung gelten die Strafen als angewendet.",
apply_n_open: "{n} offene anwenden",
confirm_apply_task: "Alle {n} offenen Strafen der Aufgabe '{task}' als angewendet markieren?",
summary: "Übersicht",
penalty_summary: "Strafen-Übersicht",
rule: "Regel",
rule_not_found: "Regel nicht gefunden",
close: "Schließen",
count: "#",
count_hint: "Anzahl früherer Strafen für diesen Piloten und diese Regel",
prior_count: "Frühere Anzahl (dieser Pilot & Regel)",
search_penalties: "Strafen durchsuchen…",
filter_all: "Alle",
filter_open: "Nur offene",
filter_applied: "Nur angewendete",
total: "Gesamt", open: "Offen",
repeat_password: "Passwort wiederholen",
password_too_short: "Passwort muss mindestens 6 Zeichen lang sein",
passwords_dont_match: "Passwörter stimmen nicht überein",
too_many_attempts: "Zu viele Anmeldeversuche — bitte ein paar Minuten warten",
profile_username_readonly: "Der Benutzername kann nur vom Systemadministrator geändert werden",
profile_displayname_readonly: "Der Anzeigename kann nur vom Systemadministrator geändert werden",
must_change_password: "Passwortwechsel erforderlich",
confirm_force_password: "Diesen Benutzer beim nächsten Zugriff zum Passwortwechsel zwingen?",
force_password_change: "Passwortwechsel erzwingen",
force_password_explain: "Ein Administrator hat festgelegt, dass du ein neues Passwort vergeben musst, bevor du fortfahren kannst.",
user_not_found: "Benutzer nicht gefunden",
show_incidents: "Vorfälle anzeigen",
hide_incidents: "Vorfälle ausblenden",
apply_select_task: "Aufgabe wählen, deren offene Strafen angewendet werden sollen.",
no_open_penalties: "Keine offenen Strafen zum Anwenden.",
no_task: "(ohne Aufgabe)",
start_apply: "Start",
step_x_of_y: "Pilot {x} von {y}",
pilot: "Pilot",
next: "Weiter",
to_overview: "Zur Übersicht",
apply_overview: "Übersicht",
apply_overview_explain: "Beim Speichern werden {n} Strafe(n) als angewendet markiert.",
nothing_to_apply: "Nichts anzuwenden.",
confirm_save_partial: "{n} bisher überprüfte Strafe(n) jetzt speichern und anwenden?",
},
pl: {
login_title: "Zaloguj się",
+11 -2
View File
@@ -7,10 +7,19 @@
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<div class="loading-wrap"><div class="muted"></div></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/app.js"></script>
<script src="/common.js"></script>
<script>
(async () => {
let user = null;
try { user = await API.me(); } catch (e) {}
if (!user) navigate("login");
else if (user.must_change_password) navigate("forcePassword");
else navigate("competitions");
})();
</script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Sign in</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/login.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
(async function () {
const root = document.getElementById("app");
// If already signed in, skip the login form.
try {
const u = await API.me();
if (u) {
navigate(u.must_change_password ? "forcePassword" : "competitions");
return;
}
} catch (e) {}
function render() {
clearNode(root);
const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username"),
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") });
const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
const langSelect = el("select",
{ onchange: (e) => { setLang(e.target.value); render(); } },
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l]))
);
async function doLogin(e) {
e.preventDefault();
errorBox.style.display = "none";
try {
const u = await API.login(usernameInput.value, passwordInput.value);
if (u.must_change_password) navigate("forcePassword");
else navigate("competitions");
} catch (err) {
if (err.status === 429) errorBox.textContent = t("too_many_attempts");
else if (err.status === 401) errorBox.textContent = t("invalid_credentials");
else errorBox.textContent = err.message || t("invalid_credentials");
errorBox.style.display = "block";
}
}
const form = el("form", { onsubmit: doLogin, class: "col" },
el("h1", null, t("login_title")),
el("div", { class: "field" }, el("label", null, t("username")), usernameInput),
el("div", { class: "field" }, el("label", null, t("password")), passwordInput),
errorBox,
el("button", { type: "submit", class: "primary" }, t("sign_in")),
el("div", { class: "field", style: { marginTop: "0.5rem" } },
el("label", null, t("language")), langSelect
),
);
root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form)));
usernameInput.focus();
}
render();
})();
+17
View File
@@ -397,3 +397,20 @@ textarea { resize: vertical; min-height: 60px; }
.topbar { padding: 0.5rem 0.75rem; }
.container { padding: 0.75rem; }
}
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.muted.small,
.small { font-size: 0.75rem; }
.apply-row { gap: 1rem; }
h4 { margin: 0.75rem 0 0.25rem 0; font-size: 0.95rem; }
input[disabled],
button[disabled] { opacity: 0.55; cursor: not-allowed; }