SSO and turn gate

This commit is contained in:
2026-10-30 16:36:36 -04:00
parent c4d9d1b86c
commit b96be7076e
8 changed files with 273 additions and 132 deletions
+32 -55
View File
@@ -1,6 +1,5 @@
"use strict";
const TOKEN_KEY = "thebutton-token";
const $ = (id) => document.getElementById(id);
const callbackParams = new URLSearchParams(window.location.hash.slice(1));
@@ -11,10 +10,10 @@ if (callbackError) {
let game = null; // latest /api/state payload
let clockOffset = 0; // serverTime - clientTime
let token = localStorage.getItem(TOKEN_KEY);
let oidcMode = false;
let oidcLoginUrl = "";
let oidcRegisterUrl = "";
let token = false;
let oidcLoginUrl = "/auth/oidc/login";
let oidcRegisterUrl = "https://lists.eom.dev/accounts/register/";
localStorage.removeItem("thebutton-token");
let statsLoaded = false;
let subscribed = false;
let currentShareText = "";
@@ -198,7 +197,6 @@ function syncPresence() {
if (token && sharePresence) $("presence-error").textContent = "Couldn't update online status. Retrying automatically.";
if (error.status === 401) {
token = null;
localStorage.removeItem(TOKEN_KEY);
lastPresenceUpdate = null;
updateButton();
}
@@ -218,13 +216,10 @@ window.addEventListener("storage", (event) => {
alertObservation = null;
renderAlertSettings();
}
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
if (event.key === PRESENCE_KEY) {
sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
if (event.key === TOKEN_KEY) isAdmin = false;
token = localStorage.getItem(TOKEN_KEY);
updateButton();
syncPresence();
if (event.key === TOKEN_KEY && token) loadMe();
}
});
@@ -247,19 +242,16 @@ function renderSections() {
const id = button.dataset.section;
button.hidden = !available[id];
button.setAttribute("aria-expanded", String(selectedSection === id));
if (id === "account-section") button.textContent = token ? "Your score" : oidcMode ? "Sign in" : "Log in / Join";
if (id === "account-section") button.textContent = token ? "Your score" : "Sign in";
});
document.querySelectorAll("[data-panel]").forEach((panel) => {
panel.hidden = panel.id !== selectedSection;
});
$("mailing-list").hidden = !token || subscribed;
$("register-section").hidden = !!token || oidcMode;
$("oidc-login-section").hidden = !!token || !oidcMode;
if (oidcMode) {
$("oidc-login-link").href = oidcLoginUrl;
$("oidc-register-link").href = oidcRegisterUrl || "#";
$("oidc-register-link").hidden = !oidcRegisterUrl;
}
$("oidc-login-section").hidden = !!token;
$("oidc-login-link").href = oidcLoginUrl;
$("oidc-register-link").href = oidcRegisterUrl || "#";
$("oidc-register-link").hidden = !oidcRegisterUrl;
$("chat-form").hidden = !token;
$("chat-login").hidden = !!token;
$("section-hint").hidden = selectedSection !== null;
@@ -282,7 +274,6 @@ $("section-nav").addEventListener("click", (event) => {
async function api(method, path, body) {
const headers = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
@@ -449,7 +440,18 @@ async function refreshState() {
}
function updateButton() {
$("the-button").disabled = !token || !game || game.ended;
const eligibility = game?.press_eligibility;
$("the-button").disabled = !token || !game || game.ended || eligibility?.eligible !== true;
$("press-eligibility").hidden = !token;
if (token) {
$("press-eligibility").textContent = !eligibility
? "Checking when you can press..."
: !eligibility.has_pressed
? "Your first press this season is ready."
: eligibility.eligible
? "Your turn is ready: two different players have pressed since your last turn."
: `Waiting for ${eligibility.other_players_remaining} more different ${eligibility.other_players_remaining === 1 ? "player" : "players"} to press.`;
}
$("me-section").hidden = !token;
$("my-subscribed").hidden = !token || !subscribed;
renderSections();
@@ -512,11 +514,11 @@ function renderAchievements(achievements) {
}
async function loadMe() {
if (!token) return;
const requestedToken = token;
try {
const me = await api("GET", "/api/me");
if (token !== requestedToken) return;
if (token !== requestedToken && token) return;
token = true;
isAdmin = me.is_admin === true;
$("my-name").textContent = me.name;
$("my-points").textContent = me.total_points.toLocaleString();
@@ -549,6 +551,7 @@ async function loadMe() {
}
subscribed = me.subscribed;
updateButton();
syncPresence();
fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts));
fillTable(
$("my-history"),
@@ -561,7 +564,6 @@ async function loadMe() {
if (e.status === 401) {
token = null;
isAdmin = false;
localStorage.removeItem(TOKEN_KEY);
updateButton();
}
}
@@ -879,7 +881,6 @@ $("chat-form").addEventListener("submit", async (event) => {
$("chat-error").textContent = error.message;
if (error.status === 401) {
token = null;
localStorage.removeItem(TOKEN_KEY);
updateButton();
}
} finally {
@@ -934,6 +935,7 @@ $("the-button").addEventListener("click", async () => {
try {
const r = await api("POST", "/api/press");
game.deadline = r.deadline;
game.press_eligibility = r.press_eligibility;
currentShareText = `I pushed #TheButton at ${formatPrecise(r.press.remaining_ms)}.`;
$("press-result").textContent =
`You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`;
@@ -954,15 +956,10 @@ $("mastodon-instance").addEventListener("input", updateShareLinks);
async function loadAuthConfig() {
try {
const response = await fetch("/api/config", { cache: "no-store" });
if (!response.ok) return;
if (!response.ok) throw new Error("Identity configuration is unavailable");
const config = await response.json();
oidcMode = config.auth_mode === "oidc";
if (oidcMode) {
token = null;
localStorage.removeItem(TOKEN_KEY);
}
oidcLoginUrl = config.login_url || "";
oidcRegisterUrl = config.register_url || "";
oidcLoginUrl = config.login_url || "/auth/oidc/login";
oidcRegisterUrl = config.register_url || "https://lists.eom.dev/accounts/register/";
renderSections();
if (callbackError) {
const errorMessage = callbackError === "access_denied"
@@ -973,36 +970,16 @@ async function loadAuthConfig() {
$("oidc-error").textContent = errorMessage;
}
} catch {
// Keep the local login UI available if the server has no OIDC config endpoint.
// Never fall back to local credentials when Dex is unavailable.
renderSections();
}
}
$("register-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
$("register-error").textContent = "";
const action = ev.submitter?.value === "register" ? "register" : "login";
try {
const r = await api("POST", `/api/${action}`, {
name: $("name-input").value,
password: $("password-input").value,
});
token = r.token;
localStorage.setItem(TOKEN_KEY, token);
syncPresence();
$("password-input").value = "";
updateButton();
await loadMe();
await loadLeaderboard();
} catch (e) {
$("register-error").textContent = e.message;
}
});
$("logout-button").addEventListener("click", async () => {
await api("POST", "/api/logout").catch(() => {});
token = null;
subscribed = false;
localStorage.removeItem(TOKEN_KEY);
isAdmin = false;
$("me-section").hidden = true;
$("press-result").textContent = "";
$("subscribe-result").textContent = "";
+4 -26
View File
@@ -34,6 +34,7 @@
<section id="button-section">
<button id="the-button" class="zone-green" disabled>PRESS</button>
<p class="button-caption">Every press buys everyone more time.</p>
<p id="press-eligibility" class="fine-print" role="status" hidden></p>
<p id="press-result" aria-live="polite"></p>
</section>
</div>
@@ -41,7 +42,7 @@
<div class="section-browser">
<div class="browser-heading"><span class="eyebrow">MAKE YOURSELF AT HOME</span><span class="site-badge">The Button / 01</span></div>
<nav id="section-nav" aria-label="Game sections">
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Log in / Join</button>
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Sign in</button>
<button type="button" data-section="leaderboard-section" aria-controls="leaderboard-section" aria-expanded="false">Leaderboard</button>
<button type="button" data-section="seasons-section" aria-controls="seasons-section" aria-expanded="false">Seasons</button>
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
@@ -141,29 +142,6 @@
</section>
<section id="account-section" data-panel hidden>
<div id="register-section" hidden>
<header class="panel-header">
<p class="eyebrow">YOUR NEXT MOVE</p>
<h2>Take your place.</h2>
<p class="panel-description">Choose a name. Trust your timing. Every press is part of the story.</p>
</header>
<div class="panel-body">
<form id="register-form">
<label class="field" for="name-input"><span>Player name</span>
<input id="name-input" maxlength="24" placeholder="Your name" required autocomplete="username">
</label>
<label class="field" for="password-input"><span>Password</span>
<input id="password-input" type="password" minlength="8" maxlength="128" placeholder="Password (8+ chars)" required autocomplete="current-password">
</label>
<div class="form-actions"><button class="primary-button" type="submit" value="login">Log in <span aria-hidden="true">&rarr;</span></button>
<button type="submit" value="register">Sign up</button>
</div>
</form>
<p id="register-error" class="error" role="status"></p>
<p class="fine-print">Already playing? Log in with the same name and password to pick up where you left off.</p>
</div>
</div>
<div id="oidc-login-section" class="panel-body" hidden>
<p class="fine-print">Your account is managed by eom.dev. Sign in with the same identity used for your other services.</p>
<div class="form-actions"><a id="oidc-login-link" class="primary-button" href="/auth/oidc/login">Sign in with eom.dev <span aria-hidden="true">&rarr;</span></a></div>
@@ -246,7 +224,7 @@
<p id="chat-status" role="status">Loading messages&hellip;</p>
</div>
<div class="chat-composer">
<p id="chat-login">Pull up a seat. Log in or join the game to say hello.</p>
<p id="chat-login">Pull up a seat. Sign in with eom.dev to say hello.</p>
<form id="chat-form" hidden>
<label for="chat-input">Add to the conversation</label>
<div class="chat-input-row">
@@ -284,7 +262,7 @@
<div class="panel-body">
<ol class="rules-list">
<li><div><h3>Keep the clock alive</h3><p>Every press resets the countdown for everyone.</p></div></li>
<li><div><h3>Wait for your moment</h3><p>The lower the countdown, the more you score. Each zone awards a fixed number of points per press.</p></div></li>
<li><div><h3>Wait for your moment</h3><p>The lower the countdown, the more you score. Each zone awards a fixed number of points per press. After your first press each season, two different players must press before you can press again.</p></div></li>
<li><div><h3>Don't wait too long</h3><p>If the countdown reaches zero, this season's game ends. The next equinox or solstice starts a new countdown and resets scores. Accounts and chat stay.</p></div></li>
</ol>
<h3 class="subheading">Six zones. Increasing stakes.</h3>
-1
View File
@@ -273,7 +273,6 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
.field > span { color: var(--subtext); font-size: 0.75rem; font-weight: 600; }
.field small { font-weight: 400; }
.field input { width: 100%; }
#register-form { display: grid; gap: 1rem; max-width: 420px; justify-content: stretch; }
.form-actions { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-top: 0.25rem; }
.form-actions button { flex: 1; }
#logout-button { flex-shrink: 0; font-size: 0.75rem; }