Add username/password auth and login UI

This commit is contained in:
2026-01-29 01:01:13 +01:00
parent ca25d4f0ee
commit f1534b7631
21 changed files with 690 additions and 50 deletions

View File

@@ -1,6 +1,8 @@
import { api, adminApi } from "./js/api.js";
const state = {
isAuthenticated: false,
authMode: "login",
me: null,
phase: null,
counts: null,
@@ -21,11 +23,54 @@ function toast(msg, isError = false) {
setTimeout(() => toastEl.classList.add("hidden"), 2000);
}
function setAuthUI(isAuthed) {
const main = document.querySelector("main");
const statusBar = document.querySelector(".status-bar");
const authCard = $("auth-card");
[main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed));
if (authCard) authCard.classList.toggle("hidden", isAuthed);
const adminToggle = $("admin-toggle");
if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed);
}
function setAuthMode(mode) {
state.authMode = mode;
document.querySelectorAll(".auth-form").forEach(form => {
form.classList.toggle("hidden", form.dataset.mode !== mode);
});
document.querySelectorAll("[data-auth-tab]").forEach(btn => {
btn.classList.toggle("active", btn.dataset.authTab === mode);
});
}
function clearUserState() {
state.me = null;
state.phase = null;
state.counts = null;
state.mySuggestions = [];
state.allSuggestions = [];
state.myVotes = [];
state.results = [];
}
function handleAuthError(err) {
if (err?.status === 401) {
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
return true;
}
toast(err?.message || "Unexpected error", true);
return false;
}
async function loadState() {
const [me, stateData] = await Promise.all([api.me(), api.state()]);
state.isAuthenticated = true;
state.me = me;
state.phase = stateData.currentPhase;
state.counts = stateData;
setAuthUI(true);
renderPhasePill();
renderCounts();
const nameInput = $("name-input");
@@ -181,6 +226,52 @@ function renderResults() {
}
function setupHandlers() {
document.querySelectorAll("[data-auth-tab]").forEach(btn => {
btn.addEventListener("click", () => setAuthMode(btn.dataset.authTab));
});
setAuthMode(state.authMode);
const loginForm = $("login-form");
if (loginForm) {
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("login-username").value.trim();
const password = $("login-password").value;
if (!username || !password) return toast("Username and password required", true);
try {
await api.login({ username, password });
state.isAuthenticated = true;
setAuthUI(true);
await refreshPhaseData();
toast("Logged in");
} catch (err) {
if (err?.status === 401) return toast("Invalid username or password", true);
if (handleAuthError(err)) return;
}
});
}
const registerForm = $("register-form");
if (registerForm) {
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("register-username").value.trim();
const password = $("register-password").value;
const displayName = $("register-displayName").value.trim();
if (!username || !password) return toast("Username and password required", true);
try {
await api.register({ username, password, displayName });
state.isAuthenticated = true;
setAuthUI(true);
await refreshPhaseData();
toast("Registered");
} catch (err) {
if (handleAuthError(err)) return;
toast(err.message, true);
}
});
}
const nameInput = $("name-input");
if (nameInput) {
["focus", "input"].forEach(evt => {
@@ -241,6 +332,20 @@ function setupHandlers() {
$("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete"));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete"));
const logoutBtn = $("logout");
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
try {
await api.logout();
} catch (err) {
toast(err.message, true);
}
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
});
}
const adminToggle = $("admin-toggle");
const adminCard = $("admin-card");
const adminClose = $("admin-close");
@@ -263,8 +368,13 @@ async function adminAction(fn, successMessage) {
}
async function refreshPhaseData() {
await loadState();
await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]);
try {
await loadState();
await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]);
} catch (err) {
if (handleAuthError(err)) return;
throw err;
}
}
function buildCard(s, { showAuthor = false, allowDelete = false }) {
@@ -328,6 +438,7 @@ function openLightbox(url, title) {
}
function applyNameRequirementUI() {
if (!state.isAuthenticated) return;
const requiresName = !state.me?.displayName?.trim();
const warning = $("name-warning");
if (warning) warning.classList.toggle("hidden", !requiresName);
@@ -359,7 +470,11 @@ async function main() {
} catch (err) {
toast(err.message, true);
}
setInterval(refreshPhaseData, 4000);
setInterval(() => {
refreshPhaseData().catch(err => {
if (!handleAuthError(err)) toast(err.message, true);
});
}, 4000);
}
main();

View File

@@ -9,11 +9,30 @@
<meta name="app-base" content="">
</head>
<body>
<section class="card hidden" id="auth-card">
<div class="stack horizontal">
<button class="ghost active" data-auth-tab="login" type="button">Log in</button>
<button class="ghost" data-auth-tab="register" type="button">Register</button>
</div>
<form id="login-form" class="stack auth-form" data-mode="login">
<input id="login-username" name="username" maxlength="64" placeholder="Username" autocomplete="username" required />
<input id="login-password" name="password" type="password" placeholder="Password" autocomplete="current-password" required />
<button type="submit">Log in</button>
</form>
<form id="register-form" class="stack auth-form hidden" data-mode="register">
<input id="register-username" name="username" maxlength="64" placeholder="Username" autocomplete="username" required />
<input id="register-password" name="password" type="password" placeholder="Password" autocomplete="new-password" required />
<input id="register-displayName" name="displayName" maxlength="64" placeholder="Display name (shows to group)" />
<button type="submit">Create account</button>
</form>
</section>
<div class="status-bar">
<div class="name-bar">
<label for="name-input" class="label">Name</label>
<input id="name-input" maxlength="64" placeholder="Pick a name" />
<button id="save-name" class="ghost">Save</button>
<button id="logout" class="ghost">Logout</button>
<span class="hint warning hidden" id="name-warning">Name required</span>
</div>
<div class="phase-bar">

View File

@@ -24,7 +24,9 @@ async function request(path, { method = "GET", body, adminKey } = {}) {
const data = await res.json();
msg = data.error || JSON.stringify(data);
} catch { /* ignore */ }
throw new Error(msg);
const err = new Error(msg);
err.status = res.status;
throw err;
}
return res.status === 204 ? null : res.json();
}
@@ -33,6 +35,9 @@ export const api = {
state: () => request("/api/state"),
me: () => request("/api/me"),
setName: (name) => request("/api/me/name", { method: "POST", body: { name } }),
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" }),
mySuggestions: () => request("/api/suggestions/mine"),
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),

View File

@@ -245,6 +245,9 @@ input[type="range"].full-slider::-moz-range-track {
}
.toast.error { background: #dc2626; }
.auth-card .active { font-weight: 700; }
.auth-form { margin-top: 8px; }
.admin-toggle {
position: fixed;
bottom: 18px;