Add username/password auth and login UI
This commit is contained in:
121
wwwroot/app.js
121
wwwroot/app.js
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user