app: OTM integrations — operator-magic + account SSO + suggestion jots + self-update (v0.4.0)
Three new doors, all wire-contract ports of the shared @otm/account-panel factories (this backbone is plain Node — the Next factories can't mount): - GET /api/auth/operator-magic — OTM 'Log in as admin'. HS256 verify against OPERATOR_SHARED_SECRET (alg allowlist, constant-time, action-claim rejected for flow separation), single-use jti via OperatorMagicConsumed (+ who/when audit), 1-HOUR session with cookie Max-Age derived from the payload, 🔑 support-session banner in the app, every failure a 302 reason redirect. - GET /api/auth/otm-sso — the OTM /account tile. Stricter sso-ticket verify (exp mandatory, audience compared), email-then-role actor mapping, safeNext. - 💡 suggestion jot chip — files a Gitea issue (shared attribution footer, 8k cap, 10s timeout); on any failure the jot is kept as a note instead. Success also leaves a '#N — …' note so she has her own record. - Footer version chip + one-tap self-update (HMAC-signed OTM proxy with explicit field picking — foreign JSON can never reach the _setSession/_redirect control keys), owner-gated, 10-min sessionStorage cache on the check. Hardening that rode along: session key now derived from OPERATOR_SHARED_SECRET (managed boot REFUSES the old derivable DATABASE_URL fallback), 1MB JSON body cap, login.html prototype-lookup fix. NOTE: server.mjs previously contained a literal NUL byte that made git treat it as binary — this commit re-encodes it as an escape (behavior identical) and adds .gitattributes so source diffs can never go blind again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmybqyQmZWfcqA1vMP4jbQ
This commit is contained in:
@@ -22,13 +22,14 @@ document.querySelectorAll("#nav button").forEach((b) =>
|
||||
function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); }
|
||||
|
||||
// ── jot box (shared) ─────────────────────────────────────────────
|
||||
const JOT_HINT = "⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot";
|
||||
function captureHTML(kinds) {
|
||||
const chips = kinds.map(([k, label], i) => `<span class="chip${i === 0 ? " on" : ""}" data-kind="${k}">${label}</span>`).join("");
|
||||
return `<div class="capture"><div class="row1">
|
||||
<textarea id="jot" rows="1" style="flex:1;resize:none" placeholder="Jot anything… a thought, a task, something to buy"></textarea>
|
||||
<button class="go" id="jot-save">Save</button></div>
|
||||
<div class="chips"><span class="hint">file it as…</span>${chips}</div>
|
||||
<div class="chips"><span class="hint">⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot</span></div></div>`;
|
||||
<div class="chips"><span class="hint" id="jot-hint">${JOT_HINT}</span></div></div>`;
|
||||
}
|
||||
function wireCapture(after) {
|
||||
const jot = $("#jot");
|
||||
@@ -39,10 +40,12 @@ function wireCapture(after) {
|
||||
const text = jot.value.trim();
|
||||
if (!text) return;
|
||||
const kind = $(".capture .chip.on")?.dataset.kind || "note";
|
||||
await api("/api/jot", { text, kind });
|
||||
const r = await api("/api/jot", { text, kind });
|
||||
await after();
|
||||
const j = $("#jot");
|
||||
if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting
|
||||
if (kind === "suggestion") jotSaid(r.issue ? `💡 sent to Tony — suggestion #${r.issue}, thank you!`
|
||||
: "💡 kept in your notes — it'll get passed along");
|
||||
};
|
||||
$("#jot-save").addEventListener("click", save);
|
||||
jot.addEventListener("keydown", (e) => {
|
||||
@@ -57,6 +60,13 @@ function wireCapture(after) {
|
||||
const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; });
|
||||
jot.addEventListener("input", autogrow);
|
||||
}
|
||||
// the keyboard-hint line doubles as the jot box's confirmation slot
|
||||
function jotSaid(msg) {
|
||||
const h = $("#jot-hint");
|
||||
if (!h) return;
|
||||
h.textContent = msg;
|
||||
setTimeout(() => { const x = $("#jot-hint"); if (x && x.textContent === msg) x.textContent = JOT_HINT; }, 7000);
|
||||
}
|
||||
// "/" from anywhere jumps to the jot box
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) {
|
||||
@@ -90,7 +100,7 @@ async function today() {
|
||||
? `<div class="todo"><span>🔥 <b>Firing #${s.activeFiring.id}</b> in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}</span></div>
|
||||
<div class="mt"><button class="btn mini" id="goto-firing">go to firing</button></div>`
|
||||
: `<div class="todo"><span>Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}</span></div>`;
|
||||
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"]]) +
|
||||
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"], ["suggestion", "💡 suggestion"]]) +
|
||||
`<div class="grid g2">
|
||||
<div class="card"><h3>To-do</h3>${todoRows}</div>
|
||||
<div class="card"><h3>Shopping list 🧺</h3>${shopRows}</div>
|
||||
@@ -604,7 +614,7 @@ async function sourcing() {
|
||||
// ── Notes ────────────────────────────────────────────────────────
|
||||
async function notes() {
|
||||
const list = await api("/api/notes");
|
||||
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"]]) +
|
||||
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["suggestion", "💡 suggestion"]]) +
|
||||
`<div class="grid g2">${list.map((n) => `
|
||||
<div class="card"><p style="font-size:14.5px">${esc(n.text)}</p>
|
||||
<div class="mt"><span class="meta">${esc(n.created.slice(0, 16))}</span>
|
||||
@@ -618,4 +628,61 @@ document.getElementById("signout")?.addEventListener("click", async () => {
|
||||
location.href = "/login";
|
||||
});
|
||||
|
||||
// ── version + one-tap update (OTM self-update) ───────────────────
|
||||
const foot = $("#foot");
|
||||
const said = (m) => { const s = $("#up-said"); if (s) s.textContent = m ? " · " + m : ""; };
|
||||
const get = (path) => fetch(path).then((r) => r.json()).catch(() => ({}));
|
||||
const newerThan = (a, b) => { // "0.4.1" is newer than "0.4.0"
|
||||
const p = (v) => [0, 1, 2].map((i) => Number(String(v || "").split(".")[i]) || 0);
|
||||
const [x, y] = [p(a), p(b)];
|
||||
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
|
||||
return false;
|
||||
};
|
||||
// the container restarts mid-update, so tolerate failures and watch for the version to flip
|
||||
async function awaitUpdate(from) {
|
||||
for (let i = 0; i < 130; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const v = (await get("/api/health")).version;
|
||||
if (v && v !== from) return location.reload();
|
||||
}
|
||||
said("still building — check back in a few minutes");
|
||||
}
|
||||
async function versionLine() {
|
||||
if (!foot) return;
|
||||
const running = (await get("/api/health")).version || "";
|
||||
if (!running) return; // unknown version → no chip (never a false "update!")
|
||||
// the update check costs OTM a Gitea round-trip — cache it for 10 minutes
|
||||
let s;
|
||||
try { s = JSON.parse(sessionStorage.getItem("bms-update") || "null"); } catch { s = null; }
|
||||
if (!s || Date.now() - s._at > 600e3 || s.status?.state === "running") {
|
||||
s = await get("/api/account/self-update"); // 503 when self-update isn't wired — just no chip
|
||||
s._at = Date.now();
|
||||
try { sessionStorage.setItem("bms-update", JSON.stringify(s)); } catch { /* private mode */ }
|
||||
}
|
||||
const updating = s.status?.state === "running";
|
||||
foot.innerHTML = `🌙 Studio Notebook${running ? " v" + esc(running) : ""} · bms.poweredbyotm.com`
|
||||
+ (!updating && newerThan(s.latest, running) ? ` <span class="chip" id="up-go">✨ update to v${esc(s.latest)}</span>` : "")
|
||||
+ `<span id="up-said">${updating ? " · updating the studio… ✨" : ""}</span>`;
|
||||
$("#up-go")?.addEventListener("click", async () => {
|
||||
$("#up-go").remove();
|
||||
said("starting the update — this takes a few minutes ✨");
|
||||
const r = await fetch("/api/account/self-update", { method: "POST" }).then((x) => x.json()).catch(() => ({}));
|
||||
if (!r.ok) return said(r.message || r.error || "couldn't start the update");
|
||||
awaitUpdate(running);
|
||||
});
|
||||
if (updating) awaitUpdate(running);
|
||||
}
|
||||
|
||||
render();
|
||||
versionLine();
|
||||
|
||||
// a session opened through OTM "Log in as admin" is visibly marked — the owner
|
||||
// (and the operator) should always be able to tell it apart from a real login
|
||||
(async () => {
|
||||
const me = await get("/api/me");
|
||||
if (!me.operator) return;
|
||||
const b = document.createElement("div");
|
||||
b.textContent = `🔑 support session — ${me.operator} is signed in as the studio owner`;
|
||||
b.style.cssText = "background:#f3e7f5;color:#6e4f78;text-align:center;padding:6px 10px;font-size:13px;";
|
||||
document.body.prepend(b);
|
||||
})();
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
.matrow input:first-child { flex: 1; }
|
||||
.matrow input:last-of-type { width: 80px; }
|
||||
footer { text-align: center; color: #b1a0ae; font-size: 13px; margin-top: 32px; font-style: italic; font-family: Georgia, serif; }
|
||||
footer .chip { font-family: "Avenir Next", "Nunito", sans-serif; font-style: normal; margin-left: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -106,7 +107,7 @@
|
||||
<div class="sub" id="greeting">welcome back</div>
|
||||
</header>
|
||||
<main id="view"></main>
|
||||
<footer>🌙 Studio Notebook · bms.poweredbyotm.com</footer>
|
||||
<footer id="foot">🌙 Studio Notebook · bms.poweredbyotm.com</footer>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
// ?reason=… arrives when an OTM "Log in as admin" link didn't take
|
||||
const REASONS = { not_enabled: "admin sign-in isn't set up for this studio yet",
|
||||
invalid_token: "that sign-in link isn't valid", link_already_used: "that sign-in link was already used — ask for a fresh one",
|
||||
no_admin: "there's no owner account here to sign in as", no_account: "no studio account matches your OTM account yet",
|
||||
server_error: "something went wrong — try again" };
|
||||
const reason = new URLSearchParams(location.search).get("reason");
|
||||
if (typeof REASONS[reason] === "string") document.getElementById("err").textContent = REASONS[reason];
|
||||
|
||||
document.getElementById("f").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const r = await fetch("/api/login", { method: "POST", headers: { "content-type": "application/json" },
|
||||
|
||||
Reference in New Issue
Block a user