556 lines
16 KiB
JavaScript
556 lines
16 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const API = {
|
|
list: "/api/keys",
|
|
register: "/api/keys/register",
|
|
delete: (id) => `/api/keys/${encodeURIComponent(id)}`,
|
|
reveal: (id) => `/api/keys/${encodeURIComponent(id)}/access-key`,
|
|
};
|
|
|
|
let bound = false;
|
|
|
|
function qs(sel, root = document) {
|
|
return root.querySelector(sel);
|
|
}
|
|
|
|
function esc(v) {
|
|
return String(v ?? "").replace(/[&<>"']/g, ch => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'",
|
|
}[ch]));
|
|
}
|
|
|
|
async function readJson(res) {
|
|
const text = await res.text();
|
|
if (!text) return {};
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return { error: text };
|
|
}
|
|
}
|
|
|
|
function formEl() {
|
|
const version = qs("#key-version");
|
|
return version ? version.closest("form") : null;
|
|
}
|
|
|
|
function listEl() {
|
|
return qs(".key-list");
|
|
}
|
|
|
|
function setMessage(text, kind = "") {
|
|
const form = formEl();
|
|
if (!form) return;
|
|
|
|
let box = qs("#key-profile-status");
|
|
if (!box) {
|
|
box = document.createElement("p");
|
|
box.id = "key-profile-status";
|
|
box.className = "key-status-message";
|
|
form.insertAdjacentElement("afterend", box);
|
|
}
|
|
|
|
box.textContent = text || "";
|
|
box.dataset.kind = kind;
|
|
}
|
|
|
|
function normalizeSerial(raw, gameVersion) {
|
|
const value = String(raw || "").trim();
|
|
|
|
if (gameVersion === "dc_v2") {
|
|
let v = value.toUpperCase();
|
|
if (v.startsWith("0X")) v = v.slice(2);
|
|
if (/^[0-9A-F]{1,8}$/.test(v)) {
|
|
return v.padStart(8, "0");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
if (gameVersion === "pc_v2") {
|
|
if (/^[0-9]{1,10}$/.test(value)) {
|
|
const n = Number(value);
|
|
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
|
|
return n.toString(16).toUpperCase().padStart(8, "0");
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
if (gameVersion === "gc_v3") {
|
|
if (/^[0-9]{2}-[0-9]{4}-[0-9]{4}$/.test(value)) {
|
|
const n = Number(value.replaceAll("-", ""));
|
|
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
|
|
return n.toString(16).toUpperCase().padStart(8, "0");
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function setupForm(form) {
|
|
const version = qs("#key-version", form);
|
|
if (version) {
|
|
version.innerHTML = [
|
|
'<option value="dc_v2">DC V2</option>',
|
|
'<option value="pc_v2">PC V2</option>',
|
|
'<option value="gc_v3">GC V3</option>',
|
|
].join("");
|
|
}
|
|
|
|
updateSerialHint(form);
|
|
|
|
const versionSelect = qs("#key-version", form);
|
|
if (versionSelect && !versionSelect.dataset.hintBound) {
|
|
versionSelect.dataset.hintBound = "1";
|
|
versionSelect.addEventListener("change", () => updateSerialHint(form));
|
|
}
|
|
|
|
const keyPassword = qs("#key-password", form);
|
|
if (keyPassword) {
|
|
const label = qs('label[for="key-password"]', form);
|
|
if (label) label.remove();
|
|
keyPassword.remove();
|
|
}
|
|
|
|
const serial = qs("#key-serial", form);
|
|
if (serial && !qs("#key-display-serial", form)) {
|
|
const displayLabel = document.createElement("label");
|
|
displayLabel.id = "key-display-serial-label";
|
|
displayLabel.setAttribute("for", "key-display-serial");
|
|
displayLabel.textContent = "Confirm Serial Number";
|
|
|
|
const displayInput = document.createElement("input");
|
|
displayInput.id = "key-display-serial";
|
|
displayInput.name = "display_serial";
|
|
displayInput.type = "text";
|
|
displayInput.autocomplete = "off";
|
|
displayInput.placeholder = "confirm serial number";
|
|
|
|
serial.insertAdjacentElement("afterend", displayInput);
|
|
serial.insertAdjacentElement("afterend", displayLabel);
|
|
}
|
|
|
|
const access = qs("#key-access", form);
|
|
if (access && !qs("#key-password", form)) {
|
|
const passwordLabel = document.createElement("label");
|
|
passwordLabel.id = "key-password-label";
|
|
passwordLabel.setAttribute("for", "key-password");
|
|
passwordLabel.textContent = "GC Password";
|
|
passwordLabel.style.display = "none";
|
|
|
|
const passwordInput = document.createElement("input");
|
|
passwordInput.id = "key-password";
|
|
passwordInput.name = "password";
|
|
passwordInput.type = "text";
|
|
passwordInput.autocomplete = "off";
|
|
passwordInput.placeholder = "GC password";
|
|
passwordInput.style.display = "none";
|
|
|
|
access.insertAdjacentElement("afterend", passwordInput);
|
|
access.insertAdjacentElement("afterend", passwordLabel);
|
|
}
|
|
|
|
const button = qs('button[type="submit"]', form) || qs("button", form);
|
|
if (button) {
|
|
button.type = "button";
|
|
}
|
|
}
|
|
|
|
|
|
function syncDotClass(status) {
|
|
const value = String(status || "").toLowerCase();
|
|
return (value === "synced" || value === "current") ? "is-synced" : "is-syncing";
|
|
}
|
|
|
|
function renderSyncStatus(label, status) {
|
|
const safeStatus = status || "unknown";
|
|
return `
|
|
<span class="key-sync-status">
|
|
<span class="key-sync-dot ${syncDotClass(safeStatus)}" style="color: ${syncDotClass(safeStatus) === 'is-synced' ? '#38d66b' : '#e05252'};" aria-hidden="true">●</span>
|
|
<span>${esc(label)}: <strong>${esc(safeStatus)}</strong></span>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
function renderKeySyncSummary(data) {
|
|
const box = qs("#key-sync-summary");
|
|
if (!box) return;
|
|
|
|
const sync = data.sync_status || "unknown";
|
|
const us = data.regions?.us?.status || "unknown";
|
|
const eu = data.regions?.eu?.status || "unknown";
|
|
|
|
box.innerHTML = `
|
|
${renderSyncStatus("Key sync", sync)}
|
|
${renderSyncStatus("US", us)}
|
|
${renderSyncStatus("EU", eu)}
|
|
`;
|
|
}
|
|
|
|
|
|
async function loadKeys() {
|
|
const res = await fetch("/api/keys", {
|
|
credentials: "same-origin",
|
|
});
|
|
|
|
const data = await readJson(res);
|
|
|
|
if (!res.ok || data.ok === false) {
|
|
throw new Error(data.error || data.detail || `HTTP ${res.status}`);
|
|
}
|
|
|
|
renderKeys(data);
|
|
return data;
|
|
}
|
|
|
|
|
|
function renderKeys(data) {
|
|
const list = listEl();
|
|
if (!list) return;
|
|
|
|
const keys = Array.isArray(data.keys) ? data.keys : [];
|
|
const sync = data.sync_status || "unknown";
|
|
const us = data.regions?.us?.status || "unknown";
|
|
const eu = data.regions?.eu?.status || "unknown";
|
|
|
|
renderKeySyncSummary(data);
|
|
const summary = "";
|
|
|
|
if (!keys.length) {
|
|
list.innerHTML = summary + `
|
|
<article class="key-row key-row--empty" role="listitem">
|
|
<div>
|
|
<h3>No V2 / V3 keys registered</h3>
|
|
<p>Add a DC V2, PC V2, or GC V3 key profile above.</p>
|
|
</div>
|
|
</article>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = summary + keys.map(key => {
|
|
const version = key.game_version_label || key.game_version || "Key";
|
|
const label = key.label || "";
|
|
const serial = key.serial_number_hex || "";
|
|
let displaySerial = key.display_serial || serial;
|
|
if (!key.display_serial && serial) {
|
|
const n = Number.parseInt(serial, 16);
|
|
if (Number.isFinite(n)) {
|
|
if (key.game_version === "pc_v2") {
|
|
displaySerial = String(n);
|
|
} else if (key.game_version === "gc_v3") {
|
|
const dec = String(n).padStart(10, "0");
|
|
displaySerial = `${dec.slice(0, 2)}-${dec.slice(2, 6)}-${dec.slice(6, 10)}`;
|
|
}
|
|
}
|
|
}
|
|
const isGC = key.game_version === "gc_v3";
|
|
|
|
return `
|
|
<article class="key-row" role="listitem" data-key-id="${esc(key.id)}" data-key-version="${esc(key.game_version || "")}">
|
|
<div>
|
|
<h3>${esc(version)}</h3>
|
|
<p>Registered profile <strong>${esc(serial)}</strong></p>
|
|
<p class="key-meta">Serial: ${esc(displaySerial)}</p>
|
|
${label ? `<p class="key-meta">Label: ${esc(label)}</p>` : ""}
|
|
<p class="key-secret-line">
|
|
<span>Access key:</span>
|
|
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="access_key">••••••••••••</code>
|
|
<button class="inline-link key-reveal-button" type="button" data-key-id="${esc(key.id)}">
|
|
show
|
|
</button>
|
|
</p>
|
|
${isGC ? `<p class="key-secret-line">
|
|
<span>Password:</span>
|
|
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="password">••••••••••••</code>
|
|
</p>` : ""}
|
|
</div>
|
|
<button class="button-danger key-delete-button" type="button" data-key-id="${esc(key.id)}">
|
|
Delete Key
|
|
</button>
|
|
</article>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
|
|
function updateSerialHint(form) {
|
|
const gameVersion = qs("#key-version", form)?.value || "";
|
|
const serial = qs("#key-serial", form);
|
|
const displaySerial = qs("#key-display-serial", form);
|
|
const access = qs("#key-access", form);
|
|
const password = qs("#key-password", form);
|
|
const passwordLabel = qs("#key-password-label", form);
|
|
|
|
if (!serial) return;
|
|
|
|
if (gameVersion === "dc_v2") {
|
|
serial.placeholder = "DC V2 serial number";
|
|
if (displaySerial) displaySerial.placeholder = "confirm DC V2 serial number";
|
|
if (access) access.placeholder = "access key";
|
|
} else if (gameVersion === "pc_v2") {
|
|
serial.placeholder = "PC V2 decimal serial number";
|
|
if (displaySerial) displaySerial.placeholder = "confirm PC V2 decimal serial number";
|
|
if (access) access.placeholder = "access key";
|
|
} else if (gameVersion === "gc_v3") {
|
|
serial.placeholder = "GC V3 serial number";
|
|
if (displaySerial) displaySerial.placeholder = "confirm GC V3 serial number";
|
|
if (access) access.placeholder = "access key";
|
|
}
|
|
|
|
if (passwordLabel) {
|
|
passwordLabel.style.display = gameVersion === "gc_v3" ? "" : "none";
|
|
}
|
|
if (password) {
|
|
password.style.display = gameVersion === "gc_v3" ? "" : "none";
|
|
if (gameVersion !== "gc_v3") {
|
|
password.value = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function registerKey() {
|
|
const form = formEl();
|
|
if (!form) return;
|
|
|
|
const button = qs("button", form);
|
|
const gameVersion = qs("#key-version", form)?.value || "";
|
|
const label = qs("#key-label", form)?.value.trim() || "";
|
|
const rawSerial = qs("#key-serial", form)?.value.trim() || "";
|
|
const displaySerial = qs("#key-display-serial", form)?.value.trim() || "";
|
|
const accessKey = qs("#key-access", form)?.value.trim() || "";
|
|
const password = qs("#key-password", form)?.value.trim() || "";
|
|
|
|
if (!["dc_v2", "pc_v2", "gc_v3"].includes(gameVersion)) {
|
|
setMessage("Choose DC V2, PC V2, or GC V3.", "warn");
|
|
return;
|
|
}
|
|
|
|
const serialChecks = {
|
|
dc_v2: /^(?:0x)?[0-9A-Fa-f]{1,8}$/,
|
|
pc_v2: /^[0-9]{1,10}$/,
|
|
gc_v3: /^[0-9]{2}-[0-9]{4}-[0-9]{4}$/,
|
|
};
|
|
|
|
if (!serialChecks[gameVersion]?.test(rawSerial)) {
|
|
const examples = {
|
|
dc_v2: "DC V2 serial must be hex, like 4E62F237.",
|
|
pc_v2: "PC V2 serial must be digits only.",
|
|
gc_v3: "GC V3 serial must use the dashed format: NN-NNNN-NNNN.",
|
|
};
|
|
setMessage(examples[gameVersion] || "Enter a valid serial number.", "warn");
|
|
return;
|
|
}
|
|
|
|
const serial = rawSerial;
|
|
|
|
if (!serial) {
|
|
setMessage("Enter a valid serial number.", "warn");
|
|
return;
|
|
}
|
|
|
|
if (!displaySerial) {
|
|
setMessage("Confirm serial number is required.", "warn");
|
|
return;
|
|
}
|
|
|
|
if (displaySerial !== rawSerial) {
|
|
setMessage("Serial number confirmation does not match.", "warn");
|
|
return;
|
|
}
|
|
|
|
if (!accessKey) {
|
|
setMessage("Enter the key.", "warn");
|
|
return;
|
|
}
|
|
|
|
if (gameVersion === "gc_v3" && !password) {
|
|
setMessage("Enter the GC password.", "warn");
|
|
return;
|
|
}
|
|
|
|
if (button) button.disabled = true;
|
|
setMessage("Registering key profile...", "pending");
|
|
|
|
try {
|
|
const res = await fetch("/api/keys/register", {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
game_version: gameVersion,
|
|
label,
|
|
serial_number: serial,
|
|
display_serial: displaySerial,
|
|
access_key: accessKey,
|
|
password,
|
|
}),
|
|
});
|
|
|
|
const data = await readJson(res);
|
|
if (!res.ok) {
|
|
throw new Error(data.error || `Register failed: ${res.status}`);
|
|
}
|
|
|
|
form.reset();
|
|
setupForm(form);
|
|
setMessage(`Saved. Sync status: ${data.sync_status}. Refresh status in a few seconds.`, "ok");
|
|
await loadKeys();
|
|
} catch (err) {
|
|
setMessage(err.message || String(err), "warn");
|
|
} finally {
|
|
if (button) button.disabled = false;
|
|
}
|
|
}
|
|
|
|
|
|
async function revealAccessKey(id, button) {
|
|
if (!id || !button) return;
|
|
|
|
const card = button.closest("article, .key-card, .key-row, .registered-key");
|
|
const values = Array.from(card?.querySelectorAll(`[data-key-secret="${String(id).replace(/"/g, "\\\"")}"]`) || []);
|
|
if (!values.length) {
|
|
setMessage("Could not find this key on the page. Refresh and try again.", "warn");
|
|
return;
|
|
}
|
|
|
|
if (button.dataset.visible === "1") {
|
|
values.forEach(value => value.textContent = "••••••••••••");
|
|
button.textContent = "show";
|
|
button.dataset.visible = "0";
|
|
return;
|
|
}
|
|
|
|
button.disabled = true;
|
|
|
|
try {
|
|
const res = await fetch(API.reveal(id), {
|
|
credentials: "same-origin",
|
|
headers: { "Accept": "application/json" },
|
|
});
|
|
|
|
const data = await readJson(res);
|
|
if (!res.ok) {
|
|
throw new Error(data.error || `Reveal failed: ${res.status}`);
|
|
}
|
|
|
|
values.forEach(value => {
|
|
const kind = value.dataset.keySecretKind || "access_key";
|
|
value.textContent = data.key?.[kind] || "";
|
|
});
|
|
|
|
button.textContent = "hide";
|
|
button.dataset.visible = "1";
|
|
} catch (err) {
|
|
setMessage(err.message || String(err), "warn");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function deleteKey(id, button) {
|
|
if (!id) return;
|
|
|
|
const card = button?.closest("article, .key-card, .key-row, .registered-key");
|
|
const gameVersion = card?.dataset?.keyVersion || "";
|
|
|
|
let confirmMessage = "Delete this V2 key profile from this website account?";
|
|
if (gameVersion === "pc_v2") {
|
|
confirmMessage += "\n\nPC V2 serial/key is forever tied to your local character saves. Please be sure you have your keys backed up before removing this key from your profile.";
|
|
}
|
|
|
|
if (!window.confirm(confirmMessage)) return;
|
|
|
|
button.disabled = true;
|
|
setMessage("Deleting key profile...", "pending");
|
|
|
|
try {
|
|
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
credentials: "same-origin",
|
|
headers: { "Accept": "application/json" },
|
|
});
|
|
|
|
const data = await readJson(res);
|
|
if (!res.ok) {
|
|
throw new Error(data.error || `Delete failed: ${res.status}`);
|
|
}
|
|
|
|
setMessage(`Deleted. Sync status: ${data.sync_status}.`, "ok");
|
|
await loadKeys();
|
|
} catch (err) {
|
|
setMessage(err.message || String(err), "warn");
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
function bind() {
|
|
if (bound) return true;
|
|
|
|
const form = formEl();
|
|
const list = listEl();
|
|
if (!form || !list) return false;
|
|
|
|
setupForm(form);
|
|
|
|
const button = qs("button", form);
|
|
if (button) {
|
|
button.addEventListener("click", registerKey);
|
|
}
|
|
|
|
form.addEventListener("submit", (ev) => {
|
|
ev.preventDefault();
|
|
registerKey();
|
|
});
|
|
|
|
list.addEventListener("click", (ev) => {
|
|
const revealButton = ev.target.closest(".key-reveal-button");
|
|
if (revealButton) {
|
|
revealAccessKey(revealButton.dataset.keyId, revealButton);
|
|
return;
|
|
}
|
|
|
|
const button = ev.target.closest(".key-delete-button");
|
|
if (!button) return;
|
|
deleteKey(button.dataset.keyId, button);
|
|
});
|
|
|
|
bound = true;
|
|
|
|
loadKeys().catch(err => {
|
|
list.innerHTML = `
|
|
<article class="key-row key-row--empty" role="listitem">
|
|
<div>
|
|
<h3>Could not load keys</h3>
|
|
<p>${esc(err.message || err)}</p>
|
|
</div>
|
|
</article>
|
|
`;
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
function start() {
|
|
[0, 500, 1500, 3000].forEach(ms => {
|
|
window.setTimeout(bind, ms);
|
|
});
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", start);
|
|
} else {
|
|
start();
|
|
}
|
|
})();
|