352 lines
11 KiB
JavaScript
352 lines
11 KiB
JavaScript
import { api, protectedFetch } from "./api.js?v=20260730-release2";
|
|
import { initSideNav, loadNavUser } from "./nav.js?v=20260730-release2";
|
|
import { showToast } from "./toast.js?v=20260730-release2";
|
|
|
|
function fmtDate(value) {
|
|
return value ? new Date(value).toLocaleString() : "—";
|
|
}
|
|
|
|
function friendlyDevice(userAgent) {
|
|
if (!userAgent) return "Unknown device";
|
|
const browser = /Edg\//.test(userAgent)
|
|
? "Edge"
|
|
: /OPR\//.test(userAgent)
|
|
? "Opera"
|
|
: /Chrome\//.test(userAgent)
|
|
? "Chrome"
|
|
: /CriOS\//.test(userAgent)
|
|
? "Chrome"
|
|
: /Firefox\//.test(userAgent)
|
|
? "Firefox"
|
|
: /Safari\//.test(userAgent)
|
|
? "Safari"
|
|
: "Browser";
|
|
const os = /Windows/.test(userAgent)
|
|
? "Windows"
|
|
: /iPhone|iPad/.test(userAgent)
|
|
? "iOS"
|
|
: /Mac OS X/.test(userAgent)
|
|
? "macOS"
|
|
: /Android/.test(userAgent)
|
|
? "Android"
|
|
: /Linux/.test(userAgent)
|
|
? "Linux"
|
|
: "";
|
|
return os ? `${browser} on ${os}` : browser;
|
|
}
|
|
|
|
async function loadProfile(user) {
|
|
document.getElementById("profile-email").textContent = user.email;
|
|
document
|
|
.getElementById("profile-role-admin")
|
|
.classList.toggle("hidden", user.role !== "admin");
|
|
document.getElementById("email-form").email.value = user.email;
|
|
}
|
|
|
|
async function loadSessions() {
|
|
const body = document.getElementById("sessions-body");
|
|
try {
|
|
const { sessions } = await api("/api/account/sessions");
|
|
if (!sessions.length) {
|
|
body.innerHTML = `<tr><td colspan="4" class="empty-state">No active sessions.</td></tr>`;
|
|
return;
|
|
}
|
|
body.replaceChildren(
|
|
...sessions.map((session) => {
|
|
const tr = document.createElement("tr");
|
|
const device = document.createElement("td");
|
|
const label = document.createElement("span");
|
|
label.className = "session-device-label";
|
|
label.textContent = friendlyDevice(session.userAgent);
|
|
if (session.userAgent) label.title = session.userAgent;
|
|
device.append(label);
|
|
if (session.current) {
|
|
const badge = document.createElement("span");
|
|
badge.className = "badge badge-current";
|
|
badge.textContent = "This device";
|
|
badge.style.marginLeft = "8px";
|
|
device.append(badge);
|
|
}
|
|
const lastSeen = document.createElement("td");
|
|
lastSeen.textContent = fmtDate(session.lastSeenAt || session.createdAt);
|
|
const expires = document.createElement("td");
|
|
expires.textContent = fmtDate(session.expiresAt);
|
|
const actions = document.createElement("td");
|
|
const revoke = document.createElement("button");
|
|
revoke.className = "ghost-btn small";
|
|
revoke.type = "button";
|
|
revoke.textContent = session.current ? "Sign out" : "Revoke";
|
|
revoke.addEventListener("click", async () => {
|
|
try {
|
|
await api(`/api/account/sessions/${session.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (session.current) return location.assign("/login");
|
|
showToast("Session revoked.");
|
|
loadSessions();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
});
|
|
actions.append(revoke);
|
|
tr.append(device, lastSeen, expires, actions);
|
|
return tr;
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load sessions.</td></tr>`;
|
|
}
|
|
}
|
|
|
|
async function loadPlans() {
|
|
const body = document.getElementById("plans-body");
|
|
try {
|
|
const response = await fetch("/api/plans");
|
|
if (!response.ok) throw new Error("could not load plans");
|
|
const { plans } = await response.json();
|
|
if (!plans.length) {
|
|
body.innerHTML = `<tr><td colspan="3" class="empty-state">No saved plans yet.</td></tr>`;
|
|
return;
|
|
}
|
|
body.replaceChildren(
|
|
...plans.map((plan) => {
|
|
const tr = document.createElement("tr");
|
|
const title = document.createElement("td");
|
|
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
|
|
const updated = document.createElement("td");
|
|
updated.textContent = fmtDate(plan.updated_at);
|
|
const actions = document.createElement("td");
|
|
actions.className = "data-table-actions";
|
|
|
|
const open = document.createElement("a");
|
|
open.className = "ghost-btn small";
|
|
open.href = `/app?plan=${encodeURIComponent(plan.id)}`;
|
|
open.textContent = "Open";
|
|
|
|
const duplicate = document.createElement("button");
|
|
duplicate.className = "ghost-btn small";
|
|
duplicate.type = "button";
|
|
duplicate.textContent = "Duplicate";
|
|
duplicate.addEventListener("click", async () => {
|
|
try {
|
|
await api("/api/plans", {
|
|
method: "POST",
|
|
body: JSON.stringify({ plan: plan.plan }),
|
|
});
|
|
showToast("Plan duplicated.");
|
|
loadPlans();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
});
|
|
|
|
const download = document.createElement("button");
|
|
download.className = "ghost-btn small";
|
|
download.type = "button";
|
|
download.textContent = "Download";
|
|
download.addEventListener("click", () => {
|
|
const blob = new Blob([JSON.stringify(plan.plan, null, 2)], {
|
|
type: "application/json",
|
|
});
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `${(plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
});
|
|
|
|
const cup = document.createElement("button");
|
|
cup.className = "ghost-btn small";
|
|
cup.type = "button";
|
|
cup.textContent = "Cup";
|
|
cup.addEventListener("click", async () => {
|
|
cup.disabled = true;
|
|
try {
|
|
const existing = await api(`/api/cupping?plan=${encodeURIComponent(plan.id)}`);
|
|
if (existing.sessions?.length) {
|
|
location.assign(`/cupping?session=${existing.sessions[0].id}`);
|
|
return;
|
|
}
|
|
const created = await api("/api/cupping", {
|
|
method: "POST",
|
|
body: JSON.stringify({ roastPlanId: plan.id }),
|
|
});
|
|
location.assign(`/cupping?session=${created.session.id}`);
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
cup.disabled = false;
|
|
}
|
|
});
|
|
|
|
const del = document.createElement("button");
|
|
del.className = "ghost-btn small";
|
|
del.type = "button";
|
|
del.textContent = "Delete";
|
|
del.addEventListener("click", async () => {
|
|
if (!confirm("Delete this plan? This cannot be undone.")) return;
|
|
try {
|
|
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
|
|
showToast("Plan deleted.");
|
|
loadPlans();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
});
|
|
|
|
actions.append(open, duplicate, download, cup, del);
|
|
tr.append(title, updated, actions);
|
|
return tr;
|
|
}),
|
|
);
|
|
} catch {
|
|
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
|
|
}
|
|
}
|
|
|
|
/** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */
|
|
async function guarded(button, run) {
|
|
if (button.disabled) return;
|
|
button.disabled = true;
|
|
try {
|
|
await run();
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
function wireForms(user) {
|
|
document.getElementById("email-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const data = Object.fromEntries(new FormData(event.target));
|
|
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
|
|
try {
|
|
await api("/api/account/email", { method: "PUT", body: JSON.stringify(data) });
|
|
showToast("Email updated.");
|
|
document.getElementById("profile-email").textContent = data.email;
|
|
event.target.password.value = "";
|
|
} catch (error) {
|
|
showToast(
|
|
error.code === "email_exists"
|
|
? "That email is already in use."
|
|
: error.message,
|
|
"fail",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
document
|
|
.getElementById("password-form")
|
|
.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const data = Object.fromEntries(new FormData(event.target));
|
|
if (data.newPassword !== data.confirmPassword) {
|
|
showToast("New passwords do not match.", "fail");
|
|
return;
|
|
}
|
|
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
|
|
try {
|
|
await api("/api/account/password", {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
showToast("Password updated. Other sessions were signed out.");
|
|
event.target.reset();
|
|
loadSessions();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
});
|
|
});
|
|
|
|
document
|
|
.getElementById("btn-revoke-others")
|
|
.addEventListener("click", (event) => {
|
|
guarded(event.currentTarget, async () => {
|
|
try {
|
|
await api("/api/account/sessions/revoke-others", { method: "POST" });
|
|
showToast("Other sessions signed out.");
|
|
loadSessions();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
});
|
|
});
|
|
|
|
document.getElementById("delete-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const data = Object.fromEntries(new FormData(event.target));
|
|
if (data.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) {
|
|
showToast("Type your email exactly to confirm.", "fail");
|
|
return;
|
|
}
|
|
if (!confirm("This permanently deletes your account and plans. Continue?"))
|
|
return;
|
|
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
|
|
try {
|
|
await api("/api/account", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ password: data.password }),
|
|
});
|
|
location.assign("/");
|
|
} catch (error) {
|
|
showToast(
|
|
error.code === "last_admin"
|
|
? "You are the only administrator — promote another admin first."
|
|
: error.message,
|
|
"fail",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function wirePwaButtons() {
|
|
let deferredInstallPrompt = null;
|
|
window.addEventListener("beforeinstallprompt", (event) => {
|
|
event.preventDefault();
|
|
deferredInstallPrompt = event;
|
|
document.getElementById("btn-install").classList.remove("hidden");
|
|
});
|
|
document.getElementById("btn-install").addEventListener("click", async () => {
|
|
if (!deferredInstallPrompt) return;
|
|
deferredInstallPrompt.prompt();
|
|
await deferredInstallPrompt.userChoice;
|
|
deferredInstallPrompt = null;
|
|
document.getElementById("btn-install").classList.add("hidden");
|
|
});
|
|
if ("serviceWorker" in navigator) {
|
|
const hadController = !!navigator.serviceWorker.controller;
|
|
navigator.serviceWorker.getRegistration().then((registration) => {
|
|
if (registration?.waiting)
|
|
document.getElementById("btn-refresh").classList.remove("hidden");
|
|
});
|
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
if (hadController) location.reload();
|
|
});
|
|
}
|
|
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
|
const registration = await navigator.serviceWorker.getRegistration();
|
|
registration?.waiting?.postMessage("SKIP_WAITING");
|
|
});
|
|
}
|
|
|
|
document.getElementById("btn-logout").addEventListener("click", async () => {
|
|
await protectedFetch("/api/auth/logout", { method: "POST" });
|
|
location.assign("/");
|
|
});
|
|
|
|
async function init() {
|
|
initSideNav();
|
|
const user = await loadNavUser();
|
|
if (!user) return;
|
|
document.getElementById("profile-since").textContent =
|
|
`Member since ${fmtDate(user.createdAt)}`;
|
|
await loadProfile(user);
|
|
wireForms(user);
|
|
wirePwaButtons();
|
|
await Promise.all([loadSessions(), loadPlans()]);
|
|
}
|
|
|
|
init();
|