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
+7 -77
View File
@@ -1,79 +1,9 @@
// Wires the ".alog reference curve" panel: local file upload, and browsing a server-side
// library directory (e.g. wherever the roastetta skill already downloaded logs).
const csrf = () => document.cookie.split("; ").find((v) => v.startsWith("rp_csrf="))?.split("=")[1] || "";
const setText = (el, text, error = false) => { const node = document.createElement("p"); node.textContent = text; if (error) node.style.color="#a8371a"; el.replaceChildren(node); };
export function initAlogPanel({ state, recompute }) {
const fileInput = document.getElementById("alog-file");
const libraryBtn = document.getElementById("alog-library-refresh");
const libraryList = document.getElementById("alog-library-list");
const resultEl = document.getElementById("alog-result");
fileInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
resultEl.innerHTML = "<p>Parsing…</p>";
try {
const content = await file.text();
const res = await fetch("/api/alog", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ filename: file.name, content }),
});
const body = await res.json();
applyResult(body);
} catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
}
});
libraryBtn.addEventListener("click", async () => {
libraryList.classList.remove("hidden");
libraryList.innerHTML = "<li>Loading…</li>";
try {
const res = await fetch("/api/alog/library");
const body = await res.json();
if (!body.ok || body.files.length === 0) {
libraryList.innerHTML = "<li>No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.</li>";
return;
}
libraryList.innerHTML = "";
for (const f of body.files) {
const li = document.createElement("li");
li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`;
li.addEventListener("click", async () => {
resultEl.innerHTML = "<p>Loading…</p>";
const r = await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`);
applyResult(await r.json());
});
libraryList.appendChild(li);
}
} catch (err) {
libraryList.innerHTML = `<li style="color:#a8371a">${escapeHtml(err.message)}</li>`;
}
});
function applyResult(body) {
if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
return;
}
state.plan.reference = body;
recompute();
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
resultEl.innerHTML = `
<p><strong>${escapeHtml(body.roast.title)}</strong> — ${body.roast.roastDate || "no date"} ·
first crack ${fmt(body.derived?.firstCrackS)} · development ${fmt(body.derived?.developmentS)} ·
drop ${fmt(body.derived?.dropS)} · DTR ${body.derived?.dtrPct ?? "—"}%</p>
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
`;
}
}
function fmt(seconds) {
if (seconds === null || seconds === undefined) return "—";
const s = Math.round(seconds);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fileInput=document.getElementById("alog-file"), libraryBtn=document.getElementById("alog-library-refresh"), libraryList=document.getElementById("alog-library-list"), resultEl=document.getElementById("alog-result");
fileInput.addEventListener("change", async(e)=>{const file=e.target.files?.[0];if(!file)return;setText(resultEl,"Parsing…");try{const res=await fetch("/api/alog",{method:"POST",headers:{"content-type":"application/json","x-csrf-token":csrf()},body:JSON.stringify({filename:file.name,content:await file.text()})});applyResult(await res.json());}catch(err){setText(resultEl,err.message,true);}});
libraryBtn.addEventListener("click",async()=>{libraryList.classList.remove("hidden");libraryList.replaceChildren(Object.assign(document.createElement("li"),{textContent:"Loading…"}));try{const body=await (await fetch("/api/alog/library")).json();if(!body.ok||!body.files.length){libraryList.replaceChildren(Object.assign(document.createElement("li"),{textContent:"No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta."}));return;}libraryList.replaceChildren(...body.files.map(f=>{const li=document.createElement("li");li.textContent=`${f.filename} (${Math.round(f.sizeBytes/1024)} KB)`;li.onclick=async()=>{setText(resultEl,"Loading…");applyResult(await (await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`)).json());};return li;}));}catch(err){const li=document.createElement("li");li.textContent=err.message;li.style.color="#a8371a";libraryList.replaceChildren(li);}});
function applyResult(body){if(!body.ok){setText(resultEl,body.error??body.code,true);return;}state.plan.reference=body;recompute();setText(resultEl,`${body.roast.title}${body.roast.roastDate||"no date"}; first crack ${fmt(body.derived?.firstCrackS)}; development ${fmt(body.derived?.developmentS)}; drop ${fmt(body.derived?.dropS)}; DTR ${body.derived?.dtrPct??"—"}%.`);}
}
function fmt(seconds){if(seconds==null)return "—";const s=Math.round(seconds);return `${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`;}
+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 });
+15 -83
View File
@@ -1,89 +1,21 @@
// Wires the "Prefill from URL" panel. Applies the returned field patch only into empty
// fields by default (checkbox to overwrite), tracks a snapshot for undo, and never touches
// the form on any error.
// URL prefill UI; output is always built with DOM nodes so remote text never becomes markup.
const csrf = () => document.cookie.split("; ").find((v) => v.startsWith("rp_csrf="))?.split("=")[1] || "";
function message(target, text, error = false) { const p = document.createElement("p"); p.textContent = text; if (error) p.style.color = "#a8371a"; target.replaceChildren(p); }
export function initPrefillPanel({ state, renderFormFromPlan, recompute }) {
const urlInput = document.getElementById("prefill-url");
const overwriteBox = document.getElementById("prefill-overwrite");
const goBtn = document.getElementById("prefill-go");
const undoBtn = document.getElementById("prefill-undo");
const resultEl = document.getElementById("prefill-result");
let snapshot = null;
const urlInput = document.getElementById("prefill-url"), overwriteBox = document.getElementById("prefill-overwrite"), goBtn = document.getElementById("prefill-go"), undoBtn = document.getElementById("prefill-undo"), resultEl = document.getElementById("prefill-result"); let snapshot = null;
goBtn.addEventListener("click", async () => {
const url = urlInput.value.trim();
if (!url) return;
goBtn.disabled = true;
resultEl.innerHTML = `<p>Fetching…</p>`;
const url = urlInput.value.trim(); if (!url) return; goBtn.disabled = true; message(resultEl, "Fetching…");
try {
const res = await fetch("/api/prefill", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url }),
});
const body = await res.json();
if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
return;
}
snapshot = JSON.parse(JSON.stringify(state.plan));
const overwrite = overwriteBox.checked;
let applied = 0;
for (const [id, value] of Object.entries(body.fields ?? {})) {
const current = state.plan.fields[id] ?? "";
if (!overwrite && current !== "") continue;
state.plan.fields[id] = value;
applied++;
}
renderFormFromPlan();
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
recompute();
undoBtn.disabled = false;
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
resultEl.innerHTML = `
<p>Applied ${applied} field${applied === 1 ? "" : "s"} from <a href="${escapeHtml(body.source.finalUrl)}" target="_blank" rel="noopener">${escapeHtml(body.source.finalUrl)}</a>.</p>
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
`;
} catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
} finally {
goBtn.disabled = false;
}
const res = await fetch("/api/prefill", { method: "POST", headers: { "content-type": "application/json", "x-csrf-token": csrf() }, body: JSON.stringify({ url }) }); const body = await res.json();
if (!body.ok) { message(resultEl, body.error ?? body.code, true); return; }
snapshot = structuredClone(state.plan); let applied = 0;
for (const [id, value] of Object.entries(body.fields ?? {})) { if (!overwriteBox.checked && (state.plan.fields[id] ?? "") !== "") continue; state.plan.fields[id] = value; applied++; }
renderFormFromPlan(); markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {}); recompute(); undoBtn.disabled = false;
const p = document.createElement("p"), link = document.createElement("a"); link.href = body.source.finalUrl; link.target = "_blank"; link.rel = "noopener"; link.textContent = body.source.finalUrl; p.append(`Applied ${applied} field${applied === 1 ? "" : "s"} from `, link, ".");
const children = [p]; if (body.warnings?.length) { const ul=document.createElement("ul"); ul.className="warnings"; for(const warning of body.warnings){const li=document.createElement("li");li.textContent=warning;ul.append(li);} children.push(ul); } resultEl.replaceChildren(...children);
} catch (err) { message(resultEl, err.message, true); } finally { goBtn.disabled = false; }
});
undoBtn.addEventListener("click", () => {
if (!snapshot) return;
state.plan = snapshot;
snapshot = null;
undoBtn.disabled = true;
renderFormFromPlan();
clearPrefilledMarks();
recompute();
resultEl.innerHTML = "<p>Prefill undone.</p>";
});
function markPrefilled(ids, provenance) {
for (const id of ids) {
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
if (!el) continue;
el.classList.add("prefilled");
const prov = provenance[id];
if (prov) el.title = `from: ${prov}`;
}
}
function clearPrefilledMarks() {
for (const el of document.querySelectorAll(".prefilled")) {
el.classList.remove("prefilled");
el.removeAttribute("title");
}
}
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
undoBtn.addEventListener("click", () => { if (!snapshot) return; state.plan = snapshot; snapshot = null; undoBtn.disabled = true; renderFormFromPlan(); for (const el of document.querySelectorAll(".prefilled")) { el.classList.remove("prefilled"); el.removeAttribute("title"); } recompute(); message(resultEl, "Prefill undone."); });
function markPrefilled(ids, provenance) { for (const id of ids) { const el=document.querySelector(`[name="${CSS.escape(id)}"]`); if(el){el.classList.add("prefilled");if(provenance[id])el.title=`from: ${provenance[id]}`;} } }
}