Add System Admin area and redesign Competitions page

Move the Users table and add a new rule-text editor into a dedicated
admin.html/admin.js page with its own collapsible sidebar (Users,
Rules categories), reachable only for system admins via a button next
to the brand. The Competitions page no longer renders admin-only
controls inline.

Competitions page: cards are now fully clickable to open the
competition (drop the separate "Open" button), the admin-only delete
action moves into a "..." menu (new generic openInlineMenu/
openAnchoredPopover helpers in common.js), and add an open/closed
filter.

Topbar: language switch and logout move out of the topbar into a
small popup under the profile button, which itself opens the full
settings modal for username/display name/language/password.
This commit is contained in:
2026-09-05 13:33:05 +02:00
parent e077703d5d
commit ae035c3fd4
8 changed files with 631 additions and 173 deletions
+49 -149
View File
@@ -3,14 +3,11 @@
const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
if (!user) return;
const state = { competitions: [], users: [] };
const state = { competitions: [], filter: "all" };
async function loadCompetitions() {
state.competitions = await API.listCompetitions();
}
async function loadUsers() {
if (user.is_system_admin) state.users = await API.listUsers();
}
async function openCompetitionModal() {
const backdrop = el("div", { class: "modal-backdrop",
@@ -53,178 +50,81 @@
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 openCardMenu(anchorEl, c) {
openInlineMenu(anchorEl, [
{ label: t("delete"), danger: true, onClick: async () => {
if (!confirm(t("confirm_delete_competition_named", { name: c.name }))) return;
try {
await API.deleteCompetition(c.id);
await loadCompetitions();
render();
} catch (e) { alert((e.data && e.data.error) || e.message); }
} },
]);
}
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 matchesFilter(c) {
if (state.filter === "open") return !c.closed;
if (state.filter === "closed") return !!c.closed;
return true;
}
function render() {
clearNode(root);
root.appendChild(renderTopbar(user));
const container = el("div", { class: "container" });
const filterSelect = el("select", { onchange: (e) => { state.filter = e.target.value; render(); } },
el("option", { value: "all", selected: state.filter === "all" }, t("filter_all")),
el("option", { value: "open", selected: state.filter === "open" }, t("open")),
el("option", { value: "closed", selected: state.filter === "closed" }, t("closed")),
);
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")),
el("div", { class: "row" },
filterSelect,
user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")),
),
));
if (state.competitions.length === 0) {
const filtered = state.competitions.filter(matchesFilter);
if (filtered.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" } },
for (const c of filtered) {
const menuBtn = user.is_system_admin
? el("button", { class: "ghost action-btn", title: t("actions"), onclick: (e) => {
e.stopPropagation();
openCardMenu(e.currentTarget, c);
} }, "⋮")
: null;
grid.appendChild(el("div", {
class: "card clickable",
tabindex: "0",
onclick: () => navigate("competition", { id: c.id }),
onkeydown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigate("competition", { id: c.id }); } },
},
el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center", flexWrap: "nowrap" } },
el("h2", { title: c.name, style: { margin: 0, minWidth: 0, flex: "1", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name),
menuBtn && el("div", { style: { flexShrink: "0" } }, menuBtn),
),
el("div", { class: "muted", style: { marginTop: "0.5rem" } },
el("span", { class: "badge accent" }, t(c.role)),
c.closed ? " " : null,
c.closed ? el("span", { class: "badge warn" }, t("closed")) : null,
),
el("div", { class: "row", style: { gap: "0.5rem" } },
el("button", { class: "primary", onclick: () => navigate("competition", { id: c.id }) }, t("open")),
user.is_system_admin && el("button", { class: "danger", onclick: async () => {
if (!confirm(t("confirm_delete_competition_named", { name: c.name }))) return;
try {
await API.deleteCompetition(c.id);
await loadCompetitions();
render();
} catch (e) { alert((e.data && e.data.error) || e.message); }
} }, t("delete")),
),
));
}
container.appendChild(grid);
}
if (user.is_system_admin) {
container.appendChild(renderUsersAdmin());
}
root.appendChild(container);
}
await loadCompetitions();
await loadUsers();
render();
})();