Replaced one-pager with multiple pages and fixed security bugs
This commit is contained in:
+8
-2
@@ -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
File diff suppressed because it is too large
Load Diff
+186
@@ -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");
|
||||
}
|
||||
})();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user