app: shared OTM login/account look, ported verbatim (v0.6.0)

Port the shared otm-login/otm-menu CSS+markup from
@otm/account-panel otm-login-core.ts 0.44.0 (verbatim, kept in sync
via header comments) into login.html and the sidebar account menu in
index.html/app.js. Accent is the studio's --plum, heading font via
the module's --otm-font-display variable. Behaviour is unchanged
(OTM-first / "Other ways to sign in" toggle, server-filled
__OTM_SSO_URL__, same reasons).

The account menu needs to know whether a session came in via OTM to
show the badge/"Manage OTM account" link, which the session didn't
track before — added a `via: "otm"` claim on the SSO callback's
session and exposed it from GET /api/me (additive; password and
operator-magic sessions are unaffected and read as not-linked).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LCQmFvKshyAJsE8C3B5ET
This commit is contained in:
2026-10-19 00:20:13 -05:00
co-authored by Claude Fable 5.1
parent fc5a82c45a
commit eca75c14b8
6 changed files with 132 additions and 57 deletions
+50 -4
View File
@@ -873,10 +873,56 @@ async function ideas() {
}));
}
document.getElementById("signout")?.addEventListener("click", async () => {
await api("/api/logout", {});
location.href = "/login";
});
// Account menu in the sidebar — plain-HTML port of @otm/account-panel's
// OtmAccountMenu (otm-login.tsx 0.44.0): initials avatar, OTM badge + "Manage
// OTM account" when the session came in via Sign in with OTM, Sign out
// always. No passwordHref — the studio has no local "change password" flow.
function initials(name, fallback = "?") {
const parts = String(name ?? "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return fallback;
return parts.slice(0, 2).map((p) => p[0].toUpperCase()).join("");
}
async function buildAccountMenu() {
const nav = document.getElementById("account-nav");
if (!nav) return;
// fetch() directly, not the shared `get` helper — that's defined further
// down this file and this runs immediately at script load.
const me = await fetch("/api/me").then((r) => r.json()).catch(() => ({}));
const email = me.email || "";
const linked = me.via === "otm";
nav.innerHTML = `
<details class="otm-menu" style="--otm-accent:#8d6b94;--otm-accent-ink:#fff">
<summary aria-label="Account menu">
<span class="otm-menu__avatar" aria-hidden="true">${esc(initials(email))}</span>
<span class="otm-menu__name">${esc(email)}</span>
${linked ? `<span class="otm-badge">OTM</span>` : ""}
</summary>
<div class="otm-menu__panel" role="menu">
<div class="otm-menu__who">
${linked ? `<strong><span class="otm-badge">OTM</span> Your OTM account</strong>` : ""}
<span>${esc(email)}</span>
</div>
${linked ? `<a class="otm-menu__item" role="menuitem" href="https://admin.poweredbyotm.com/account" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
Manage OTM account
</a>` : ""}
<button type="button" class="otm-menu__item" role="menuitem" id="signout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
Sign out
</button>
</div>
</details>`;
document.getElementById("signout")?.addEventListener("click", async () => {
await api("/api/logout", {});
location.href = "/login";
});
const menu = nav.querySelector("details.otm-menu");
if (menu) {
document.addEventListener("mousedown", (e) => { if (menu.open && !menu.contains(e.target)) menu.open = false; });
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && menu.open) menu.open = false; });
}
}
buildAccountMenu();
// ── version + one-tap update (OTM self-update) ───────────────────
const foot = $("#foot");