615 lines
19 KiB
JavaScript
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) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
}[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);
|
|
})();
|