From b96be7076ecae7fcc6db1cc357feb613196a9bd2 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Fri, 30 Oct 2026 16:36:36 -0400 Subject: [PATCH] SSO and turn gate --- .vscode/settings.json | 9 ++++ README.md | 40 ++++++++------ compose.yaml | 10 ++-- server.py | 105 +++++++++++++++++++++++++++--------- static/app.js | 87 +++++++++++------------------- static/index.html | 30 ++--------- static/style.css | 1 - test_server.py | 123 ++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 273 insertions(+), 132 deletions(-) create mode 100755 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100755 index 0000000..afe5d7d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python-envs.pythonProjects": [ + { + "path": ".", + "envManager": "ms-python.python:system", + "packageManager": "ms-python.python:pip" + } + ] +} diff --git a/README.md b/README.md index 37382f2..3358abb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Inspired by Reddit's [The Button](https://en.wikipedia.org/wiki/The_Button_(Redd ## How to play 1. Sign in with your eom.dev account through Dex. New accounts are registered in OpenLDAP from the eom.dev account portal. -2. Press the button. The countdown resets to 24 hours. +2. Press the button. The countdown resets to 24 hours. Your first press each season is always available; after that, two different players must press before you can press again. 3. You score points based on the zone the countdown was in when you pressed. The longer you wait, the more you score, but if everyone waits too long, the game ends. ### Zones @@ -25,6 +25,8 @@ Higher-value zones are shorter, so scoring big is risky. Every press records the zone, the points, and the exact remaining time in milliseconds. Players can view their own score, presses per zone, and press history at any time. A public live leaderboard shows all players ranked by total points, with press counts; ties are sorted alphabetically. Scores refresh every five seconds and immediately after you press or join. Detailed global stats (presses per zone, closest call, full press log) stay hidden until the game ends. +Press eligibility is enforced by the server and counts distinct player accounts, not sessions or repeated presses. The first press each season is allowed; after each successful press, two different players must press before that player can take another turn. Eligibility resets when the season resets. With fewer than three participating accounts, a player who has pressed may have to wait until another player joins. + Logged-in players can also join the separate email-only mailing list; that subscription does not require an LDAP/Dex account. The Chat section is a simple public chatroom. Logged-in players can post plain-text messages (up to 200 characters, one message every two seconds). The latest 100 messages are displayed oldest first and refresh every five seconds while Chat is open. Messages persist in SQLite across restarts, including after the game ends. Administrators can remove messages through the Admin dashboard; players cannot edit messages. @@ -37,7 +39,7 @@ Administrator controls are available in the **Admin** section after signing in w ### Assigning the first administrator -1. Upgrade/start the server once so it adds the new database fields, then create a regular account if needed. +1. Upgrade/start the server once so it adds the new database fields, then create a regular account through the eom.dev account portal and sign in through Dex if needed. 2. **Stop the server and back up the SQLite database.** The game uses an in-memory snapshot; editing flags while it runs is unsupported and can be overwritten. 3. Open the configured SQLite database with a trusted local SQLite editor. In Docker, the database lives in the persistent `/data` volume; stop the service before editing/copying it. Do not modify the legacy JSON backup. 4. Find the intended account's exact ID, then promote only that ID: @@ -63,7 +65,7 @@ The database fields `players.is_admin`, `players.is_banned`, and `players.is_del Account deletion is **not** a comprehensive personal-data erasure tool: the separate mailing-list CSV, backups, exported data, and third-party copies are not edited automatically. Handle those separately when necessary. Audit identifiers and anonymized game history are deliberately retained. Dashboard controls refresh manually to avoid moving focused actions unexpectedly; access is still rechecked by the server each time. No game reset/end controls are included. -Admin access uses the existing per-game session authorization, with cross-site request rejection when Dex mode is enabled and no permissive CORS. In Dex mode the OIDC flow establishes a game session only after server-side token validation; its opaque token is held in an HttpOnly, SameSite cookie and is not exposed to JavaScript, a URL fragment, or localStorage. POST requests require an exact same-origin `Origin`. Use HTTPS behind a reverse proxy before exposing the service; administrators should log out on shared devices. Local-login failure limits apply only when OIDC is disabled; require MFA at Dex for privileged identities. +Admin access uses the existing per-game session authorization, with cross-site request rejection and no permissive CORS. The OIDC flow establishes a game session only after server-side token validation; its opaque token is held in an HttpOnly, SameSite cookie and is not exposed to JavaScript, a URL fragment, or localStorage. POST requests require an exact same-origin `Origin`. Use HTTPS behind a reverse proxy before exposing the service; administrators should log out on shared devices and require MFA at Dex for privileged identities. Admin API: authenticated admin-only `GET /api/admin`; `POST /api/admin/action` accepts `{action, target_id, value?}` for `set_admin`, `set_banned`, and `delete_player`, or `{action: "delete_message", target_id, at}`. Boolean flags must be actual JSON booleans; IDs/timestamps must match the selected record. Anonymous requests receive 401, non-admin accounts 403, invalid inputs 400, missing targets 404, and protected actions 409. All JSON responses use `Cache-Control: no-store`. @@ -174,10 +176,14 @@ Presence tests cover authentication, validation, expiry and heartbeat renewal, t ### Docker (recommended) +Copy `.env.example` to a private `.env`, set the Dex client secret and required access group, then start the service: + ```sh docker compose up -d --build ``` +The service refuses to start without complete Dex configuration. There is no local-password fallback. + Open http://localhost:8000. The SQLite game database and the mailing list are stored in the `button-data` volume. Existing JSON game data in that volume is automatically migrated on the first startup after upgrading. ```sh @@ -189,7 +195,7 @@ docker compose down -v # stop and delete all ### Without Docker -Requires Python 3.9+. Install `requirements.txt` before running. OIDC mode uses Authlib, requests, and JOSE verification; install the dependencies even when local development temporarily runs without Dex. +Requires Python 3.9+. Install `requirements.txt` and set the required Dex variables from `.env.example` before running. The game has no local-password login mode. ```sh python server.py @@ -199,7 +205,7 @@ Open http://127.0.0.1:8000. Game state is stored in `data.sqlite3`. ### Migrating existing data -On startup, if the database has no saved game and `data.json` exists, the server automatically imports the countdown, players, password hashes, active sessions, subscription flags, and complete press history in one transaction. Older single-token player records are also supported. No manual conversion or additional dependencies are needed. +On startup, if the database has no saved game and `data.json` exists, the server automatically imports the countdown, players, legacy password hashes, active sessions, subscription flags, and complete press history in one transaction. Password hashes are retained for data compatibility but are never accepted for login. Older single-token player records are also supported. No manual conversion or additional dependencies are needed. The JSON file is left untouched as a backup. Once the database contains a game, it is authoritative: subsequent restarts never re-import the JSON file. Invalid source data causes startup to fail rather than silently starting a new game; fix the source and restart to retry. The mailing-list CSV is unchanged. @@ -219,18 +225,18 @@ Set via environment variables (in Docker, add them under `environment:` in `comp | `BUTTON_DATA_FILE` | `data.json` | Legacy JSON file to import if no saved SQLite game exists. | | `BUTTON_DATABASE_FILE` | `data.sqlite3` | SQLite game database; defaults to `BUTTON_DATA_FILE` with a `.sqlite3` suffix. | | `BUTTON_MAILING_LIST_FILE` | `mailing_list.csv` | Mailing list file. | -| `BUTTON_OIDC_ISSUER` | unset | Dex issuer URL (HTTPS). Enables OIDC mode when configured. | -| `BUTTON_OIDC_CLIENT_ID` | unset | Dex OIDC client ID registered for The Button. | -| `BUTTON_OIDC_CLIENT_SECRET` | unset | Client secret; provide through a private deployment secret, not Git. | -| `BUTTON_OIDC_REDIRECT_URI` | unset | Exact HTTPS callback URI registered in Dex, e.g. `https://button.eom.dev/auth/oidc/callback`. | -| `BUTTON_OIDC_REQUIRED_GROUP` | unset | Required Dex `groups` claim value for The Button access. Required in OIDC mode. | +| `BUTTON_OIDC_ISSUER` | required | Dex issuer URL (HTTPS), normally `https://dex.eom.dev`. | +| `BUTTON_OIDC_CLIENT_ID` | required | Dex OIDC client ID registered for The Button (`the-button`). | +| `BUTTON_OIDC_CLIENT_SECRET` | required | Client secret; provide through a private deployment secret, not Git. | +| `BUTTON_OIDC_REDIRECT_URI` | required | Exact HTTPS callback URI registered in Dex, normally `https://button.eom.dev/auth/oidc/callback`. | +| `BUTTON_OIDC_REQUIRED_GROUP` | required | Required Dex `groups` claim value for The Button access. | | `BUTTON_OIDC_ADMIN_GROUP` | unset | Optional Dex group granting The Button administrator role. | | `BUTTON_OIDC_SUBJECT_LINKS_JSON` | `{}` | Temporary map from OIDC `sub` values to existing player IDs, preserving game history. | | `BUTTON_OIDC_ADMIN_SUBJECTS_JSON` | `[]` | Optional explicit Dex subject allowlist for bootstrap administrators. | | `BUTTON_OIDC_REGISTER_URL` | account portal URL | Link to account registration. | | `BUTTON_OIDC_COOKIE_SECURE` | `1` | Mark the OIDC state cookie Secure; disable only for isolated local tests. | -Copy `.env.example` to a private deployment environment and register the exact callback URI with Dex. Request `openid`, `profile`, `email`, and `groups` scopes; configure Dex to emit a stable `sub`, verified email, a usable `preferred_username`, and the required service group. OIDC mode disables `/api/register` and `/api/login`; when OIDC is not configured, the legacy local login remains available for development. +Copy `.env.example` to a private deployment environment and register the exact callback URI with Dex. Request `openid`, `profile`, `email`, and `groups` scopes; configure Dex to emit a stable `sub`, verified email, `preferred_username` (the LDAP `uid`), the LDAP `displayName` as the OIDC `name` claim, and the required service group. If `displayName` is empty, the player name falls back to `uid`. Dex is mandatory: the server fails closed if its configuration is missing, and local password login and registration are not available. Use Dex `sub` as the immutable identity key. Do not automatically link a local player just because an email or display name matches. For score/history continuity, obtain the correct Dex subject through a trusted admin process, map it to that existing player ID with `BUTTON_OIDC_SUBJECT_LINKS_JSON`, have the player sign in once, then remove the temporary mapping after `oidc_sub` is saved. Never replace player IDs or rewrite press/season history. A name collision without an explicit subject link is rejected rather than silently merging accounts. @@ -243,8 +249,8 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing. | Method | Path | Auth | Description | |--------|------------------|------|-----------------------------------------------| | GET | `/api/state` | | Deadline, server time, zones, whether ended | -| POST | `/api/register` | | `{name, password}` → `{name, token}` | -| POST | `/api/login` | | `{name, password}` → `{name, token}` | +| POST | `/api/register` | | Retired; returns 403. Create accounts through the eom.dev portal. | +| POST | `/api/login` | | Retired; returns 403. Sign in through Dex. | | POST | `/api/logout` | ✓ | Revoke the current session token | | POST | `/api/press` | ✓ | Press the button | | GET | `/api/me` | ✓ | Your score, zone counts, and press history | @@ -257,13 +263,13 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing. | GET | `/api/stats` | | Global stats (403 until the game ends) | | POST | `/api/subscribe` | ✓ | `{first_name, email, last_name?, phone?}` | -In local-login mode, authenticated requests use `Authorization: Bearer `. In Dex mode, the browser uses the server-issued HttpOnly session cookie instead; JavaScript does not receive the bearer token. +Authenticated requests use the server-issued HttpOnly session cookie; JavaScript does not receive a bearer token. Dex is the only login method. ## Security notes -- Legacy local passwords are hashed with PBKDF2-SHA256 (600k iterations); session tokens are stored only as SHA-256 hashes. -- Logins lock for 15 minutes after 5 failed attempts per name. -- With Dex configured, legacy local login/register endpoints are disabled. New Button players are linked to stable Dex `sub` values; passwords remain in OpenLDAP and are never copied to The Button. Existing local hashes remain for migration/rollback until explicitly retired. +- Legacy local passwords are hashed with PBKDF2-SHA256 (600k iterations) for import compatibility only; session tokens are stored only as SHA-256 hashes. +- Legacy local logins previously locked for 15 minutes after 5 failed attempts per name; those endpoints are now retired. +- New Button players are linked to stable Dex `sub` values; passwords remain in OpenLDAP and are never copied to The Button. Imported local hashes remain stored for migration compatibility but cannot authenticate. - Configure `BUTTON_OIDC_REQUIRED_GROUP` to a Dex group backed by the appropriate OpenLDAP `groupOfUniqueNames`. The account portal updates that group only after confirming the registration email. - The account portal accepts only allowlisted service targets. After email confirmation, the password is sent via LDAP Password Modify over verified TLS; slapd generates the hash. Production provisioning is gated on `LDAP_EXPECTED_PASSWORD_SCHEME={ARGON2}`. This is an operator acknowledgement, not a live read of `cn=config`; independently verify the server's Argon2 module and `olcPasswordHash` setting. - Do not use MD5-crypt, `{CRYPT}`, MD5, or cleartext for new accounts. Current osixia OpenLDAP v2 documentation includes Argon2 support/defaults; v1 may require a deliberate data/config migration before enabling account provisioning. diff --git a/compose.yaml b/compose.yaml index 91c0369..69e3248 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,11 +2,11 @@ services: thebutton: build: . environment: - BUTTON_OIDC_ISSUER: ${BUTTON_OIDC_ISSUER:-} - BUTTON_OIDC_CLIENT_ID: ${BUTTON_OIDC_CLIENT_ID:-} - BUTTON_OIDC_CLIENT_SECRET: ${BUTTON_OIDC_CLIENT_SECRET:-} - BUTTON_OIDC_REDIRECT_URI: ${BUTTON_OIDC_REDIRECT_URI:-} - BUTTON_OIDC_REQUIRED_GROUP: ${BUTTON_OIDC_REQUIRED_GROUP:-} + BUTTON_OIDC_ISSUER: ${BUTTON_OIDC_ISSUER:?Set the Dex issuer URL} + BUTTON_OIDC_CLIENT_ID: ${BUTTON_OIDC_CLIENT_ID:?Set the Dex client ID} + BUTTON_OIDC_CLIENT_SECRET: ${BUTTON_OIDC_CLIENT_SECRET:?Set the private Dex client secret} + BUTTON_OIDC_REDIRECT_URI: ${BUTTON_OIDC_REDIRECT_URI:?Set the registered Dex callback URI} + BUTTON_OIDC_REQUIRED_GROUP: ${BUTTON_OIDC_REQUIRED_GROUP:?Set the required Dex access group} BUTTON_OIDC_ADMIN_GROUP: ${BUTTON_OIDC_ADMIN_GROUP:-} BUTTON_OIDC_SUBJECT_LINKS_JSON: ${BUTTON_OIDC_SUBJECT_LINKS_JSON:-} BUTTON_OIDC_ADMIN_SUBJECTS_JSON: ${BUTTON_OIDC_ADMIN_SUBJECTS_JSON:-} diff --git a/server.py b/server.py index 923d57f..28801b2 100644 --- a/server.py +++ b/server.py @@ -51,6 +51,7 @@ CHAT_HISTORY_LIMIT = 100 CHAT_COOLDOWN_MS = 2000 PRESENCE_TTL_SECONDS = 45 MAX_PRESENCE_TABS_PER_SESSION = 10 +OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES = 2 OIDC_ISSUER = os.environ.get("BUTTON_OIDC_ISSUER", "").rstrip("/") OIDC_CLIENT_ID = os.environ.get("BUTTON_OIDC_CLIENT_ID", "") OIDC_CLIENT_SECRET = os.environ.get("BUTTON_OIDC_CLIENT_SECRET", "") @@ -61,10 +62,10 @@ OIDC_SUBJECT_LINKS = json.loads(os.environ.get("BUTTON_OIDC_SUBJECT_LINKS_JSON", OIDC_REGISTER_URL = os.environ.get("BUTTON_OIDC_REGISTER_URL", "") OIDC_ADMIN_SUBJECTS = set(json.loads(os.environ.get("BUTTON_OIDC_ADMIN_SUBJECTS_JSON", "") or "[]")) OIDC_COOKIE_SECURE = os.environ.get("BUTTON_OIDC_COOKIE_SECURE", "1").lower() in {"1", "true", "yes"} -OIDC_ENABLED = bool(OIDC_ISSUER or OIDC_CLIENT_ID or OIDC_CLIENT_SECRET or OIDC_REDIRECT_URI) -if OIDC_ENABLED and not all((OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_REQUIRED_GROUP)): - raise RuntimeError("OIDC requires BUTTON_OIDC_ISSUER, BUTTON_OIDC_CLIENT_ID, BUTTON_OIDC_CLIENT_SECRET, BUTTON_OIDC_REDIRECT_URI, and BUTTON_OIDC_REQUIRED_GROUP") -if OIDC_ENABLED and urlparse(OIDC_ISSUER).scheme != "https" and os.environ.get("BUTTON_OIDC_ALLOW_HTTP", "0") != "1": +if not all((OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_REQUIRED_GROUP)): + raise RuntimeError("Dex OIDC is required; configure BUTTON_OIDC_ISSUER, BUTTON_OIDC_CLIENT_ID, BUTTON_OIDC_CLIENT_SECRET, BUTTON_OIDC_REDIRECT_URI, and BUTTON_OIDC_REQUIRED_GROUP") +OIDC_ENABLED = True +if urlparse(OIDC_ISSUER).scheme != "https" and os.environ.get("BUTTON_OIDC_ALLOW_HTTP", "0") != "1": raise RuntimeError("OIDC issuer must use HTTPS (HTTP is test-only via BUTTON_OIDC_ALLOW_HTTP=1)") # Ordered from a freshly reset countdown to an almost-expired one. @@ -229,6 +230,36 @@ def zone_for_remaining(remaining_ms): return ZONES[-1] +def press_eligibility(pid): + """Require two distinct other accounts to press between a player's turns.""" + last_press_index = next( + (index for index in range(len(state["presses"]) - 1, -1, -1) + if state["presses"][index]["player_id"] == pid), + None, + ) + if last_press_index is None: + return { + "eligible": True, + "has_pressed": False, + "other_players_required": OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES, + "other_players_pressed": 0, + "other_players_remaining": 0, + } + other_players = { + press["player_id"] for press in state["presses"][last_press_index + 1:] + if press["player_id"] != pid + } + pressed = min(len(other_players), OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES) + remaining = OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES - pressed + return { + "eligible": remaining == 0, + "has_pressed": True, + "other_players_required": OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES, + "other_players_pressed": pressed, + "other_players_remaining": remaining, + } + + def new_game(): t = now_ms() return { @@ -649,9 +680,12 @@ def issue_oidc_player(claims, groups): if OIDC_REQUIRED_GROUP and OIDC_REQUIRED_GROUP not in groups: raise PermissionError("This account is not enabled for The Button") subject = claims["sub"] - username = claims.get("preferred_username") or claims.get("username") + display_name = claims.get("displayName") or claims.get("name") + username = display_name if isinstance(display_name, str) and display_name.strip() else ( + claims.get("uid") or claims.get("preferred_username") or claims.get("username") + ) if not isinstance(username, str): - username = claims["email"].split("@", 1)[0] + raise ValueError("The identity provider did not supply a display name or uid") username = " ".join(username.split()) if not username or len(username) > MAX_NAME_LEN or not username.isprintable(): raise ValueError("The identity provider username is not a valid player name") @@ -672,23 +706,30 @@ def issue_oidc_player(claims, groups): raise ValueError("Player is already linked to another OIDC subject") state["players"][pid]["oidc_sub"] = subject else: - if find_player_by_name(username) is not None: - raise ValueError("This player name already exists and needs an administrator-managed account link") - pid = secrets.token_hex(8) - state["players"][pid] = { - "name": username, - "password_hash": None, - "oidc_sub": subject, - "token_hashes": [], - "created_at": now_ms(), - "subscribed": False, - "is_admin": False, - "is_banned": False, - "is_deleted": False, - } - player = state["players"].get(pid) - if not player or player.get("is_banned") or player.get("is_deleted"): + pid = None + player = state["players"].get(pid) if pid is not None else None + if player and (player.get("is_banned") or player.get("is_deleted")): raise PermissionError("This account cannot access The Button") + name_owner = find_player_by_name(username) + if name_owner is not None and name_owner != pid: + raise ValueError("This player name already exists and needs an administrator-managed account link") + if pid is None: + pid = secrets.token_hex(8) + state["players"][pid] = { + "name": username, + "password_hash": None, + "oidc_sub": subject, + "token_hashes": [], + "created_at": now_ms(), + "subscribed": False, + "is_admin": False, + "is_banned": False, + "is_deleted": False, + } + player = state["players"][pid] + if not player: + raise PermissionError("This account cannot access The Button") + player["name"] = username if OIDC_ADMIN_GROUP or OIDC_ADMIN_SUBJECTS: is_admin = subject in OIDC_ADMIN_SUBJECTS or bool(OIDC_ADMIN_GROUP and OIDC_ADMIN_GROUP in groups) else: @@ -1197,9 +1238,9 @@ class Handler(BaseHTTPRequestHandler): path = parsed_request.path if path == "/api/config": return self.send_json(HTTPStatus.OK, { - "auth_mode": "oidc" if OIDC_ENABLED else "local", - "login_url": "/auth/oidc/login" if OIDC_ENABLED else "", - "register_url": OIDC_REGISTER_URL if OIDC_ENABLED else "", + "auth_mode": "oidc", + "login_url": "/auth/oidc/login", + "register_url": OIDC_REGISTER_URL, }) if path == "/auth/oidc/login": if not OIDC_ENABLED: @@ -1281,6 +1322,7 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/state": with lock: check_ended() + pid = self.authed_player() return self.send_json(HTTPStatus.OK, { "now": now_ms(), "started_at": state["started_at"], @@ -1291,6 +1333,7 @@ class Handler(BaseHTTPRequestHandler): "total_presses": len(state["presses"]), "zones": ZONES, "season": season_summary(), + "press_eligibility": press_eligibility(pid) if pid is not None else None, }) if path == "/api/seasons": with lock: @@ -1505,6 +1548,17 @@ class Handler(BaseHTTPRequestHandler): check_ended(t) if state["ended"]: return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"}) + eligibility = press_eligibility(pid) + if not eligibility["eligible"]: + remaining_players = eligibility["other_players_remaining"] + message = ( + f"Wait for {remaining_players} more distinct " + f"{'player' if remaining_players == 1 else 'players'} to press before your next turn." + ) + return self.send_json(HTTPStatus.CONFLICT, { + "error": message, + "press_eligibility": eligibility, + }) remaining = state["deadline"] - t zone = zone_for_remaining(remaining) press = { @@ -1542,6 +1596,7 @@ class Handler(BaseHTTPRequestHandler): "press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")}, "deadline": state["deadline"], "now": t, + "press_eligibility": press_eligibility(pid), }) self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) diff --git a/static/app.js b/static/app.js index fa350e6..1224974 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = ""; diff --git a/static/index.html b/static/index.html index 9aa39da..55ee8a3 100644 --- a/static/index.html +++ b/static/index.html @@ -34,6 +34,7 @@

Every press buys everyone more time.

+

@@ -41,7 +42,7 @@
MAKE YOURSELF AT HOMEThe Button / 01