SSO and turn gate
This commit is contained in:
+32
-55
@@ -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
@@ -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">→</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">→</span></a></div>
|
||||
@@ -246,7 +224,7 @@
|
||||
<p id="chat-status" role="status">Loading messages…</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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user