Files
psopeeps_site/site/app.js
T

615 lines
19 KiB
JavaScript

(() => {
const API_BASE = "/api";
async function api(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
...options,
});
let data = {};
try {
data = await res.json();
} catch (_) {}
if (!res.ok) {
throw new Error(data.error || `Request failed: ${res.status}`);
}
return data;
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[ch]));
}
function replaceText(root, from, to) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) {
if (walker.currentNode.nodeValue.includes(from)) {
nodes.push(walker.currentNode);
}
}
for (const node of nodes) {
node.nodeValue = node.nodeValue.replaceAll(from, to);
}
}
function buildLoginForm() {
const form = document.createElement("form");
form.className = "top-account-form";
form.action = "#";
form.method = "post";
form.setAttribute("aria-label", "Account login or registration");
let mode = "login";
function render() {
form.classList.toggle("is-register", mode === "register");
form.classList.toggle("is-login", mode !== "register");
if (mode === "register") {
form.innerHTML = `
<label class="sr-only" for="top-username">Username</label>
<input id="top-username" name="username" type="text" placeholder="username" autocomplete="username">
<label class="sr-only" for="top-email">Email</label>
<input id="top-email" name="email" type="email" placeholder="email" autocomplete="email">
<label class="sr-only" for="top-password">Password</label>
<input id="top-password" name="password" type="password" placeholder="password" autocomplete="new-password">
<div class="top-account-actions">
<button class="button-compact" type="submit" data-action="create-account">Create Account</button>
<button class="button-compact button-secondary" type="button" data-action="cancel-register">Cancel</button>
</div>
<div class="auth-message" role="status"></div>
`;
return;
}
form.innerHTML = `
<label class="sr-only" for="top-username">Username</label>
<input id="top-username" name="username" type="text" placeholder="username" autocomplete="username">
<label class="sr-only" for="top-password">Password</label>
<input id="top-password" name="password" type="password" placeholder="password" autocomplete="current-password">
<button class="button-compact" type="submit" data-action="login">Login</button>
<button class="button-compact button-secondary" type="button" data-action="show-register">Register</button>
<div class="auth-message" role="status"></div>
`;
}
function setMessage(text, kind = "error") {
const message = form.querySelector(".auth-message");
if (!message) return;
message.textContent = text || "";
message.classList.remove("is-error", "is-ok");
message.classList.add(kind === "ok" ? "is-ok" : "is-error");
}
form.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const action = button.dataset.action;
if (action === "show-register") {
event.preventDefault();
mode = "register";
render();
form.querySelector('input[name="username"]')?.focus();
return;
}
if (action === "cancel-register") {
event.preventDefault();
mode = "login";
render();
form.querySelector('input[name="username"]')?.focus();
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const submitter = event.submitter;
const action = submitter?.dataset.action || "login";
const username = form.querySelector('input[name="username"]')?.value.trim() || "";
const password = form.querySelector('input[name="password"]')?.value || "";
const email = form.querySelector('input[name="email"]')?.value.trim() || "";
setMessage("");
try {
if (action === "create-account") {
await api("/register", {
method: "POST",
body: JSON.stringify({ username, email, password }),
});
setMessage("Account created. Check your email to verify your account.", "ok");
window.setTimeout(() => {
window.location.href = "account-ready.html";
}, 900);
return;
}
await api("/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
window.location.href = "account-ready.html";
} catch (err) {
setMessage(err.message);
}
});
render();
return form;
}
function buildSignedInStatus(user) {
const wrap = document.createElement("div");
wrap.className = "top-account-status";
wrap.innerHTML = `
<span class="status-dot" aria-hidden="true"></span>
<a href="account-ready.html">Signed in as ${escapeHtml(user.username)}</a>
<button class="button-compact button-secondary" type="button" data-logout>Logout</button>
`;
wrap.querySelector("[data-logout]").addEventListener("click", async () => {
await api("/logout", { method: "POST" });
window.location.href = "index.html";
});
return wrap;
}
function renderHeader(user) {
const header = document.querySelector(".site-header--accountline");
if (!header) return;
header.querySelectorAll(".top-account-form, .top-account-status").forEach((el) => el.remove());
if (user) {
header.appendChild(buildSignedInStatus(user));
} else {
header.appendChild(buildLoginForm());
}
}
function findCardByHeading(text) {
const needle = text.toLowerCase();
for (const heading of document.querySelectorAll("h1, h2, h3, h4")) {
if (!heading.textContent.toLowerCase().includes(needle)) continue;
const card = heading.closest(".setup-card, .card, section, article, div");
if (card) return card;
}
return null;
}
function findBBCard() {
return document.querySelector("[data-bb-card], .setup-card--bb") || findCardByHeading("Blue Burst");
}
function badge(text, kind = "") {
const cls = kind ? `badge ${kind}` : "badge";
return `<span class="${cls}">${escapeHtml(text)}</span>`;
}
function updateAccountStatusBadges(accountData) {
if (!accountData?.bb) return;
const bb = accountData.bb;
let label = "BB PASSWORD NEEDED";
let warn = true;
if (bb.created && bb.ready) {
label = "ACCOUNT READY";
warn = false;
} else if (bb.created || bb.account_id) {
label = "BB SYNC PENDING";
warn = true;
}
for (const badge of document.querySelectorAll(".status-badges .badge")) {
const text = badge.textContent.trim().toUpperCase();
if (text.includes("BB ")) {
badge.textContent = label;
badge.classList.toggle("badge--warn", warn);
}
}
}
function renderBBCard(accountData) {
const card = findBBCard();
if (!card || !accountData?.user) return;
const user = accountData.user;
const bb = accountData.bb || {
created: false,
ready: false,
sync_status: "missing",
username: user.username,
account_id: null,
};
const bbCreated = !!(bb.created || bb.account_id);
const bbReady = !!bb.ready;
const bbBadgeLabel = bbReady ? "ACCOUNT READY" : "BB SYNC PENDING";
if (bbCreated) {
card.innerHTML = `
<div class="setup-card-header">
<div>
<p class="eyebrow">Blue Burst</p>
<h2>Blue Burst Account</h2>
</div>
${badge(bbBadgeLabel, bbReady ? "" : "badge--warn")}
</div>
<div class="account-kv">
<div>
<span>BB username</span>
<strong>${escapeHtml(bb.username)}</strong>
</div>
<div>
<span>BB account ID</span>
<strong>${escapeHtml(bb.account_id)}</strong>
</div>
</div>
<form class="bb-account-form" data-bb-action="change-password">
<p class="muted">Change your Blue Burst login password. This updates the account file, then it needs to sync to the ships.</p>
<label>
New BB password
<input name="password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<label>
Confirm new BB password
<input name="confirm_password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<button class="button" type="submit">Change Blue Burst Password</button>
<div class="bb-message" role="status"></div>
</form>
`;
} else {
card.innerHTML = `
<div class="setup-card-header">
<div>
<p class="eyebrow">Blue Burst</p>
<h2>Create Blue Burst Account</h2>
</div>
${badge("BB PASSWORD NEEDED", "badge--warn")}
</div>
<div class="account-kv">
<div>
<span>BB username</span>
<strong>${escapeHtml(user.username)}</strong>
</div>
<div>
<span>BB account ID</span>
<strong>created after setup</strong>
</div>
</div>
<form class="bb-account-form" data-bb-action="create">
<p class="muted">Your Blue Burst username will match your website account name.</p>
<label>
BB password
<input name="password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<label>
Confirm BB password
<input name="confirm_password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<button class="button" type="submit">Create Blue Burst Account</button>
<div class="bb-message" role="status"></div>
</form>
`;
}
const form = card.querySelector(".bb-account-form");
if (!form) return;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = form.querySelector(".bb-message");
const password = form.querySelector('input[name="password"]').value;
const confirmPassword = form.querySelector('input[name="confirm_password"]').value;
const action = form.dataset.bbAction;
message.textContent = "";
message.classList.remove("is-error", "is-ok");
const endpoint = action === "change-password" ? "/bb/change-password" : "/bb/create";
try {
const result = await api(endpoint, {
method: "POST",
body: JSON.stringify({
password,
confirm_password: confirmPassword,
}),
});
message.textContent = result?.bb?.ready
? "Saved and synced."
: "Saved. Sync queued; refresh shortly.";
message.classList.add("is-ok");
const fresh = await api("/account");
renderBBCard(fresh);
} catch (err) {
message.textContent = err.message;
message.classList.add("is-error");
}
});
}
function renderAccountEmail(accountData) {
const hero = document.querySelector(".account-hero-card");
const title = document.querySelector("#account-title");
if (!hero || !title) return;
for (const p of Array.from(hero.querySelectorAll("p"))) {
if (p.textContent.includes("Manage your Blue Burst login")) {
p.remove();
}
}
let box = hero.querySelector("#account-email-summary");
if (!box) {
box = document.createElement("div");
box.id = "account-email-summary";
box.className = "account-email-summary";
title.insertAdjacentElement("afterend", box);
}
const email = accountData?.email || {};
const emailText = email.email || "No email address set";
const verified = !!email.verified;
box.innerHTML = `
<div class="account-control-line">
<span class="account-control-label">Email</span>
<strong>${escapeHtml(emailText)}</strong>
<span class="account-email-state ${verified ? "is-verified" : "is-pending"}">
${verified ? "Verified" : "Verification needed"}
</span>
<button class="inline-link account-email-update" type="button">update email address</button>
<button class="inline-link account-password-update" type="button">change password</button>
</div>
<form class="account-inline-form account-email-form" hidden>
<label class="sr-only" for="account-email-input">New email address</label>
<input id="account-email-input" name="email" type="email" placeholder="new email address" autocomplete="email">
<button class="button-compact" type="submit">Send Verification Email</button>
<button class="button-compact button-secondary" type="button" data-email-cancel>Cancel</button>
</form>
<form class="account-inline-form account-password-form" hidden>
<label class="sr-only" for="account-current-password">Current password</label>
<input id="account-current-password" name="current_password" type="password" placeholder="current password" autocomplete="current-password">
<label class="sr-only" for="account-new-password">New password</label>
<input id="account-new-password" name="password" type="password" placeholder="new password" autocomplete="new-password">
<label class="sr-only" for="account-confirm-password">Confirm new password</label>
<input id="account-confirm-password" name="confirm_password" type="password" placeholder="confirm new password" autocomplete="new-password">
<button class="button-compact" type="submit">Change Password</button>
<button class="button-compact button-secondary" type="button" data-password-cancel>Cancel</button>
</form>
<p class="account-control-message" role="status"></p>
`;
const emailButton = box.querySelector(".account-email-update");
const passwordButton = box.querySelector(".account-password-update");
const emailForm = box.querySelector(".account-email-form");
const passwordForm = box.querySelector(".account-password-form");
const emailCancel = box.querySelector("[data-email-cancel]");
const passwordCancel = box.querySelector("[data-password-cancel]");
const message = box.querySelector(".account-control-message");
const emailInput = box.querySelector("#account-email-input");
function setMessage(text, kind = "") {
message.textContent = text || "";
message.className = "account-control-message";
if (kind) message.classList.add(kind);
}
function hideForms() {
emailForm.hidden = true;
passwordForm.hidden = true;
}
emailButton.addEventListener("click", () => {
hideForms();
emailForm.hidden = false;
emailInput.value = email.email || "";
setMessage("");
emailInput.focus();
});
passwordButton.addEventListener("click", () => {
hideForms();
passwordForm.hidden = false;
setMessage("");
passwordForm.querySelector('input[name="current_password"]')?.focus();
});
emailCancel.addEventListener("click", () => {
emailForm.hidden = true;
setMessage("");
});
passwordCancel.addEventListener("click", () => {
passwordForm.hidden = true;
passwordForm.reset();
setMessage("");
});
emailForm.addEventListener("submit", async (event) => {
event.preventDefault();
const newEmail = emailInput.value.trim();
setMessage("");
try {
const result = await api("/email/start", {
method: "POST",
body: JSON.stringify({ email: newEmail }),
});
const sent = result?.verification?.sent;
const debugLink = result?.verification?.debug_link;
setMessage(sent ? "Verification email sent. Check your inbox." : "Verification link created.", "is-ok");
if (debugLink) {
const a = document.createElement("a");
a.href = debugLink;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = " Open verification link";
message.appendChild(a);
}
const updated = await api("/account");
renderAccountEmail(updated);
} catch (err) {
setMessage(err.message || String(err), "is-error");
}
});
passwordForm.addEventListener("submit", async (event) => {
event.preventDefault();
const currentPassword = passwordForm.querySelector('input[name="current_password"]').value;
const newPassword = passwordForm.querySelector('input[name="password"]').value;
const confirmPassword = passwordForm.querySelector('input[name="confirm_password"]').value;
setMessage("");
try {
await api("/password/change", {
method: "POST",
body: JSON.stringify({
current_password: currentPassword,
password: newPassword,
confirm_password: confirmPassword,
}),
});
passwordForm.reset();
passwordForm.hidden = true;
setMessage("Password changed.", "is-ok");
} catch (err) {
setMessage(err.message || String(err), "is-error");
}
});
}
function renderAccountPage(user, accountData) {
const main = document.querySelector(".account-layout");
if (!main) return;
if (!user) {
main.innerHTML = `
<section class="card account-hero-card" aria-labelledby="account-title">
<div>
<p class="eyebrow">Account Dashboard</p>
<h1 id="account-title">Sign in or register</h1>
<p>Use the account form at the top right to create or open your PSO Peeps website account.</p>
</div>
<div class="status-badges" aria-label="Account setup status">
<span class="badge badge--warn">Not signed in</span>
</div>
</section>
`;
return;
}
replaceText(document.body, "chuudoku", user.username);
const title = document.querySelector("#account-title");
if (title) title.textContent = user.username;
renderAccountEmail(accountData);
updateAccountStatusBadges(accountData);
renderBBCard(accountData);
}
async function boot() {
let user = null;
let accountData = null;
try {
const me = await api("/me");
if (me.authenticated) {
user = me.user;
}
} catch (_) {}
if (user) {
try {
accountData = await api("/account");
} catch (_) {
accountData = {
authenticated: true,
user,
bb: {
ready: false,
username: user.username,
account_id: null,
},
v2_v3_keys: [],
};
}
}
renderHeader(user);
renderAccountPage(user, accountData);
}
document.addEventListener("DOMContentLoaded", boot);
})();