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:
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.5.2";
|
||||
export const APP_VERSION = "0.6.0";
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bms-backbone",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -873,10 +873,56 @@ async function ideas() {
|
||||
}));
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
+22
-1
@@ -85,6 +85,27 @@
|
||||
.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; }
|
||||
|
||||
/* ported from @otm/account-panel otm-login-core.ts 0.44.0 (OTM_LOGIN_CSS,
|
||||
the .otm-badge/.otm-menu rules) — keep in sync, verbatim except
|
||||
.otm-menu__panel's `right:0` → `left:0` (the trigger sits in a 212px
|
||||
left sidebar; opening to the right would clip off the page edge). The
|
||||
studio's accent (--plum) is set inline on .otm-menu in app.js, not here. */
|
||||
.otm-badge{display:inline-block;border-radius:5px;padding:1px 5px;font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:color-mix(in srgb,var(--otm-accent,#b5562a) 16%,transparent);color:var(--otm-accent,#b5562a);vertical-align:middle}
|
||||
.otm-menu{position:relative;display:inline-block;--otm-accent:#b5562a;--otm-accent-ink:#fff;--otm-card:#fff;--otm-fg:#1f1b18;--otm-muted:#6b6560;--otm-line:rgba(31,27,24,.12)}
|
||||
@media (prefers-color-scheme:dark){.otm-menu{--otm-card:#211e1b;--otm-fg:#f3efe9;--otm-muted:#a39b92;--otm-line:rgba(243,239,233,.12)}}
|
||||
.otm-menu>summary{list-style:none;display:inline-flex;align-items:center;gap:8px;padding:4px 8px;border-radius:10px;cursor:pointer;color:inherit;font:inherit}
|
||||
.otm-menu>summary::-webkit-details-marker{display:none}
|
||||
.otm-menu>summary:hover{background:rgba(127,127,127,.1)}
|
||||
.otm-menu__avatar{width:26px;height:26px;border-radius:999px;background:var(--otm-accent);color:var(--otm-accent-ink);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}
|
||||
.otm-menu__name{font-size:14px}
|
||||
@media (max-width:640px){.otm-menu__name{display:none}}
|
||||
.otm-menu__panel{position:absolute;left:0;top:calc(100% + 6px);min-width:15rem;background:var(--otm-card);color:var(--otm-fg);border:1px solid var(--otm-line);border-radius:14px;padding:6px;box-shadow:0 10px 30px rgba(0,0,0,.18);z-index:50}
|
||||
.otm-menu__who{padding:8px 10px 10px;font-size:12px;color:var(--otm-muted);border-bottom:1px solid var(--otm-line);margin-bottom:6px}
|
||||
.otm-menu__who strong{display:flex;align-items:center;gap:6px;color:var(--otm-fg);font-weight:500;margin-bottom:2px}
|
||||
.otm-menu__item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border-radius:9px;border:0;background:none;color:inherit;font:inherit;font-size:14px;text-align:left;text-decoration:none;cursor:pointer}
|
||||
.otm-menu__item:hover{background:rgba(127,127,127,.1)}
|
||||
.otm-menu__item svg{width:16px;height:16px;opacity:.75;flex:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -102,7 +123,7 @@
|
||||
<button data-v="ideas">💭 Mind Dump</button>
|
||||
<button data-v="notes">📒 Notes</button>
|
||||
</nav>
|
||||
<nav style="margin-top:18px"><button id="signout" style="font-size:13px">👋 sign out</button></nav>
|
||||
<nav style="margin-top:18px" id="account-nav"></nav>
|
||||
</aside>
|
||||
<div class="wrap">
|
||||
<header style="padding-bottom:18px">
|
||||
|
||||
+49
-41
@@ -5,59 +5,67 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>🌙 Studio Notebook — sign in</title>
|
||||
<style>
|
||||
:root { --bg: #fdf7f2; --card: #fff; --ink: #4a3b47; --plum: #8d6b94; --plum-deep: #6e4f78;
|
||||
--blush-deep: #c98da8; --line: #eddfe3; --muted: #9c8698; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg);
|
||||
color: var(--ink); min-height: 100vh; display: grid; place-items: center; padding: 24px 16px; }
|
||||
.wrap { width: min(92vw, 380px); }
|
||||
.brand { text-align: center; margin-bottom: 22px; }
|
||||
.brand h1 { font-family: Georgia, serif; font-size: 26px; color: var(--plum-deep); margin-bottom: 4px; }
|
||||
.brand .sub { color: var(--muted); font-size: 14px; font-style: italic; font-family: Georgia, serif; }
|
||||
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 20px;
|
||||
padding: 34px 36px; box-shadow: 0 4px 16px rgba(141,107,148,0.1); }
|
||||
label { display: block; font-size: 12px; font-weight: 700; color: var(--blush-deep);
|
||||
text-transform: uppercase; letter-spacing: 0.08em; margin: 14px 0 5px; }
|
||||
input { width: 100%; border: 1.5px solid var(--line); border-radius: 12px; padding: 12px 15px;
|
||||
font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
|
||||
button, a.btn { width: 100%; display: block; margin-top: 22px; border: none; border-radius: 12px; padding: 13px;
|
||||
font-size: 15px; font-weight: 700; cursor: pointer; background: var(--plum); color: #fff; font-family: inherit;
|
||||
text-align: center; text-decoration: none; }
|
||||
button:hover, a.btn:hover { background: var(--plum-deep); }
|
||||
.err { color: #b3556b; font-size: 13.5px; margin-top: 12px; min-height: 18px; }
|
||||
.helper { color: var(--muted); font-size: 12.5px; text-align: center; margin-top: 14px; }
|
||||
.linklike { width: auto; display: block; margin: 16px auto 0; border: none; background: none;
|
||||
padding: 0; font-size: 13px; font-weight: 600; color: var(--muted); text-decoration: underline;
|
||||
cursor: pointer; font-family: inherit; text-align: center; }
|
||||
#local { display: none; }
|
||||
/* ported from @otm/account-panel otm-login-core.ts 0.44.0 (OTM_LOGIN_CSS) —
|
||||
keep in sync. Verbatim; the studio's brand (accent, heading font) is set
|
||||
via the documented --otm-* variables on .otm-login below, not by editing
|
||||
these rules. */
|
||||
.otm-login{--otm-accent:#b5562a;--otm-accent-ink:#fff;--otm-bg:#faf8f5;--otm-card:#fff;--otm-fg:#1f1b18;--otm-muted:#6b6560;--otm-line:rgba(31,27,24,.12);--otm-radius:20px;--otm-font-display:inherit;
|
||||
min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px 16px;background:var(--otm-bg);color:var(--otm-fg);box-sizing:border-box}
|
||||
@media (prefers-color-scheme:dark){.otm-login{--otm-bg:#161412;--otm-card:#211e1b;--otm-fg:#f3efe9;--otm-muted:#a39b92;--otm-line:rgba(243,239,233,.12)}}
|
||||
.otm-login *{box-sizing:border-box}
|
||||
.otm-login__col{width:100%;max-width:26rem}
|
||||
.otm-login__brand{text-align:center;margin-bottom:24px}
|
||||
.otm-login__mark{width:56px;height:56px;border-radius:999px;background:var(--otm-accent);color:var(--otm-accent-ink);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:20px;letter-spacing:.02em;margin-bottom:12px}
|
||||
.otm-login__mark svg,.otm-login__mark img{width:28px;height:28px}
|
||||
.otm-login__name{font-family:var(--otm-font-display);font-size:30px;line-height:1.1;letter-spacing:-.01em;font-weight:600;margin:0}
|
||||
.otm-login__tagline{color:var(--otm-muted);font-size:14px;margin:6px 0 0}
|
||||
.otm-login__card{background:var(--otm-card);border:1px solid var(--otm-line);border-radius:var(--otm-radius);padding:24px;box-shadow:0 4px 24px rgba(0,0,0,.06)}
|
||||
.otm-login__btn{display:flex;align-items:center;justify-content:center;width:100%;height:44px;border-radius:12px;border:0;background:var(--otm-accent);color:var(--otm-accent-ink);font:inherit;font-weight:600;font-size:15px;text-decoration:none;cursor:pointer}
|
||||
.otm-login__btn:hover{filter:brightness(1.06)}
|
||||
.otm-login__btn--outline{background:transparent;color:var(--otm-fg);border:1px solid var(--otm-line)}
|
||||
.otm-login__btn--outline:hover{background:rgba(127,127,127,.06);filter:none}
|
||||
.otm-login__btn[disabled]{opacity:.55;cursor:default}
|
||||
.otm-login__helper{color:var(--otm-muted);font-size:14px;text-align:center;margin:12px 0 0;line-height:1.45}
|
||||
.otm-login__error{color:#b3556b;font-size:14px;margin:0 0 12px;text-align:center}
|
||||
.otm-login__link{display:inline-block;background:none;border:0;padding:0;font:inherit;font-size:14px;color:var(--otm-muted);text-decoration:underline;text-underline-offset:3px;cursor:pointer}
|
||||
.otm-login__link:hover{color:var(--otm-fg)}
|
||||
.otm-login__foot{text-align:center;margin-top:16px}
|
||||
.otm-login__field{display:block;margin-bottom:12px}
|
||||
.otm-login__label{display:block;font-size:13px;color:var(--otm-muted);margin-bottom:6px}
|
||||
.otm-login__input{width:100%;height:44px;border-radius:12px;border:1px solid var(--otm-line);background:var(--otm-bg);color:var(--otm-fg);padding:0 14px;font:inherit;font-size:15px}
|
||||
.otm-login__input:focus{outline:2px solid color-mix(in srgb,var(--otm-accent) 45%,transparent);outline-offset:1px;border-color:var(--otm-accent)}
|
||||
.otm-login__stack>*+*{margin-top:10px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<h1>🌙 Studio Notebook</h1>
|
||||
<div class="sub">welcome back to the studio</div>
|
||||
<div class="otm-login" style="--otm-accent:#8d6b94;--otm-accent-ink:#fff;--otm-font-display:Georgia,'Iowan Old Style',serif">
|
||||
<div class="otm-login__col">
|
||||
<div class="otm-login__brand">
|
||||
<div class="otm-login__mark">🌙</div>
|
||||
<h1 class="otm-login__name">Studio Notebook</h1>
|
||||
<p class="otm-login__tagline">welcome back to the studio</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="err" id="err"></div>
|
||||
<div class="otm-login__card">
|
||||
<p class="otm-login__error" id="err" role="alert" hidden></p>
|
||||
|
||||
<!-- Sign in with OTM: one OTM account opens every OTM surface. The server
|
||||
fills in the link (it knows this studio's audience); left empty, the
|
||||
block stays hidden and the password form shows directly instead. -->
|
||||
<div id="otm-step">
|
||||
<a class="btn" id="otmLink" href="__OTM_SSO_URL__">Sign in with OTM</a>
|
||||
<div class="helper">Your OTM account — Face ID, Touch ID or a passkey works there.</div>
|
||||
<button type="button" class="linklike" id="showOther">Other ways to sign in</button>
|
||||
<a class="otm-login__btn" id="otmLink" href="__OTM_SSO_URL__">Sign in with OTM</a>
|
||||
<p class="otm-login__helper">Your OTM account — Face ID, Touch ID or a passkey works there.</p>
|
||||
<p class="otm-login__foot"><button type="button" class="otm-login__link" id="showOther">Other ways to sign in</button></p>
|
||||
</div>
|
||||
|
||||
<form id="local">
|
||||
<label>Email</label><input type="email" id="email" autocomplete="username" required>
|
||||
<label>Password</label><input type="password" id="password" autocomplete="current-password" required>
|
||||
<button type="submit">Sign in</button>
|
||||
<button type="button" class="linklike" id="showOtm">Sign in with OTM</button>
|
||||
<form id="local" class="otm-login__stack">
|
||||
<div class="otm-login__field"><label class="otm-login__label">Email</label><input class="otm-login__input" type="email" id="email" autocomplete="username" required></div>
|
||||
<div class="otm-login__field"><label class="otm-login__label">Password</label><input class="otm-login__input" type="password" id="password" autocomplete="current-password" required></div>
|
||||
<button class="otm-login__btn" type="submit">Sign in</button>
|
||||
<p class="otm-login__foot"><button type="button" class="otm-login__link" id="showOtm">Sign in with OTM</button></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// ?reason=… arrives when an OTM "Log in as admin" link didn't take
|
||||
const REASONS = { not_enabled: "OTM sign-in isn't set up for this studio yet",
|
||||
@@ -76,14 +84,14 @@
|
||||
e.preventDefault(); otmStep.style.display = "block"; localForm.style.display = "none";
|
||||
});
|
||||
const reason = new URLSearchParams(location.search).get("reason");
|
||||
if (typeof REASONS[reason] === "string") document.getElementById("err").textContent = REASONS[reason];
|
||||
if (typeof REASONS[reason] === "string") { const e = document.getElementById("err"); e.textContent = REASONS[reason]; e.hidden = false; }
|
||||
|
||||
document.getElementById("local").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const r = await fetch("/api/login", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: email.value, password: password.value }) }).then((x) => x.json());
|
||||
if (r.ok) location.href = "/";
|
||||
else document.getElementById("err").textContent = r.error || "sign-in failed";
|
||||
else { const el = document.getElementById("err"); el.textContent = r.error || "sign-in failed"; el.hidden = false; }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+2
-2
@@ -229,9 +229,9 @@ const routes = {
|
||||
if (!user) user = await db.user.findFirst({ where: { role: p.role }, orderBy: { id: "asc" } });
|
||||
if (!user) return fail("no_account");
|
||||
const next = p.next && p.next.startsWith("/") && !p.next.startsWith("//") ? p.next : "/";
|
||||
return { _setSession: { email: user.email, exp: Date.now() + SESSION_DAYS * 86400e3 }, _redirect: next };
|
||||
return { _setSession: { email: user.email, via: "otm", exp: Date.now() + SESSION_DAYS * 86400e3 }, _redirect: next };
|
||||
},
|
||||
"GET /api/me": async (_b, _q, session) => ({ email: session.email, operator: session.operator ?? null }),
|
||||
"GET /api/me": async (_b, _q, session) => ({ email: session.email, operator: session.operator ?? null, via: session.via ?? null }),
|
||||
|
||||
"GET /api/summary": async () => ({
|
||||
todos: (await db.todo.findMany({ orderBy: { id: "desc" }, take: 60 })).map(sTodo),
|
||||
|
||||
Reference in New Issue
Block a user