Add owner role and admin management controls
This commit is contained in:
@@ -14,6 +14,7 @@ Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen
|
||||
### Brauche ich Admin-Rechte?
|
||||
|
||||
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden.
|
||||
Sobald ein Owner-Konto existiert, wird das Admin-Schlüssel-Feld in der Registrierung nicht mehr angezeigt.
|
||||
|
||||
## Phasen im Überblick
|
||||
|
||||
@@ -152,6 +153,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
||||
- Joker während der Abstimmung vergeben
|
||||
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
|
||||
- Ergebniszugriff mit einem einzelnen Button umschalten (Beschriftung wechselt je nach Zustand)
|
||||
- Admin-Rechte für Nicht-Owner-Konten in der Spielertabelle vergeben oder entziehen
|
||||
- Doppelte Vorschläge verknüpfen oder trennen
|
||||
- Vorschläge löschen
|
||||
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
||||
@@ -163,6 +165,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
||||
### Was können Admin-Konten nicht tun?
|
||||
|
||||
- Einzelne Spielerbewertungen einsehen
|
||||
- Owner-Rechte entziehen oder das Owner-Konto löschen
|
||||
|
||||
Die Abstimmung bleibt anonym und fair.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ Your display name is required ‒ it appears next to all of your suggestions and
|
||||
|
||||
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
|
||||
Admin-key bootstrap is only available until the first admin account exists. Admin access cannot be added later. To become an admin afterward, an existing admin must create/manage access outside the public registration flow.
|
||||
Once an owner account exists, the registration form no longer shows the admin-key field.
|
||||
|
||||
## Phases at a Glance
|
||||
|
||||
@@ -156,6 +157,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
- Grant jokers during Vote
|
||||
- Move a voter back to Suggest (stronger than a joker; use sparingly)
|
||||
- Toggle results access with a single button (label switches by current state)
|
||||
- Grant or revoke admin role for any non-owner account from the player table
|
||||
- Link or unlink duplicate suggestions
|
||||
- Delete suggestions
|
||||
- View vote readiness (who has finalized)
|
||||
@@ -167,6 +169,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
### What can't admin accounts do?
|
||||
|
||||
- View individual player votes
|
||||
- Revoke owner permissions or delete the owner account
|
||||
|
||||
Voting remains anonymous and fair.
|
||||
|
||||
|
||||
@@ -124,13 +124,16 @@
|
||||
"admin.playerStatus": "Status",
|
||||
"admin.playerGames": "Games",
|
||||
"admin.playerJoker": "Joker",
|
||||
"admin.playerAdmin": "Admin",
|
||||
"admin.playerDelete": "Delete",
|
||||
"admin.owner": "owner",
|
||||
"admin.grantJokerChip": "Grant",
|
||||
"admin.statusSuggesting": "Suggesting",
|
||||
"admin.statusVoting": "Voting",
|
||||
"admin.statusFinished": "Finished",
|
||||
"admin.statusMoveToSuggest": "Move to Suggest",
|
||||
"admin.statusUpdated": "Player phase updated",
|
||||
"admin.roleUpdated": "Admin role updated",
|
||||
"admin.deleteTitle": "Delete account?",
|
||||
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
||||
"admin.deleteConfirm": "Delete",
|
||||
@@ -294,13 +297,16 @@
|
||||
"admin.playerStatus": "Status",
|
||||
"admin.playerGames": "Spiele",
|
||||
"admin.playerJoker": "Joker",
|
||||
"admin.playerAdmin": "Admin",
|
||||
"admin.playerDelete": "Löschen",
|
||||
"admin.owner": "owner",
|
||||
"admin.grantJokerChip": "Joker",
|
||||
"admin.statusSuggesting": "Vorschlagen",
|
||||
"admin.statusVoting": "Bewerten",
|
||||
"admin.statusFinished": "Fertig",
|
||||
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
|
||||
"admin.statusUpdated": "Spielerphase aktualisiert",
|
||||
"admin.roleUpdated": "Admin-Rolle aktualisiert",
|
||||
"admin.deleteTitle": "Konto löschen?",
|
||||
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"admin.deleteConfirm": "Löschen",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
|
||||
<input id="register-displayName" name="displayName" maxlength="16" required />
|
||||
</label>
|
||||
<label class="stack">
|
||||
<label class="stack" id="register-admin-key-field">
|
||||
<span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
|
||||
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
|
||||
</label>
|
||||
@@ -172,6 +172,7 @@
|
||||
<th data-i18n="admin.playerStatus">Status</th>
|
||||
<th data-i18n="admin.playerGames">Games</th>
|
||||
<th data-i18n="admin.playerJoker">Joker</th>
|
||||
<th data-i18n="admin.playerAdmin">Admin</th>
|
||||
<th data-i18n="admin.playerDelete">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -72,6 +72,21 @@ export function renderAdminVoteStatus() {
|
||||
jokerButton.textContent = v.hasJoker ? "🎟" : t("admin.grantJokerChip");
|
||||
jokerCell.appendChild(jokerButton);
|
||||
|
||||
const adminCell = document.createElement("td");
|
||||
if (v.isOwner) {
|
||||
const ownerLabel = document.createElement("span");
|
||||
ownerLabel.className = "muted small";
|
||||
ownerLabel.textContent = t("admin.owner");
|
||||
adminCell.appendChild(ownerLabel);
|
||||
} else {
|
||||
const adminCheckbox = document.createElement("input");
|
||||
adminCheckbox.type = "checkbox";
|
||||
adminCheckbox.dataset.setPlayerAdmin = v.playerId;
|
||||
adminCheckbox.checked = !!v.isAdmin;
|
||||
adminCheckbox.setAttribute("aria-label", t("admin.playerAdmin"));
|
||||
adminCell.appendChild(adminCheckbox);
|
||||
}
|
||||
|
||||
const deleteCell = document.createElement("td");
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "chip danger-chip";
|
||||
@@ -87,6 +102,7 @@ export function renderAdminVoteStatus() {
|
||||
statusCell,
|
||||
countCell,
|
||||
jokerCell,
|
||||
adminCell,
|
||||
deleteCell,
|
||||
);
|
||||
table.appendChild(tr);
|
||||
|
||||
@@ -34,6 +34,7 @@ async function request(path, { method = "GET", body } = {}) {
|
||||
export const api = {
|
||||
state: () => request("/api/state"),
|
||||
me: () => request("/api/me"),
|
||||
authOptions: () => request("/api/auth/options"),
|
||||
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }),
|
||||
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }),
|
||||
logout: () => request("/api/auth/logout", { method: "POST" }),
|
||||
@@ -61,6 +62,11 @@ export const adminApi = {
|
||||
factoryReset: (password) =>
|
||||
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
|
||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||
setPlayerAdmin: (playerId, isAdmin) =>
|
||||
request("/api/admin/player-admin", {
|
||||
method: "POST",
|
||||
body: { playerId, isAdmin },
|
||||
}),
|
||||
setPlayerPhase: (playerId, phase) =>
|
||||
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
||||
deletePlayer: (playerId, password) =>
|
||||
|
||||
@@ -127,6 +127,7 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
||||
const playerTable = $("admin-player-table");
|
||||
if (!playerTable) return;
|
||||
const phaseSelectSelector = "[data-set-player-phase]";
|
||||
const adminCheckboxSelector = "[data-set-player-admin]";
|
||||
|
||||
playerTable.addEventListener("focusin", (e) => {
|
||||
if (e.target.matches?.(phaseSelectSelector)) {
|
||||
@@ -144,6 +145,25 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
||||
});
|
||||
|
||||
playerTable.addEventListener("change", async (e) => {
|
||||
const adminCheckbox = e.target.closest(adminCheckboxSelector);
|
||||
if (adminCheckbox) {
|
||||
const playerId = adminCheckbox.dataset.setPlayerAdmin;
|
||||
if (!playerId) return;
|
||||
const previous = !adminCheckbox.checked;
|
||||
adminCheckbox.disabled = true;
|
||||
try {
|
||||
await adminApi.setPlayerAdmin(playerId, adminCheckbox.checked);
|
||||
toast(t("admin.roleUpdated"));
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
adminCheckbox.checked = previous;
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
adminCheckbox.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const select = e.target.closest(phaseSelectSelector);
|
||||
if (!select) return;
|
||||
const playerId = select.dataset.setPlayerPhase;
|
||||
|
||||
@@ -46,6 +46,29 @@ function setupAuthModeToggle() {
|
||||
setAuthMode(state.authMode);
|
||||
}
|
||||
|
||||
function applyRegistrationOptions(ownerExists) {
|
||||
state.ownerExists = !!ownerExists;
|
||||
const adminKeyField = $("register-admin-key-field");
|
||||
const adminKeyInput = $("register-adminkey");
|
||||
if (!adminKeyField || !adminKeyInput) return;
|
||||
|
||||
const hideAdminKeyInput = state.ownerExists;
|
||||
adminKeyField.classList.toggle("hidden", hideAdminKeyInput);
|
||||
adminKeyInput.disabled = hideAdminKeyInput;
|
||||
if (hideAdminKeyInput) {
|
||||
adminKeyInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRegistrationOptions() {
|
||||
try {
|
||||
const options = await api.authOptions();
|
||||
applyRegistrationOptions(options?.ownerExists);
|
||||
} catch {
|
||||
applyRegistrationOptions(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setupLoginUserEditingHint() {
|
||||
const loginUser = $("login-username");
|
||||
if (!loginUser) return;
|
||||
@@ -121,6 +144,7 @@ function setupRegisterFormHandlers({
|
||||
return toast(t("auth.cookieRequired"), true);
|
||||
try {
|
||||
await api.register({ username, password, displayName, adminKey });
|
||||
await refreshRegistrationOptions();
|
||||
setConsent();
|
||||
toggleConsentRows();
|
||||
setSavedUsername(username);
|
||||
@@ -152,6 +176,7 @@ function setupLogoutHandler() {
|
||||
clearUserState();
|
||||
state.isAuthenticated = false;
|
||||
setAuthUI(false);
|
||||
await refreshRegistrationOptions();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +203,7 @@ function setupSuggestionEntryButtons() {
|
||||
|
||||
export function setupAuthHandlers({ runSerializedRefresh }) {
|
||||
setupAuthModeToggle();
|
||||
refreshRegistrationOptions();
|
||||
const consent = setupConsentRows();
|
||||
setupLoginUserEditingHint();
|
||||
setupLoginFormHandlers({ ...consent, runSerializedRefresh });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const state = {
|
||||
isAuthenticated: false,
|
||||
ownerExists: false,
|
||||
authMode: "login",
|
||||
me: null,
|
||||
phase: null,
|
||||
@@ -19,6 +20,7 @@ export const state = {
|
||||
};
|
||||
|
||||
export function clearUserState() {
|
||||
state.ownerExists = false;
|
||||
state.me = null;
|
||||
state.phase = null;
|
||||
state.prevPhase = null;
|
||||
|
||||
Reference in New Issue
Block a user