feat: add secure auth, admin and postgres persistence

This commit is contained in:
2026-07-29 21:52:22 -04:00
parent 74b4c3a368
commit 432dd2176f
18 changed files with 946 additions and 256 deletions
+38 -16
View File
@@ -9,6 +9,9 @@ import { initPrint } from "./print.js";
const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_KEY = "roastPlannerPlan.v1";
let remotePlanId = null;
const csrfToken = () => document.cookie.split("; ").find((v) => v.startsWith("rp_csrf="))?.split("=")[1] || "";
export const protectedFetch = (url, options = {}) => fetch(url, { ...options, headers: { ...options.headers, "x-csrf-token": csrfToken() } });
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
export const state = { plan: loadFromStorage() ?? blankPlan() };
@@ -59,34 +62,34 @@ function setValueForName(name, value) {
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
function renderBlendPrintRows() {
const tbody = document.getElementById("blend-rows");
tbody.innerHTML = "";
tbody.replaceChildren();
state.plan.blendComponents.forEach((_, i) => {
const tr = document.createElement("tr");
tr.innerHTML = `
tr.append(document.createRange().createContextualFragment(`
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
`;
`));
tbody.appendChild(tr);
});
}
function renderBlendCards() {
const wrap = document.getElementById("blend-cards");
wrap.innerHTML = "";
wrap.replaceChildren();
state.plan.blendComponents.forEach((_, i) => {
const card = document.createElement("div");
card.className = "blend-card";
card.innerHTML = `
card.append(document.createRange().createContextualFragment(`
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
`;
`));
wrap.appendChild(card);
});
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
@@ -124,27 +127,27 @@ function renderBlend() {
function renderActuatorPrintRows() {
const tbody = document.getElementById("actuator-rows");
tbody.innerHTML = "";
tbody.replaceChildren();
state.plan.actuators.forEach((_, i) => {
const tr = document.createElement("tr");
tr.innerHTML = `
tr.append(document.createRange().createContextualFragment(`
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
`;
`));
tbody.appendChild(tr);
});
}
function renderActuatorTimeline() {
const wrap = document.getElementById("actuator-timeline");
wrap.innerHTML = "";
wrap.replaceChildren();
state.plan.actuators.forEach((_, i) => {
const step = document.createElement("div");
step.className = "actuator-step";
step.innerHTML = `
step.append(document.createRange().createContextualFragment(`
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
<div class="actuator-card">
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
@@ -154,7 +157,7 @@ function renderActuatorTimeline() {
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
</div>
`;
`));
wrap.appendChild(step);
});
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
@@ -279,7 +282,7 @@ function renderBandMarkers() {
}
function paintCurveInto(planGroup, refGroup, planPoints, ref) {
planGroup.innerHTML = "";
planGroup.replaceChildren();
if (planPoints.length > 0) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pointsToPathD(planPoints));
@@ -299,7 +302,7 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
}
}
refGroup.innerHTML = "";
refGroup.replaceChildren();
if (ref?.curve?.length) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const d = ref.curve
@@ -365,6 +368,14 @@ function autosave() {
autosave._t = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
// Local storage preserves edits while offline; the account copy is authoritative when online.
if (navigator.onLine && csrfToken()) {
const method = remotePlanId ? "PUT" : "POST";
const url = remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans";
protectedFetch(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify({ plan: state.plan }) })
.then((r) => r.ok ? r.json() : null).then((body) => { if (body?.plan?.id) remotePlanId = body.plan.id; })
.catch(() => { /* local copy remains available */ });
}
const savedAt = Date.now();
chip.classList.remove("saving");
chip.classList.add("saved");
@@ -403,7 +414,11 @@ function cultivarAutofill(name) {
function wireCultivarDatalist() {
const list = document.getElementById("cultivar-list");
list.innerHTML = CULTIVARS.map((c) => `<option value="${c.name}">`).join("");
list.replaceChildren(...CULTIVARS.map((c) => {
const option = document.createElement("option");
option.value = c.name;
return option;
}));
}
function wireForm() {
@@ -535,7 +550,11 @@ function wireSectionNav() {
for (const s of sections) observer.observe(s);
}
function init() {
async function init() {
try {
const response = await fetch("/api/plans");
if (response.ok) { const body = await response.json(); const latest = body.plans?.[0]; if (latest) { state.plan = latest.plan; remotePlanId = latest.id; } }
} catch { /* offline starts from the local cache */ }
renderBlend();
renderActuators();
wireCultivarDatalist();
@@ -546,6 +565,9 @@ function init() {
wireToolbar();
wirePwa();
wireSectionNav();
fetch("/api/auth/me").then((r) => r.ok ? r.json() : null).then((body) => {
if (body?.user?.role === "admin") { const button = document.getElementById("btn-admin"); button.classList.remove("hidden"); button.onclick = () => { location.href = "/admin"; }; }
}).catch(() => {});
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
initAlogPanel({ state, recompute });
initPrint({ beforePrint: renderFormFromPlan });