EXTENSION for TreeOS
html-rendering
Server-rendered HTML pages, share token auth, and a page registration API for other extensions. Direct imports (used by extensions that build their own pages): import { page } from '../html-rendering/html/layout.js' import { esc, escapeHtml } from '../html-rendering/html/utils.js' import { baseStyles, glassHeaderStyles, glassCardStyles } from '../html-rendering/html/baseStyles.js' import { htmlOnly, buildQS, tokenQS } from '../html-rendering/htmlHelpers.js' import urlAuth from '../html-rendering/urlAuth.js' import authenticateLite from '../html-rendering/authenticateLite.js' import { isHtmlEnabled } from '../html-rendering/config.js'
v1.0.1 by TreeOS Site 0 downloads 18 files 4,220 lines 122.9 KB published 38d ago
treeos ext install html-rendering
View changelog

Manifest

Provides

  • routes
  • 1 CLI commands

Requires

  • services: auth
  • models: User, Node
SHA256: 23533429ca6a4ac1e0bcdc1e80521115c2765320646fb81e93d0498e907d6679

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
ccGETCommand center. Tools, modes, extensions at this position.

Hooks

Listens To

  • afterRegister

Source Code

1import log from "../../seed/log.js";
2import jwt from "jsonwebtoken";
3import path from "path";
4import { fileURLToPath } from "url";
5import { resolveHtmlShareAccess } from "./shareAuth.js";
6
7const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9if (!process.env.JWT_SECRET) throw new Error("JWT_SECRET is required. Run the setup wizard or add it to .env");
10const JWT_SECRET = process.env.JWT_SECRET;
11
12/**
13 * Lightweight auth for HTML page API calls. Never rejects (always calls next).
14 * Sets req.userId if a valid JWT cookie, Bearer token, or share token is present.
15 * Share token support lets embedded fetch() calls work when the page was loaded
16 * via a share URL (no cookie, no JWT).
17 */
18export default async function authenticateLite(req, res, next) {
19  try {
20    // 1. JWT from cookie or Bearer header
21    const token =
22      req.cookies?.token ||
23      req.headers.authorization?.replace("Bearer ", "");
24
25    if (token) {
26      try {
27        const decoded = jwt.verify(token, JWT_SECRET);
28        req.userId = decoded.userId || decoded.id || decoded._id;
29        req.username = decoded.username;
30        req.authType = "jwt";
31        return next();
32      } catch {
33        // Invalid JWT, fall through to share token
34      }
35    }
36
37    // 2. Share token from query string (?token=...)
38    const shareToken = req.query?.token;
39    if (shareToken) {
40      const userId = req.params?.userId || req.query?.userId || null;
41      const nodeId = req.params?.nodeId || req.params?.rootId || req.query?.nodeId || null;
42
43      if (userId || nodeId) {
44        const result = await resolveHtmlShareAccess({ userId, nodeId, shareToken });
45        if (result.allowed) {
46          req.userId = result.matchedUserId;
47          req.username = result.matchedUsername;
48          req.authType = "share-token";
49          req.isHtmlShare = true;
50          return next();
51        }
52      }
53    }
54
55    // No auth matched. Continue without userId.
56    next();
57  } catch (err) {
58    log.debug("AuthLite", `Auth failed: ${err.message}`);
59    next();
60  }
61}
62
1import { getLandConfigValue } from "../../seed/landConfig.js";
2
3/**
4 * Check if HTML rendering is enabled.
5 * Reads from .config (runtime, changeable via CLI/API/AI).
6 * Falls back to process.env for migration.
7 */
8export function isHtmlEnabled() {
9  const configVal = getLandConfigValue("htmlEnabled");
10  if (configVal !== undefined && configVal !== null && configVal !== "") {
11    return String(configVal) !== "false";
12  }
13  // If env var is explicitly set, respect it. Otherwise default to true
14  // (if html-rendering is installed, HTML is wanted).
15  if (process.env.ENABLE_FRONTEND_HTML !== undefined) {
16    return process.env.ENABLE_FRONTEND_HTML === "true";
17  }
18  return true;
19}
20
1/**
2 * Generic App Dashboard
3 *
4 * One renderer for all tree-as-app extensions. The extension describes
5 * what to show. This file renders it. No extension-specific logic here.
6 *
7 * Usage from an extension's htmlRoutes.js:
8 *
9 *   import { renderAppDashboard } from "../../html-rendering/html/appDashboard.js";
10 *
11 *   res.send(renderAppDashboard({
12 *     rootId, rootName, token, userId, dateStr,
13 *     subtitle: "180g protein, 2400 cal target",
14 *     hero: { value: "1,847", label: "of 2,400 calories (77%)", color: "#48bb78" },
15 *     stats: [
16 *       { label: "avg cal/day", value: "2,100" },
17 *       { label: "days tracked", value: "12" },
18 *     ],
19 *     bars: [
20 *       { label: "Protein", current: 120, goal: 180, color: "#667eea", sub: "avg: 135g" },
21 *     ],
22 *     cards: [
23 *       { title: "Recent Log", items: [{ text: "Chicken and rice", sub: "12:30 PM" }] },
24 *       { title: "Past 7 Days", items: [{ text: "Mon", sub: "P:150 C:200 F:60" }] },
25 *     ],
26 *     commands: [
27 *       { cmd: "food <message>", desc: "Log what you ate" },
28 *     ],
29 *     chatBar: { placeholder: "What did you eat?", endpoint: "/api/v1/root/xyz/food" },
30 *   }));
31 */
32
33import { page } from "./layout.js";
34import { esc } from "./utils.js";
35import { glassCardStyles, glassHeaderStyles, responsiveBase } from "./baseStyles.js";
36import { chatBarCss, chatBarHtml, chatBarJs, commandsRefHtml } from "./chatBar.js";
37
38function pctColor(pct) {
39  if (pct >= 90) return "#48bb78";
40  if (pct >= 60) return "#ecc94b";
41  return "#718096";
42}
43
44export function renderAppDashboard(opts) {
45  const {
46    rootId, rootName, token, userId, dateStr, inApp,
47    subtitle, hero, stats, bars, cards, tags,
48    commands, chatBar, emptyState, afterBars, extraCss, extraJs,
49  } = opts;
50
51  const today = new Date();
52  const date = dateStr || today.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
53
54  const css = `
55    ${glassHeaderStyles}
56    ${glassCardStyles}
57    ${responsiveBase}
58
59    .app-layout { max-width: 800px; margin: 0 auto; padding: 1.5rem; }
60    .app-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; margin-top: 1.5rem; }
61    @media (max-width: 700px) { .app-grid { grid-template-columns: 1fr; } }
62
63    .section-title {
64      font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em;
65      color: rgba(255,255,255,0.5); margin-bottom: 0.5rem; margin-top: 1.5rem;
66    }
67
68    .app-hero { text-align: center; padding: 28px 0 20px; }
69    .app-hero-val { font-size: 2.5rem; font-weight: 700; line-height: 1; }
70    .app-hero-label { font-size: 0.85rem; color: rgba(255,255,255,0.4); margin-top: 4px; }
71    .app-hero-sub { font-size: 0.9rem; color: rgba(255,255,255,0.5); margin-top: 8px; }
72
73    .stat-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 16px; }
74    .stat-chip {
75      background: rgba(255,255,255,0.06); border-radius: 16px;
76      padding: 4px 12px; font-size: 0.8rem; color: rgba(255,255,255,0.5);
77    }
78    .stat-chip strong { color: rgba(255,255,255,0.8); }
79
80    .tag-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
81    .app-tag {
82      display: inline-block; padding: 3px 10px; border-radius: 12px;
83      font-size: 0.75rem; background: rgba(255,255,255,0.06);
84      border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.5);
85    }
86
87    .bar-wrap { margin-bottom: 16px; }
88    .bar-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
89    .bar-label { font-size: 0.9rem; color: rgba(255,255,255,0.8); font-weight: 500; }
90    .bar-value { font-size: 0.85rem; color: rgba(255,255,255,0.5); }
91    .bar-track { height: 10px; background: rgba(255,255,255,0.08); border-radius: 5px; overflow: hidden; margin-bottom: 4px; }
92    .bar-fill { height: 100%; border-radius: 5px; transition: width 0.3s; }
93    .bar-footer { display: flex; justify-content: space-between; font-size: 0.75rem; color: rgba(255,255,255,0.3); }
94
95    .card-item { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 0.85rem; }
96    .card-item:last-child { border-bottom: none; }
97    .card-text { color: rgba(255,255,255,0.7); margin-bottom: 2px; }
98    .card-sub { color: rgba(255,255,255,0.3); font-size: 0.75rem; }
99    .card-detail { display: flex; gap: 12px; font-size: 0.8rem; color: rgba(255,255,255,0.4); margin-top: 2px; }
100
101    .empty-state { color: rgba(255,255,255,0.35); font-size: 0.9rem; padding: 1rem 0; font-style: italic; }
102
103    .card-delete {
104      background: none; border: none; color: rgba(255,255,255,0.15); font-size: 1.1rem;
105      cursor: pointer; padding: 0 4px; line-height: 1; flex-shrink: 0;
106    }
107    .card-delete:hover { color: #ef4444; }
108  `;
109
110  // Nav
111  const navHtml = userId
112    ? `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
113        <a href="/api/v1/user/${esc(userId)}/apps?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">\u2190 Apps</a>
114        <div style="display:flex;gap:16px;">
115          <a href="/api/v1/root/${esc(rootId)}?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">Tree</a>
116          <a href="/api/v1/user/${esc(userId)}/llm?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">LLM</a>
117        </div>
118      </div>`
119    : "";
120
121  // Hero
122  const heroHtml = hero
123    ? `<div class="app-hero">
124        <div class="app-hero-val" style="color:${hero.color || "#fff"}">${esc(String(hero.value))}</div>
125        <div class="app-hero-label">${esc(hero.label || "")}</div>
126        ${hero.sub ? `<div class="app-hero-sub">${esc(hero.sub)}</div>` : ""}
127      </div>`
128    : "";
129
130  // Stats
131  const statsHtml = stats?.length > 0
132    ? `<div class="stat-row">${stats.map(s =>
133        `<span class="stat-chip"><strong>${esc(String(s.value))}</strong> ${esc(s.label)}</span>`
134      ).join("")}</div>`
135    : "";
136
137  // Tags
138  const tagsHtml = tags?.length > 0
139    ? `<div class="tag-row">${tags.map(t =>
140        typeof t === "string"
141          ? `<span class="app-tag">${esc(t)}</span>`
142          : `<span class="app-tag" style="${t.color ? `border-color:${t.color}30;color:${t.color}` : ""}">${esc(t.label)}${t.count != null ? ` <span style="opacity:0.5">${t.count}</span>` : ""}</span>`
143      ).join("")}</div>`
144    : "";
145
146  // Bars
147  const barsHtml = bars?.length > 0
148    ? `<div class="glass-card" style="padding:20px">${bars.map(b => {
149        const pct = b.goal > 0 ? Math.min(Math.round((b.current / b.goal) * 100), 100) : 0;
150        const remaining = Math.max(0, (b.goal || 0) - (b.current || 0));
151        const delBtn = b.deleteUrl
152          ? `<button class="card-delete" onclick="deleteEntry(this,'${esc(b.deleteUrl)}')" title="Remove metric" style="margin-left:8px">\u00d7</button>`
153          : "";
154        return `
155          <div class="bar-wrap">
156            <div class="bar-header">
157              <span class="bar-label">${esc(b.label)}${delBtn}</span>
158              <span class="bar-value">${Math.round(b.current)}/${b.goal}${b.unit || "g"} <span style="color:${pctColor(pct)}">(${pct}%)</span></span>
159            </div>
160            <div class="bar-track">
161              <div class="bar-fill" style="width:${pct}%;background:${b.color || "#667eea"}"></div>
162            </div>
163            <div class="bar-footer">
164              <span>${remaining > 0 ? Math.round(remaining) + (b.unit || "g") + " remaining" : "Goal reached"}</span>
165              <span>${b.sub || ""}</span>
166            </div>
167          </div>`;
168      }).join("")}</div>`
169    : "";
170
171  // Cards
172  const cardsHtml = cards?.length > 0
173    ? `<div class="app-grid">${cards.map(card => {
174        const itemsHtml = card.items?.length > 0
175          ? card.items.slice(0, card.limit || 10).map(item => {
176              if (typeof item === "string") return `<div class="card-item"><div class="card-text">${esc(item)}</div></div>`;
177              const deleteBtn = item.deleteUrl
178                ? `<button class="card-delete" onclick="deleteEntry(this,'${esc(item.deleteUrl)}')" title="Delete">\u00d7</button>`
179                : "";
180              return `
181                <div class="card-item"${item.bg ? ` style="padding:10px 12px;border-radius:8px;margin-bottom:6px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.04)"` : ""}>
182                  <div style="display:flex;justify-content:space-between;align-items:start">
183                    <div style="flex:1">
184                      <div class="card-text">${esc(item.text || "")}</div>
185                      ${item.sub ? `<div class="card-sub">${esc(item.sub)}</div>` : ""}
186                      ${item.detail ? `<div class="card-detail">${item.detail.map(d => `<span>${esc(d)}</span>`).join("")}</div>` : ""}
187                    </div>
188                    ${deleteBtn}
189                  </div>
190                </div>`;
191            }).join("")
192          : `<div class="empty-state">${esc(card.empty || "Nothing here yet.")}</div>`;
193
194        return `
195          <div class="glass-card" style="padding:16px">
196            <div class="section-title" style="margin-top:0">${esc(card.title)}</div>
197            ${itemsHtml}
198          </div>`;
199      }).join("")}</div>`
200    : "";
201
202  // Empty state (if no content at all)
203  const emptyHtml = (!hero && !bars?.length && !cards?.length && emptyState)
204    ? `<div class="glass-card" style="padding:32px;text-align:center">
205        <div style="font-size:1.1rem;color:rgba(255,255,255,0.6);margin-bottom:12px">${esc(emptyState.title || "Not initialized yet")}</div>
206        <div style="color:rgba(255,255,255,0.35);font-size:0.9rem;line-height:1.6">${esc(emptyState.message || "Send a message below to get started.")}</div>
207      </div>`
208    : "";
209
210  const body = `
211    <div class="app-layout">
212      ${navHtml}
213      <h1 style="font-size:1.5rem;color:#fff;margin-bottom:0">${esc(rootName || "App")}</h1>
214      <div style="color:rgba(255,255,255,0.35);font-size:0.85rem;margin-top:4px">${date}</div>
215      ${subtitle ? `<div style="color:rgba(255,255,255,0.3);font-size:0.8rem;margin-top:2px">${esc(subtitle)}</div>` : ""}
216
217      ${heroHtml}
218      ${statsHtml}
219      ${tagsHtml}
220      ${barsHtml}
221      ${afterBars || ""}
222      ${emptyHtml}
223      ${cardsHtml}
224
225      ${commands?.length > 0 ? commandsRefHtml(commands) : ""}
226    </div>
227  `;
228
229  const deleteJs = `
230    async function deleteEntry(btn, url) {
231      const item = btn.closest('.card-item') || btn.closest('.bar-wrap') || btn.parentElement;
232      item.style.opacity = '0.3';
233      try {
234        const sep = url.includes('?') ? '&' : '?';
235        const authUrl = ${token ? `url + sep + 'token=${esc(token)}'` : "url"};
236        const res = await fetch(authUrl, { method: 'DELETE', credentials: 'include' });
237        if (res.ok) {
238          item.style.transition = 'all 0.2s';
239          item.style.maxHeight = item.offsetHeight + 'px';
240          item.style.overflow = 'hidden';
241          requestAnimationFrame(() => {
242            item.style.maxHeight = '0';
243            item.style.padding = '0';
244            item.style.margin = '0';
245          });
246          setTimeout(() => location.reload(), 300);
247        } else {
248          item.style.opacity = '1';
249        }
250      } catch { item.style.opacity = '1'; }
251    }
252  `;
253
254  return page({
255    title: `${rootName || "App"} . ${date}`,
256    css: css + (extraCss || "") + (!inApp ? chatBarCss() : ""),
257    body: body + (!inApp && chatBar ? chatBarHtml({ placeholder: chatBar.placeholder || "Type a message..." }) : ""),
258    js: deleteJs + (extraJs || "") + (!inApp && chatBar ? chatBarJs({ endpoint: chatBar.endpoint, token, rootId }) : ""),
259  });
260}
261
1// ─────────────────────────────────────────────────
2// Shared CSS building blocks for HTML renderers
3//
4// Theme: Nightfall
5//   Dark slate surface. Sage accent. No glass. No orbs. No gradients.
6//   Class names preserved for compatibility (.glass-card, .note-card, etc.)
7//   Internals rewritten as solid surfaces with subtle borders.
8//
9// Import the pieces you need:
10//   import { baseStyles, backNavStyles } from ...
11//   <style>${baseStyles}${backNavStyles}
12//   ... page-specific CSS here ...
13//   </style>
14// ─────────────────────────────────────────────────
15
16// ─── Core: variables, reset, surface, keyframes, container ───
17
18export const baseStyles = `
19:root {
20  /* Surfaces */
21  --bg:           #0d1117;
22  --bg-elevated:  #161b24;
23  --bg-hover:     #1c222e;
24  --bg-active:    #222837;
25
26  /* Borders */
27  --border:        #232a38;
28  --border-strong: #2f3849;
29
30  /* Text */
31  --text:        #e6e8eb;
32  --text-muted:  #9ba1ad;
33  --text-dim:    #5d6371;
34
35  /* Accent (alive sage) */
36  --accent:        #7dd385;
37  --accent-strong: #9ce0a2;
38  --accent-bg:     rgba(125, 211, 133, 0.12);
39  --accent-border: rgba(125, 211, 133, 0.4);
40  --accent-glow:   rgba(125, 211, 133, 0.45);
41
42  /* Semantic */
43  --error:   #c97e6a;
44  --warning: #d4a574;
45  --success: #7dd385;
46
47  /* Backwards-compat aliases for legacy class consumers */
48  --glass-water-rgb: 22, 27, 36;
49  --glass-alpha:     1;
50  --glass-alpha-hover: 1;
51}
52
53* {
54  box-sizing: border-box;
55  margin: 0;
56  padding: 0;
57  -webkit-tap-highlight-color: transparent;
58}
59
60html, body {
61  background: var(--bg);
62  margin: 0;
63  padding: 0;
64}
65
66body {
67  font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
68  background: var(--bg);
69  min-height: 100vh;
70  min-height: 100dvh;
71  padding: 24px 20px;
72  color: var(--text);
73  position: relative;
74  overflow-x: hidden;
75  touch-action: manipulation;
76  font-size: 14px;
77  line-height: 1.55;
78  -webkit-font-smoothing: antialiased;
79  -moz-osx-font-smoothing: grayscale;
80}
81
82@keyframes fadeInUp {
83  from { opacity: 0; transform: translateY(8px); }
84  to   { opacity: 1; transform: translateY(0); }
85}
86
87.container {
88  max-width: 880px;
89  margin: 0 auto;
90  position: relative;
91  z-index: 1;
92}
93
94::selection { background: var(--accent-bg); color: var(--text); }
95`;
96
97// ─── Pill-shaped back navigation buttons ───
98
99export const backNavStyles = `
100.back-nav {
101  display: flex;
102  gap: 10px;
103  margin-bottom: 24px;
104  flex-wrap: wrap;
105  animation: fadeInUp 0.3s ease-out both;
106}
107
108.back-link {
109  display: inline-flex;
110  align-items: center;
111  gap: 6px;
112  padding: 8px 16px;
113  background: var(--bg-elevated);
114  color: var(--text-muted);
115  text-decoration: none;
116  border-radius: 8px;
117  font-weight: 500;
118  font-size: 13px;
119  border: 1px solid var(--border);
120  transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
121}
122
123.back-link:hover {
124  background: var(--bg-hover);
125  color: var(--text);
126  border-color: var(--border-strong);
127}
128`;
129
130// ─── Header panel (title bar with h1) ───
131
132export const glassHeaderStyles = `
133.header {
134  background: var(--bg-elevated);
135  border-radius: 12px;
136  padding: 24px 28px;
137  margin-bottom: 20px;
138  border: 1px solid var(--border);
139  color: var(--text);
140  animation: fadeInUp 0.35s ease-out both;
141}
142
143.header h1 {
144  font-size: 22px;
145  font-weight: 600;
146  color: var(--text);
147  margin-bottom: 6px;
148  line-height: 1.3;
149  letter-spacing: -0.3px;
150}
151
152.header h1 a {
153  color: var(--text);
154  text-decoration: none;
155  border-bottom: 1px solid var(--border-strong);
156  transition: border-color 150ms ease;
157}
158
159.header h1 a:hover {
160  border-bottom-color: var(--accent);
161}
162
163.message-count {
164  display: inline-block;
165  padding: 3px 10px;
166  background: var(--accent-bg);
167  color: var(--accent-strong);
168  border-radius: 980px;
169  font-size: 12px;
170  font-weight: 600;
171  margin-left: 10px;
172  border: 1px solid var(--accent-border);
173}
174
175.header-subtitle {
176  font-size: 13px;
177  color: var(--text-muted);
178  margin-bottom: 0;
179  font-weight: 400;
180  line-height: 1.5;
181}
182`;
183
184// ─── Note/item cards with category color borders ───
185
186export const glassCardStyles = `
187.notes-list {
188  list-style: none;
189  display: flex;
190  flex-direction: column;
191  gap: 10px;
192}
193
194.note-card {
195  --accent-rgb: 155, 161, 173;
196  position: relative;
197  background: var(--bg-elevated);
198  border-radius: 10px;
199  padding: 18px 22px;
200  border: 1px solid var(--border);
201  border-left: 3px solid rgba(var(--accent-rgb), 0.55);
202  transition: background 150ms ease, border-color 150ms ease;
203  color: var(--text);
204}
205
206.note-card:hover {
207  background: var(--bg-hover);
208  border-color: var(--border-strong);
209  border-left-color: rgba(var(--accent-rgb), 0.85);
210}
211
212/* ── Color variants (left border identifies the category) ── */
213.glass-default  { --accent-rgb: 155, 161, 173; }
214.glass-green    { --accent-rgb: 125, 211, 133; }
215.glass-red      { --accent-rgb: 201, 126, 106; }
216.glass-blue     { --accent-rgb: 122, 146, 184; }
217.glass-cyan     { --accent-rgb: 127, 179, 196; }
218.glass-gold     { --accent-rgb: 212, 165, 116; }
219.glass-purple   { --accent-rgb: 158, 130, 196; }
220.glass-pink     { --accent-rgb: 196, 130, 168; }
221.glass-orange   { --accent-rgb: 201, 142, 90;  }
222.glass-emerald  { --accent-rgb: 130, 200, 145; }
223.glass-teal     { --accent-rgb: 116, 168, 173; }
224.glass-indigo   { --accent-rgb: 130, 138, 196; }
225
226.note-content {
227  margin-bottom: 10px;
228  color: var(--text);
229  font-size: 14px;
230  line-height: 1.6;
231}
232
233.note-content:last-child { margin-bottom: 0; }
234
235.note-meta {
236  padding-top: 10px;
237  border-top: 1px solid var(--border);
238  font-size: 12px;
239  color: var(--text-muted);
240  line-height: 1.7;
241  display: flex;
242  flex-wrap: wrap;
243  align-items: center;
244  gap: 6px;
245}
246
247.note-meta a {
248  color: var(--text);
249  text-decoration: none;
250  font-weight: 500;
251  border-bottom: 1px solid var(--border-strong);
252  transition: border-color 150ms ease, color 150ms ease;
253}
254
255.note-meta a:hover {
256  border-bottom-color: var(--accent);
257  color: var(--accent-strong);
258}
259
260.meta-separator {
261  color: var(--text-dim);
262}
263`;
264
265// ─── Empty state panel ───
266
267export const emptyStateStyles = `
268.empty-state {
269  background: var(--bg-elevated);
270  border-radius: 12px;
271  padding: 48px 32px;
272  text-align: center;
273  border: 1px solid var(--border);
274  color: var(--text-muted);
275  animation: fadeInUp 0.35s ease-out both;
276}
277
278.empty-state-icon {
279  font-size: 40px;
280  margin-bottom: 12px;
281  opacity: 0.5;
282}
283
284.empty-state-text {
285  font-size: 16px;
286  color: var(--text);
287  margin-bottom: 6px;
288  font-weight: 600;
289}
290
291.empty-state-subtext {
292  font-size: 13px;
293  color: var(--text-muted);
294}
295`;
296
297// ─── Card panel (reusable container) ───
298
299export const glassCardPanelStyles = `
300.glass-card {
301  background: var(--bg-elevated);
302  border-radius: 12px;
303  padding: 24px 28px;
304  border: 1px solid var(--border);
305  margin-bottom: 16px;
306  animation: fadeInUp 0.35s ease-out both;
307  position: relative;
308  color: var(--text);
309}
310.glass-card h2 {
311  font-size: 16px;
312  font-weight: 600;
313  color: var(--text);
314  margin-bottom: 14px;
315  letter-spacing: -0.2px;
316}
317.glass-card h3 {
318  font-size: 13px;
319  font-weight: 600;
320  color: var(--text-muted);
321  text-transform: uppercase;
322  letter-spacing: 0.5px;
323  margin-bottom: 10px;
324}
325`;
326
327// ─── Form inputs and buttons ───
328
329export const glassFormStyles = `
330.glass-input {
331  padding: 10px 14px;
332  font-size: 14px;
333  border-radius: 8px;
334  border: 1px solid var(--border);
335  background: var(--bg);
336  color: var(--text);
337  font-family: inherit;
338  transition: border-color 150ms ease, background 150ms ease;
339  width: 100%;
340}
341.glass-input::placeholder { color: var(--text-dim); }
342.glass-input:focus {
343  outline: none;
344  border-color: var(--accent);
345  background: var(--bg-elevated);
346}
347.glass-select {
348  padding: 9px 12px;
349  font-size: 14px;
350  border-radius: 8px;
351  border: 1px solid var(--border);
352  background: var(--bg);
353  color: var(--text);
354  font-family: inherit;
355  cursor: pointer;
356  appearance: none;
357  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2 4l4 4 4-4' stroke='%239ba1ad' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
358  background-repeat: no-repeat;
359  background-position: right 12px center;
360  padding-right: 32px;
361}
362.glass-select option { background: var(--bg-elevated); color: var(--text); }
363.glass-btn-save {
364  padding: 8px 18px;
365  border-radius: 8px;
366  border: 1px solid var(--accent-border);
367  background: var(--accent-bg);
368  color: var(--accent-strong);
369  font-weight: 600;
370  font-size: 13px;
371  cursor: pointer;
372  transition: background 150ms ease, border-color 150ms ease;
373}
374.glass-btn-save:hover {
375  background: rgba(125, 211, 133, 0.2);
376  border-color: var(--accent);
377}
378.glass-btn-danger {
379  padding: 8px 18px;
380  border-radius: 8px;
381  border: 1px solid rgba(201, 126, 106, 0.35);
382  background: rgba(201, 126, 106, 0.1);
383  color: var(--error);
384  font-weight: 600;
385  font-size: 13px;
386  cursor: pointer;
387  transition: background 150ms ease, border-color 150ms ease;
388}
389.glass-btn-danger:hover {
390  background: rgba(201, 126, 106, 0.18);
391  border-color: rgba(201, 126, 106, 0.55);
392}
393`;
394
395// ─── Stat grid (counts, metrics) ───
396
397export const statGridStyles = `
398.stat-grid {
399  display: grid;
400  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
401  gap: 10px;
402}
403.stat-item {
404  padding: 18px 22px;
405  background: var(--bg-elevated);
406  border: 1px solid var(--border);
407  border-radius: 10px;
408  text-align: left;
409  transition: background 150ms ease, border-color 150ms ease;
410}
411.stat-item:hover {
412  background: var(--bg-hover);
413  border-color: var(--border-strong);
414}
415.stat-label {
416  font-size: 11px;
417  font-weight: 600;
418  text-transform: uppercase;
419  letter-spacing: 0.6px;
420  color: var(--text-muted);
421  margin-bottom: 6px;
422}
423.stat-value {
424  font-size: 26px;
425  font-weight: 600;
426  color: var(--text);
427  letter-spacing: -0.5px;
428}
429.stat-sub {
430  font-size: 12px;
431  color: var(--text-dim);
432  margin-top: 4px;
433}
434`;
435
436// ─── Status message bar ───
437
438export const statusBarStyles = `
439.status-bar {
440  display: none;
441  padding: 10px 14px;
442  border-radius: 8px;
443  font-size: 13px;
444  font-weight: 500;
445  margin-top: 10px;
446  text-align: center;
447  background: var(--bg-elevated);
448  border: 1px solid var(--border);
449  color: var(--text-muted);
450}
451.status-bar.success { color: var(--success); border-color: var(--accent-border); background: var(--accent-bg); }
452.status-bar.error   { color: var(--error);   border-color: rgba(201, 126, 106, 0.35); background: rgba(201, 126, 106, 0.1); }
453`;
454
455// ─── Responsive breakpoints ───
456
457export const responsiveBase = `
458@media (max-width: 640px) {
459  body { padding: 16px 14px; }
460  .header { padding: 20px 22px; }
461  .header h1 { font-size: 20px; }
462  .message-count { display: inline-block; margin-left: 8px; }
463  .note-card { padding: 16px 18px; }
464  .glass-card { padding: 20px 22px; }
465  .back-nav { flex-direction: column; }
466  .back-link { width: 100%; justify-content: center; }
467  .empty-state { padding: 32px 22px; }
468  .stat-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
469  .stat-item { padding: 14px 16px; }
470  .stat-value { font-size: 22px; }
471}
472
473@media (min-width: 641px) and (max-width: 1024px) {
474  .container { max-width: 740px; }
475}
476`;
477
1/**
2 * Chat Bar
3 *
4 * Embeddable chat widget for extension dashboard pages.
5 * Floats at the bottom. Input bar, output area, minimize toggle.
6 * Uses fetch POST to the extension's route. No WebSocket needed.
7 * Messages persist in sessionStorage across page navigations.
8 *
9 * Usage in a dashboard page:
10 *   import { chatBarCss, chatBarHtml, chatBarJs } from "../../html-rendering/html/chatBar.js";
11 *   return page({
12 *     css: myStyles + chatBarCss(),
13 *     body: myBody + chatBarHtml({ placeholder: "Ask about your studies..." }),
14 *     js: chatBarJs({ endpoint: `/api/v1/root/${rootId}/study`, token }),
15 *   });
16 */
17
18export function chatBarCss() {
19  return `
20    .chat-bar {
21      position: fixed;
22      bottom: 12px;
23      left: 12px;
24      right: 12px;
25      z-index: 1000;
26      transition: transform 0.3s ease;
27      border-radius: 16px;
28      border: 1px solid rgba(255, 255, 255, 0.45);
29      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
30      background: rgba(255, 255, 255, 0.06);
31      backdrop-filter: blur(30px) saturate(150%);
32      -webkit-backdrop-filter: blur(30px) saturate(150%);
33      overflow: hidden;
34    }
35    .chat-bar.minimized { transform: translateY(calc(100% - 44px)); }
36
37    .chat-bar-drag {
38      width: 40px;
39      height: 4px;
40      background: rgba(255,255,255,0.15);
41      border-radius: 2px;
42      margin: 0 auto;
43      cursor: ns-resize;
44      position: absolute;
45      top: -8px;
46      left: 50%;
47      transform: translateX(-50%);
48    }
49    .chat-bar-drag:hover { background: rgba(255,255,255,0.3); }
50
51    .chat-bar-toggle {
52      display: flex;
53      align-items: center;
54      justify-content: space-between;
55      padding: 10px 20px;
56      background: rgba(255, 255, 255, 0.06);
57      border-top: none;
58      cursor: pointer;
59      user-select: none;
60    }
61    .chat-bar-toggle-label {
62      font-size: 0.8rem;
63      color: rgba(255,255,255,0.5);
64      letter-spacing: 0.05em;
65    }
66    .chat-bar-toggle-icon {
67      font-size: 0.9rem;
68      color: rgba(255,255,255,0.4);
69      transition: transform 0.3s;
70    }
71    .chat-bar.minimized .chat-bar-toggle-icon { transform: rotate(180deg); }
72
73    .chat-bar-messages {
74      height: 300px;
75      overflow-y: auto;
76      padding: 16px 20px;
77      background: linear-gradient(180deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.06) 50%, rgba(255,255,255,0.02) 100%);
78      display: flex;
79      flex-direction: column;
80      gap: 12px;
81    }
82
83    .chat-msg {
84      font-size: 0.9rem;
85      line-height: 1.6;
86      padding: 8px 12px;
87      border-radius: 8px;
88      max-width: 85%;
89      white-space: pre-wrap;
90      word-wrap: break-word;
91    }
92    .chat-msg.user {
93      align-self: flex-end;
94      background: rgba(255,255,255,0.1);
95      border: 1px solid rgba(255,255,255,0.2);
96      color: rgba(255,255,255,0.95);
97    }
98    .chat-msg.ai {
99      align-self: flex-start;
100      background: rgba(255,255,255,0.05);
101      border: 1px solid rgba(255,255,255,0.1);
102      color: rgba(255,255,255,0.85);
103    }
104    .chat-msg.error {
105      align-self: center;
106      background: rgba(239, 68, 68, 0.1);
107      color: rgba(239, 68, 68, 0.8);
108      font-size: 0.8rem;
109    }
110    .chat-msg.loading {
111      align-self: flex-start;
112      color: rgba(255,255,255,0.4);
113      font-style: italic;
114    }
115    .chat-msg.loading .thinking-text {
116      display: inline-block;
117      animation: thinkFade 0.6s ease-in-out;
118      transition: opacity 0.3s ease, transform 0.3s ease;
119    }
120    @keyframes thinkFade {
121      from { opacity: 0; transform: translateY(4px); }
122      to { opacity: 1; transform: translateY(0); }
123    }
124    @keyframes breathe {
125      0%, 100% { transform: scale(1); }
126      50% { transform: scale(1.01); }
127    }
128    body.thinking .glass-card,
129    body.thinking .category-card,
130    body.thinking .stat-pill {
131      animation: breathe 2.5s ease-in-out infinite;
132    }
133
134    .chat-bar-input-row {
135      display: flex;
136      gap: 8px;
137      padding: 12px 20px 16px;
138      background: rgba(255, 255, 255, 0.04);
139      border-top: 1px solid rgba(255,255,255,0.1);
140    }
141    .chat-bar-input {
142      flex: 1;
143      background: rgba(255,255,255,0.08);
144      border: 1px solid rgba(255,255,255,0.25);
145      border-radius: 10px;
146      padding: 10px 14px;
147      color: #fff;
148      font-size: 0.9rem;
149      outline: none;
150      font-family: inherit;
151    }
152    .chat-bar-input:focus { border-color: rgba(255,255,255,0.4); box-shadow: 0 0 0 2px rgba(255,255,255,0.08); }
153    .chat-bar-input::placeholder { color: rgba(255,255,255,0.35); }
154
155    .chat-bar-send {
156      background: rgba(255, 255, 255, 0.12);
157      border: 1px solid rgba(255,255,255,0.3);
158      border-radius: 10px;
159      padding: 10px 18px;
160      color: rgba(255,255,255,0.9);
161      font-size: 0.85rem;
162      cursor: pointer;
163      white-space: nowrap;
164      transition: all 0.2s;
165    }
166    .chat-bar-send:hover { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.4); }
167    .chat-bar-send:disabled { opacity: 0.4; cursor: not-allowed; }
168
169    @media (max-width: 640px) {
170      .chat-bar {
171        bottom: 0;
172        left: 0;
173        right: 0;
174        border-radius: 16px 16px 0 0;
175        border-bottom: none;
176        background: rgba(15, 10, 46, 0.95);
177        backdrop-filter: blur(20px);
178        -webkit-backdrop-filter: blur(20px);
179        border-color: rgba(255,255,255,0.15);
180      }
181      .chat-bar.minimized { transform: translateY(calc(100% - 40px)); }
182      .chat-bar-messages { height: 200px; }
183      .chat-bar-toggle { padding: 8px 16px; }
184      .chat-bar-input-row { padding: 8px 12px 12px; }
185      .chat-bar-input { font-size: 16px; }
186      .cmd-ref { margin-bottom: 100px; }
187    }
188
189    .cmd-ref {
190      margin-top: 24px;
191      margin-bottom: 80px;
192      border-top: 1px solid rgba(255,255,255,0.06);
193      padding-top: 12px;
194    }
195    .cmd-ref summary {
196      font-size: 0.75rem;
197      text-transform: uppercase;
198      letter-spacing: 0.1em;
199      color: rgba(255,255,255,0.3);
200      cursor: pointer;
201      user-select: none;
202      list-style: none;
203    }
204    .cmd-ref summary::-webkit-details-marker { display: none; }
205    .cmd-ref summary::before { content: "▸ "; }
206    .cmd-ref[open] summary::before { content: "▾ "; }
207    .cmd-ref-list {
208      margin-top: 8px;
209      display: flex;
210      flex-direction: column;
211      gap: 4px;
212    }
213    .cmd-ref-item {
214      display: flex;
215      gap: 12px;
216      font-size: 0.8rem;
217      padding: 3px 0;
218    }
219    .cmd-ref-cmd {
220      font-family: monospace;
221      color: rgba(255,255,255,0.5);
222      min-width: 140px;
223      flex-shrink: 0;
224    }
225    .cmd-ref-desc { color: rgba(255,255,255,0.3); }
226
227    @keyframes dotPulse {
228      0%, 80%, 100% { opacity: 0.3; }
229      40% { opacity: 1; }
230    }
231    .loading-dots span {
232      animation: dotPulse 1.4s infinite;
233    }
234    .loading-dots span:nth-child(2) { animation-delay: 0.2s; }
235    .loading-dots span:nth-child(3) { animation-delay: 0.4s; }
236  `;
237}
238
239/**
240 * Render a collapsible commands reference section.
241 * @param {Array<{cmd: string, desc: string}>} commands
242 */
243export function commandsRefHtml(commands) {
244  if (!commands || commands.length === 0) return "";
245  const items = commands.map(c =>
246    `<div class="cmd-ref-item"><span class="cmd-ref-cmd">${c.cmd}</span><span class="cmd-ref-desc">${c.desc}</span></div>`
247  ).join("");
248  return `<details class="cmd-ref"><summary>Commands</summary><div class="cmd-ref-list">${items}</div></details>`;
249}
250
251export function chatBarHtml({ placeholder = "Type a message..." } = {}) {
252  return `
253    <div class="chat-bar minimized" id="chatBar">
254      <div class="chat-bar-drag" id="chatDragHandle"></div>
255      <div class="chat-bar-toggle" onclick="toggleChatBar()">
256        <span class="chat-bar-toggle-label">Chat</span>
257        <span style="display:flex;align-items:center;gap:10px;" onclick="event.stopPropagation()">
258          <span id="chatClearBtn" class="chat-bar-toggle-icon" onclick="clearChatBar()" title="Clear chat" style="cursor:pointer;font-size:0.75rem;color:rgba(255,255,255,0.3);">clear</span>
259          <span class="chat-bar-toggle-icon" onclick="toggleChatBar()" style="cursor:pointer;">▲</span>
260        </span>
261      </div>
262      <div class="chat-bar-messages" id="chatMessages"></div>
263      <div class="chat-bar-input-row">
264        <input class="chat-bar-input" id="chatInput" placeholder="${placeholder}"
265               onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChatMessage()}" />
266        <button class="chat-bar-send" id="chatSend" onclick="sendChatMessage()">Send</button>
267      </div>
268    </div>
269  `;
270}
271
272export function chatBarJs({ endpoint, rootId, token } = {}) {
273  return `
274    function clearChatBar() {
275      document.getElementById('chatMessages').innerHTML = '';
276      try { sessionStorage.removeItem('chatbar:' + window.location.pathname); } catch {}
277    }
278
279    var _chatStorageKey = 'chatbar:' + window.location.pathname;
280
281    function saveChatHistory() {
282      var msgs = [];
283      var container = document.getElementById('chatMessages');
284      if (!container) return;
285      for (var el of container.children) {
286        if (el.classList.contains('loading')) continue;
287        var role = el.classList.contains('user') ? 'user' : el.classList.contains('error') ? 'error' : 'ai';
288        msgs.push({ role: role, text: el.textContent });
289      }
290      try { sessionStorage.setItem(_chatStorageKey, JSON.stringify(msgs.slice(-30))); } catch {}
291    }
292
293    function restoreChatHistory() {
294      try {
295        var saved = sessionStorage.getItem(_chatStorageKey);
296        if (!saved) return;
297        var msgs = JSON.parse(saved);
298        if (!Array.isArray(msgs) || msgs.length === 0) return;
299        for (var m of msgs) {
300          appendMessage(m.role, m.text);
301        }
302        // Open chat bar if there's history
303        document.getElementById('chatBar').classList.remove('minimized');
304      } catch {}
305    }
306
307    function toggleChatBar() {
308      document.getElementById('chatBar').classList.toggle('minimized');
309      var input = document.getElementById('chatInput');
310      if (!document.getElementById('chatBar').classList.contains('minimized')) {
311        setTimeout(function() { input.focus(); }, 100);
312      }
313    }
314
315    function appendMessage(role, text) {
316      var el = document.createElement('div');
317      el.className = 'chat-msg ' + role;
318      el.textContent = text;
319      var container = document.getElementById('chatMessages');
320      container.appendChild(el);
321      container.scrollTop = container.scrollHeight;
322      return el;
323    }
324
325    var _activeAbort = null;
326
327    function stopChatMessage() {
328      if (_activeAbort) {
329        _activeAbort.abort();
330        _activeAbort = null;
331      }
332    }
333
334    async function sendChatMessage() {
335      var input = document.getElementById('chatInput');
336      var message = input.value.trim();
337      if (!message) return;
338
339      input.value = '';
340      var sendBtn = document.getElementById('chatSend');
341      sendBtn.textContent = 'Stop';
342      sendBtn.onclick = stopChatMessage;
343      input.disabled = true;
344      document.getElementById('chatClearBtn').style.opacity = '0.15';
345      document.getElementById('chatClearBtn').style.pointerEvents = 'none';
346
347      // Open chat bar if minimized
348      document.getElementById('chatBar').classList.remove('minimized');
349
350      appendMessage('user', message);
351      saveChatHistory();
352
353      var _thinkingPhrases = [
354        'Reading the tree...',
355        'Thinking through the branches...',
356        'Finding the right path...',
357        'Connecting what you said to what it knows...',
358        'Building the response...',
359        'The tree is listening...',
360        'Processing against the structure...',
361        'Pulling context from the roots...',
362        'Almost there...',
363        'Following the signals...',
364        'Assembling the picture...',
365        'One moment. The tree is working.',
366        'Checking the branches...',
367        'Shaping the answer...',
368      ];
369      var loadingEl = document.createElement('div');
370      loadingEl.className = 'chat-msg loading';
371      var _thinkSpan = document.createElement('span');
372      _thinkSpan.className = 'thinking-text';
373      _thinkSpan.textContent = _thinkingPhrases[Math.floor(Math.random() * _thinkingPhrases.length)];
374      loadingEl.appendChild(_thinkSpan);
375      document.getElementById('chatMessages').appendChild(loadingEl);
376      document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
377      document.body.classList.add('thinking');
378
379      var _thinkUsed = [_thinkingPhrases.indexOf(_thinkSpan.textContent)];
380      var _thinkTimer = setInterval(function() {
381        var available = _thinkingPhrases.filter(function(_, i) { return _thinkUsed.indexOf(i) === -1; });
382        if (available.length === 0) { _thinkUsed = []; available = _thinkingPhrases; }
383        var pick = Math.floor(Math.random() * available.length);
384        var _thinkIdx = _thinkingPhrases.indexOf(available[pick]);
385        _thinkUsed.push(_thinkIdx);
386        _thinkSpan.style.opacity = '0';
387        _thinkSpan.style.transform = 'translateY(4px)';
388        setTimeout(function() {
389          _thinkSpan.textContent = _thinkingPhrases[_thinkIdx];
390          _thinkSpan.style.opacity = '1';
391          _thinkSpan.style.transform = 'translateY(0)';
392        }, 300);
393      }, 3000);
394
395      _activeAbort = new AbortController();
396
397      try {
398        var res = await fetch('${endpoint}', {
399          method: 'POST',
400          headers: { 'Content-Type': 'application/json' },
401          credentials: 'include',
402          body: JSON.stringify({ message: message }),
403          signal: _activeAbort.signal,
404        });
405
406        var data = await res.json();
407        clearInterval(_thinkTimer); document.body.classList.remove('thinking'); loadingEl.remove();
408
409        if (data.status === 'ok' && data.data) {
410          var answer = data.data.answer || data.data.synthesis || JSON.stringify(data.data);
411          appendMessage('ai', answer);
412          saveChatHistory();
413          // Refresh dashboard data without full page reload
414          refreshDashboardData();
415        } else if (data.error) {
416          appendMessage('error', data.error.message || 'Something went wrong.');
417        } else {
418          appendMessage('ai', JSON.stringify(data));
419        }
420      } catch (err) {
421        clearInterval(_thinkTimer); document.body.classList.remove('thinking'); loadingEl.remove();
422        if (err.name === 'AbortError') {
423          appendMessage('error', 'Cancelled.');
424        } else {
425          appendMessage('error', 'Connection failed.');
426        }
427      }
428
429      _activeAbort = null;
430      var sendBtn = document.getElementById('chatSend');
431      sendBtn.textContent = 'Send';
432      sendBtn.onclick = sendChatMessage;
433      sendBtn.disabled = false;
434      input.disabled = false;
435      document.getElementById('chatClearBtn').style.opacity = '';
436      document.getElementById('chatClearBtn').style.pointerEvents = '';
437      input.focus();
438      saveChatHistory();
439    }
440
441    // Refresh dashboard content without full page reload
442    async function refreshDashboardData() {
443      try {
444        var res = await fetch(window.location.href, { credentials: 'include' });
445        if (!res.ok) { window.location.reload(); return; }
446        var html = await res.text();
447        var parser = new DOMParser();
448        var doc = parser.parseFromString(html, 'text/html');
449        var newContent = doc.querySelector('.container, .rec-layout, .kb-layout, [class*="-layout"]');
450        var oldContent = document.querySelector('.container, .rec-layout, .kb-layout, [class*="-layout"]');
451        if (newContent && oldContent) {
452          oldContent.innerHTML = newContent.innerHTML;
453        } else {
454          window.location.reload();
455        }
456      } catch {
457        window.location.reload();
458      }
459    }
460
461    // Drag to resize chat bar
462    (function() {
463      var handle = document.getElementById('chatDragHandle');
464      var chatBar = document.getElementById('chatBar');
465      var messages = document.getElementById('chatMessages');
466      var dragging = false;
467      var startY = 0;
468      var startHeight = 0;
469      var DEFAULT_HEIGHT = 300;
470      var MIN_HEIGHT = 120;
471      var MAX_HEIGHT = window.innerHeight * 0.7;
472
473      handle.addEventListener('mousedown', function(e) {
474        e.preventDefault();
475        dragging = true;
476        startY = e.clientY;
477        startHeight = messages.offsetHeight;
478        chatBar.style.transition = 'none';
479        document.body.style.userSelect = 'none';
480      });
481
482      handle.addEventListener('touchstart', function(e) {
483        dragging = true;
484        startY = e.touches[0].clientY;
485        startHeight = messages.offsetHeight;
486        chatBar.style.transition = 'none';
487      }, { passive: true });
488
489      document.addEventListener('mousemove', function(e) {
490        if (!dragging) return;
491        var delta = startY - e.clientY;
492        var newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + delta));
493        messages.style.height = newHeight + 'px';
494      });
495
496      document.addEventListener('touchmove', function(e) {
497        if (!dragging) return;
498        var delta = startY - e.touches[0].clientY;
499        var newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + delta));
500        messages.style.height = newHeight + 'px';
501      }, { passive: true });
502
503      document.addEventListener('mouseup', function() {
504        if (!dragging) return;
505        dragging = false;
506        chatBar.style.transition = '';
507        document.body.style.userSelect = '';
508      });
509
510      document.addEventListener('touchend', function() {
511        if (!dragging) return;
512        dragging = false;
513        chatBar.style.transition = '';
514      });
515    })();
516
517    // Kill entry animations after they play so they don't replay on DOM updates
518    setTimeout(function() {
519      var cards = document.querySelectorAll('.glass-card, [style*="animation"]');
520      for (var i = 0; i < cards.length; i++) {
521        cards[i].style.animation = 'none';
522      }
523    }, 1500);
524
525    // Restore chat history on page load
526    restoreChatHistory();
527
528    // Auto-send startMsg from URL (used by apps/create redirect)
529    (function() {
530      var params = new URLSearchParams(window.location.search);
531      var startMsg = params.get('startMsg');
532      if (startMsg && startMsg.trim()) {
533        // Clean the URL so refresh doesn't re-send
534        var clean = new URL(window.location);
535        clean.searchParams.delete('startMsg');
536        history.replaceState(null, '', clean.toString());
537        // Send after a short delay to let the page render
538        setTimeout(function() {
539          document.getElementById('chatInput').value = startMsg;
540          sendChatMessage();
541        }, 500);
542      }
543    })();
544
545    // Live dashboard updates via WebSocket.
546    // When any extension writes data to this tree, the server emits dashboardUpdate.
547    // We re-fetch and swap the layout content (same logic as refreshDashboardData).
548    (function() {
549      ${rootId ? `
550      var liveRootId = '${rootId}';
551      var src = document.createElement('script');
552      src.src = '/socket.io/socket.io.js';
553      src.onload = function() {
554        var sock = io({ auth: { token: '${token || ''}' } });
555        sock.on('dashboardUpdate', function(msg) {
556          if (msg.rootId !== liveRootId) return;
557          // Skip if the user is currently typing a chat message
558          if (document.body.classList.contains('thinking')) return;
559          refreshDashboardData();
560        });
561      };
562      document.head.appendChild(src);
563      ` : '// No rootId provided, live updates disabled.'}
564    })();
565  `;
566}
567
1/* ------------------------------------------------- */
2/* Error page (layout-wrapped)                       */
3/* ------------------------------------------------- */
4
5import { page } from "./layout.js";
6
7export function errorHtml(status, title, message) {
8  const css = `
9/* ── Error page overrides on base ── */
10html, body { height: 100%; }
11body {
12  color: white;
13  display: flex;
14  align-items: center;
15  justify-content: center;
16}
17
18/* Hide base orbs for centered layout */
19body::before, body::after { display: none; }
20.card {
21  background: rgba(255,255,255,0.12);
22  backdrop-filter: blur(20px);
23  -webkit-backdrop-filter: blur(20px);
24  border: 1px solid rgba(255,255,255,0.2);
25  border-radius: 20px;
26  padding: 48px 40px;
27  max-width: 480px;
28  width: 100%;
29  text-align: center;
30  box-shadow: 0 20px 60px rgba(0,0,0,0.2);
31}
32.code {
33  display: inline-block;
34  margin-bottom: 12px;
35  font-size: 13px;
36  font-weight: 700;
37  color: #dc2626;
38  letter-spacing: 1px;
39  background: rgba(255,255,255,0.18);
40  border-radius: 10px;
41  padding: 6px 16px;
42}
43.icon { font-size: 48px; margin-bottom: 8px; }
44.brand {
45  font-size: 28px;
46  font-weight: 700;
47  color: white;
48  margin-bottom: 20px;
49  text-decoration: none;
50  display: block;
51}
52.brand:hover { opacity: 0.9; }
53h1 {
54  font-size: 22px;
55  font-weight: 700;
56  margin-bottom: 12px;
57  color: white;
58}
59p {
60  font-size: 15px;
61  line-height: 1.6;
62  color: rgba(255,255,255,0.75);
63  margin-bottom: 28px;
64}
65.btn {
66  display: inline-block;
67  padding: 12px 32px;
68  border-radius: 980px;
69  background: rgba(255,255,255,0.18);
70  border: 1px solid rgba(255,255,255,0.25);
71  color: white;
72  font-size: 14px;
73  font-weight: 600;
74  text-decoration: none;
75  transition: all 0.2s;
76}
77.btn:hover {
78  background: rgba(255,255,255,0.28);
79  transform: translateY(-1px);
80}
81.ai-note {
82  margin-top: 20px;
83  padding: 12px 16px;
84  background: rgba(239,68,68,0.2);
85  border: 1px solid rgba(239,68,68,0.35);
86  border-radius: 12px;
87  font-size: 13px;
88  line-height: 1.5;
89  color: rgba(255,255,255,0.85);
90}
91@keyframes heroGrow {
92  0%, 100% { transform: scale(1); }
93  50% { transform: scale(1.06); }
94}
95.icon { animation: heroGrow 4.5s ease-in-out infinite; }`;
96
97  const bodyHtml = `
98<div class="card">
99  <div class="code">${status}</div>
100  <a href="/" class="brand" onclick="event.preventDefault(); window.top.location.href='/';">
101    <div class="icon">\u{1F333}</div>
102    Tree
103  </a>
104  <h1>${title}</h1>
105  <p>${message}</p>
106  <a href="/" class="btn" onclick="event.preventDefault(); window.top.location.href='/';">Back to Home</a>
107  <div class="ai-note">If this was triggered by an AI automated process, wait a moment. You may be redirected shortly.</div>
108</div>`;
109
110  return page({
111    title: `${title} - TreeOS`,
112    css,
113    body: bodyHtml,
114  });
115}
116
1// Layout
2//
3// Every server-rendered page in html-rendering uses this wrapper.
4// It provides the HTML document skeleton, meta tags, and shared CSS.
5// Pages supply their title, page-specific styles, body content, and scripts.
6//
7// Usage from a page file:
8//
9//   import { page } from "./layout.js";
10//
11//   export function renderMyPage({ name }) {
12//     return page({
13//       title: `${name} -- My Page`,
14//       css: `.my-class { color: white; }`,
15//       body: `<div class="container"><h1>${esc(name)}</h1></div>`,
16//       js: `console.log("page loaded");`,
17//     });
18//   }
19//
20// Options:
21//   title    - Page title (appears in browser tab)
22//   css      - Page-specific CSS (injected after shared styles)
23//   body     - Page body HTML
24//   js       - Client-side JavaScript (injected in a <script> block)
25//   bare     - Skip shared styles entirely. For pages with custom themes
26//              (command center, query page). Default: false.
27//
28// Other extensions register pages via:
29//   const html = getExtension("html-rendering");
30//   html.exports.registerPage("get", "/my-page", authenticate, handler);
31//
32// Their handlers can import layout from this file or build raw HTML.
33
34import {
35  baseStyles,
36  backNavStyles,
37  glassHeaderStyles,
38  glassCardStyles,
39  emptyStateStyles,
40  glassCardPanelStyles,
41  glassFormStyles,
42  statGridStyles,
43  statusBarStyles,
44  responsiveBase,
45} from "./baseStyles.js";
46
47const shared = [
48  baseStyles,
49  backNavStyles,
50  glassHeaderStyles,
51  glassCardStyles,
52  emptyStateStyles,
53  glassCardPanelStyles,
54  glassFormStyles,
55  statGridStyles,
56  statusBarStyles,
57  responsiveBase,
58].join("\n");
59
60export function page({ title = "TreeOS", css = "", body = "", js = "", bare = false }) {
61  return `<!DOCTYPE html>
62<html lang="en">
63<head>
64  <meta charset="UTF-8">
65  <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
66  <meta name="theme-color" content="#0d1117">
67  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
68  <title>${title}</title>
69  <style>
70${bare ? "" : shared}
71${css}
72  </style>
73</head>
74<body>
75${body}
76${js ? `<script>\n${js}\n</script>` : ""}
77</body>
78</html>`;
79}
80
1import { getLandUrl } from "../../../canopy/identity.js";
2import { baseStyles } from "./baseStyles.js";
3
4export function renderLoginPage(req, res, { hasEmail = false } = {}) {
5  // Only allow relative redirects starting with / to prevent open redirect and JS injection
6  const rawRedirect = req.query.redirect || "";
7  const redirect = (typeof rawRedirect === "string" && rawRedirect.startsWith("/") && !rawRedirect.startsWith("//"))
8    ? rawRedirect.replace(/["\\<>]/g, "") : "";
9
10  res.setHeader("Content-Type", "text/html");
11  res.send(`<!DOCTYPE html>
12<html lang="en">
13<head>
14  <meta charset="UTF-8" />
15  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
16  <meta name="theme-color" content="#736fe6">
17  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
18  <title>TreeOS - Login</title>
19
20  <style>
21    ${baseStyles}
22
23    body {
24      display: flex;
25      flex-direction: column;
26      align-items: center;
27      justify-content: center;
28      overflow-y: auto;
29    }
30
31    @keyframes fadeInDown {
32      from {
33        opacity: 0;
34        transform: translateY(-30px);
35      }
36      to {
37        opacity: 1;
38        transform: translateY(0);
39      }
40    }
41
42    @keyframes slideUp {
43      from {
44        opacity: 0;
45        transform: translateY(30px);
46      }
47      to {
48        opacity: 1;
49        transform: translateY(0);
50      }
51    }
52
53    /* Brand Header */
54    .brand-header {
55      position: relative;
56      z-index: 1;
57      margin-bottom: 32px;
58      text-align: center;
59      animation: fadeInDown 0.8s ease-out;
60    }
61
62    .brand-logo {
63      font-size: 80px;
64      margin-bottom: 16px;
65      display: inline-block;
66      filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
67      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
68    }
69
70    @keyframes grow {
71      0%, 100% {
72        transform: scale(1);
73      }
74      50% {
75        transform: scale(1.06);
76      }
77    }
78
79    .brand-title {
80      font-size: 56px;
81      font-weight: 600;
82      color: white;
83      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
84      letter-spacing: -1.5px;
85      margin-bottom: 8px;
86    }
87
88    .brand-subtitle {
89      font-size: 18px;
90      color: rgba(255, 255, 255, 0.85);
91      font-weight: 400;
92      letter-spacing: 0.2px;
93    }
94
95    /* Login Container - Glass */
96    .login-container {
97      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
98      backdrop-filter: blur(22px) saturate(140%);
99      -webkit-backdrop-filter: blur(22px) saturate(140%);
100      padding: 48px;
101      border-radius: 16px;
102      width: 100%;
103      max-width: 460px;
104      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
105        inset 0 1px 0 rgba(255, 255, 255, 0.25);
106      border: 1px solid rgba(255, 255, 255, 0.28);
107      text-align: center;
108      position: relative;
109      z-index: 1;
110      animation: slideUp 0.6s ease-out 0.2s both;
111    }
112
113    h2 {
114      font-size: 32px;
115      font-weight: 600;
116      color: white;
117      margin-bottom: 12px;
118      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
119      letter-spacing: -0.5px;
120    }
121
122    /* Form */
123    form {
124      margin-bottom: 16px;
125    }
126
127    .input-group {
128      margin-bottom: 16px;
129      text-align: left;
130    }
131
132    label {
133      display: block;
134      font-size: 14px;
135      font-weight: 600;
136      color: white;
137      margin-bottom: 8px;
138      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
139      letter-spacing: -0.2px;
140    }
141
142    input {
143      width: 100%;
144      padding: 14px 18px;
145      border-radius: 12px;
146      border: 2px solid rgba(255, 255, 255, 0.3);
147      font-size: 16px;
148      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
149      background: rgba(255, 255, 255, 0.15);
150      backdrop-filter: blur(20px) saturate(150%);
151      -webkit-backdrop-filter: blur(20px) saturate(150%);
152      font-family: inherit;
153      color: white;
154      font-weight: 500;
155      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
156        inset 0 1px 0 rgba(255, 255, 255, 0.25);
157      touch-action: manipulation;
158    }
159
160    input:focus {
161      outline: none;
162      border-color: rgba(255, 255, 255, 0.6);
163      background: rgba(255, 255, 255, 0.25);
164      backdrop-filter: blur(25px) saturate(160%);
165      -webkit-backdrop-filter: blur(25px) saturate(160%);
166      box-shadow: 
167        0 0 0 4px rgba(255, 255, 255, 0.15),
168        0 8px 30px rgba(0, 0, 0, 0.15),
169        inset 0 1px 0 rgba(255, 255, 255, 0.4);
170      transform: translateY(-2px);
171    }
172
173    input::placeholder {
174      color: rgba(255, 255, 255, 0.5);
175      font-weight: 400;
176    }
177
178    /* Glass Button */
179    button {
180      width: 100%;
181      padding: 16px;
182      margin-top: 8px;
183      border-radius: 980px;
184      border: 1px solid rgba(255, 255, 255, 0.3);
185      background: rgba(255, 255, 255, 0.25);
186      backdrop-filter: blur(10px);
187      color: white;
188      font-size: 16px;
189      font-weight: 600;
190      cursor: pointer;
191      transition: all 0.3s;
192      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
193      font-family: inherit;
194      letter-spacing: -0.2px;
195      position: relative;
196      overflow: hidden;
197      touch-action: manipulation;
198    }
199
200    button::before {
201      content: "";
202      position: absolute;
203      inset: -40%;
204      background: radial-gradient(
205        120% 60% at 0% 0%,
206        rgba(255, 255, 255, 0.35),
207        transparent 60%
208      );
209      opacity: 0;
210      transform: translateX(-30%) translateY(-10%);
211      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
212      pointer-events: none;
213    }
214
215    button:hover::before {
216      opacity: 1;
217      transform: translateX(30%) translateY(10%);
218    }
219
220    button:hover {
221      background: rgba(255, 255, 255, 0.35);
222      transform: translateY(-2px);
223      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
224    }
225
226    button:active {
227      transform: translateY(0);
228    }
229
230    .error-message {
231      color: white;
232      margin-top: 16px;
233      margin-bottom: 16px;
234      padding: 12px 16px;
235      background: rgba(239, 68, 68, 0.3);
236      backdrop-filter: blur(10px);
237      border-radius: 10px;
238      font-size: 14px;
239      font-weight: 600;
240      border: 1px solid rgba(239, 68, 68, 0.4);
241      text-align: left;
242    }
243
244    .error-message:empty {
245      display: none;
246    }
247
248    /* Secondary Actions */
249    .secondary-actions {
250      display: flex;
251      flex-direction: column;
252      gap: 8px;
253      margin-top: 24px;
254      padding-top: 24px;
255    }
256
257    .back-btn,
258    .secondary-btn {
259      width: 100%;
260      background: rgba(255, 255, 255, 0.15);
261      color: white;
262      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
263      font-weight: 600;
264      padding: 12px;
265      font-size: 15px;
266    }
267
268    .back-btn:hover,
269    .secondary-btn:hover {
270      background: rgba(255, 255, 255, 0.25);
271    }
272
273    .secondary-btn {
274      margin-top: 0;
275    }
276
277    /* Divider */
278    .divider {
279      display: flex;
280      align-items: center;
281      text-align: center;
282      margin: 20px 0;
283    }
284
285    .divider::before,
286    .divider::after {
287      content: '';
288      flex: 1;
289      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
290    }
291
292    .divider span {
293      padding: 0 16px;
294      color: rgba(255, 255, 255, 0.8);
295      font-size: 13px;
296      font-weight: 600;
297      text-transform: uppercase;
298      letter-spacing: 0.5px;
299    }
300
301    /* Loading State */
302    button.loading {
303      position: relative;
304      color: transparent;
305      pointer-events: none;
306    }
307
308    button.loading::after {
309      content: '';
310      position: absolute;
311      width: 20px;
312      height: 20px;
313      top: 50%;
314      left: 50%;
315      margin-left: -10px;
316      margin-top: -10px;
317      border: 3px solid rgba(255, 255, 255, 0.3);
318      border-radius: 50%;
319      border-top-color: white;
320      animation: spin 0.8s linear infinite;
321    }
322
323    @keyframes spin {
324      to {
325        transform: rotate(360deg);
326      }
327    }
328
329    /* Responsive */
330    @media (max-width: 640px) {
331      body {
332        padding: 20px 16px;
333        justify-content: center;
334      }
335
336      .brand-header {
337        margin-bottom: 24px;
338      }
339
340      .brand-logo {
341        font-size: 64px;
342      }
343
344      .brand-title {
345        font-size: 42px;
346        letter-spacing: -1px;
347      }
348
349      .brand-subtitle {
350        font-size: 16px;
351      }
352
353      .login-container {
354        padding: 32px 24px;
355      }
356
357      h2 {
358        font-size: 28px;
359      }
360
361      input {
362        font-size: 16px;
363      }
364    }
365
366    @media (min-width: 641px) and (max-width: 1024px) {
367      .login-container {
368        max-width: 420px;
369      }
370    }
371  </style>
372</head>
373
374<body>
375  <!-- Brand Header -->
376  <div class="brand-header">
377  <a href="/" style="text-decoration: none;">
378    <div class="brand-logo">🌳</div>
379    <h1 class="brand-title">TreeOS</h1></a>
380    <div class="brand-subtitle">Organize your life, efficiently</div>
381  </div>
382
383  <!-- Login Container -->
384  <div class="login-container">
385    <h2>Welcome Back</h2>
386
387    <form id="loginForm">
388      <div class="input-group">
389        <label for="username">Username</label>
390        <input 
391          type="text" 
392          id="username" 
393          placeholder="Enter your username" 
394          required 
395          autocomplete="username"
396          autocapitalize="off"
397          autocorrect="off"
398        />
399      </div>
400
401      <div class="input-group">
402        <label for="password">Password</label>
403        <input 
404          type="password" 
405          id="password" 
406          placeholder="Enter your password" 
407          required 
408          autocomplete="current-password"
409        />
410      </div>
411
412      <button type="submit" id="loginBtn">Login</button>
413    </form>
414
415    <p id="errorMessage" class="error-message"></p>
416
417    <div class="divider">
418      <span>Need Help?</span>
419    </div>
420
421    <div class="secondary-actions">
422        <button type="button" id="registerBtn" class="secondary-btn">
423        Create an account
424      </button>
425      ${hasEmail ? `<button type="button" id="forgotPasswordBtn" class="secondary-btn">
426        Forgot your password?
427      </button>` : ""}
428
429  
430
431      <button class="back-btn" onclick="goBack()">← Back to Home</button>
432    </div>
433  </div>
434
435  <script>
436    const apiUrl = "${getLandUrl()}";
437    const redirectAfterLogin = "${redirect}" || null;
438
439    // Secondary button handlers
440    document.getElementById("registerBtn").addEventListener("click", () => {
441      window.location.href = "/register";
442    });
443
444    var forgotBtn = document.getElementById("forgotPasswordBtn");
445    if (forgotBtn) forgotBtn.addEventListener("click", () => {
446      window.location.href = "/forgot-password";
447    });
448
449    // Login form submission
450    document.getElementById("loginForm").addEventListener("submit", async (e) => {
451      e.preventDefault();
452
453      const username = document.getElementById("username").value.trim();
454      const password = document.getElementById("password").value;
455      const errorEl = document.getElementById("errorMessage");
456      const loginBtn = document.getElementById("loginBtn");
457      
458      errorEl.textContent = "";
459      loginBtn.classList.add("loading");
460      loginBtn.disabled = true;
461
462      try {
463        const res = await fetch(\`\${apiUrl}/login\`, {
464          method: "POST",
465          headers: { "Content-Type": "application/json" },
466          credentials: "include",
467          body: JSON.stringify({ username, password })
468        });
469
470        const data = await res.json();
471
472        if (!res.ok) {
473          errorEl.textContent = data.message || "Login failed. Please check your credentials.";
474          loginBtn.classList.remove("loading");
475          loginBtn.disabled = false;
476          return;
477        }
478
479    window.location.href = redirectAfterLogin || "/chat";
480
481      } catch (err) {
482        console.error(err);
483        errorEl.textContent = "An error occurred. Please try again.";
484        loginBtn.classList.remove("loading");
485        loginBtn.disabled = false;
486      }
487    });
488
489    function goBack() {
490      window.location.href = "/";
491    }
492  </script>
493</body>
494</html>`);
495}
496export function renderRegisterPage(req, res, { hasEmail = false, hasLegal = false } = {}) {
497  res.setHeader("Content-Type", "text/html");
498  res.send(`<!DOCTYPE html>
499<html lang="en">
500<head>
501  <meta charset="UTF-8" />
502  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
503  <meta name="theme-color" content="#736fe6">
504  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
505  <title>TreeOS - Register</title>
506
507  <style>
508    ${baseStyles}
509
510    body {
511      display: flex;
512      flex-direction: column;
513      align-items: center;
514      justify-content: center;
515      overflow-y: auto;
516    }
517
518    @keyframes fadeInDown {
519      from { opacity: 0; transform: translateY(-30px); }
520      to { opacity: 1; transform: translateY(0); }
521    }
522
523    @keyframes slideUp {
524      from { opacity: 0; transform: translateY(30px); }
525      to { opacity: 1; transform: translateY(0); }
526    }
527
528    .brand-header {
529      position: relative;
530      z-index: 1;
531      margin-bottom: 32px;
532      text-align: center;
533      animation: fadeInDown 0.8s ease-out;
534    }
535
536    .brand-logo {
537      font-size: 80px;
538      margin-bottom: 16px;
539      display: inline-block;
540      filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
541      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
542    }
543
544    @keyframes grow {
545      0%, 100% { transform: scale(1); }
546      50% { transform: scale(1.06); }
547    }
548
549    .brand-title {
550      font-size: 56px;
551      font-weight: 600;
552      color: white;
553      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
554      letter-spacing: -1.5px;
555      margin-bottom: 8px;
556    }
557
558    .brand-subtitle {
559      font-size: 18px;
560      color: rgba(255, 255, 255, 0.85);
561      font-weight: 400;
562      letter-spacing: 0.2px;
563    }
564
565    .register-container {
566      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
567      backdrop-filter: blur(22px) saturate(140%);
568      -webkit-backdrop-filter: blur(22px) saturate(140%);
569      padding: 48px;
570      border-radius: 16px;
571      width: 100%;
572      max-width: 460px;
573      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
574        inset 0 1px 0 rgba(255, 255, 255, 0.25);
575      border: 1px solid rgba(255, 255, 255, 0.28);
576      text-align: center;
577      position: relative;
578      z-index: 1;
579      animation: slideUp 0.6s ease-out 0.2s both;
580    }
581
582    h2 {
583      font-size: 32px;
584      font-weight: 600;
585      color: white;
586      margin-bottom: 8px;
587      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
588      letter-spacing: -0.5px;
589    }
590
591    .subtitle {
592      font-size: 15px;
593      color: rgba(255, 255, 255, 0.85);
594      margin-bottom: 32px;
595      line-height: 1.5;
596      font-weight: 400;
597    }
598
599    form { margin-bottom: 16px; }
600
601    .input-group {
602      margin-bottom: 16px;
603      text-align: left;
604    }
605
606    label {
607      display: block;
608      font-size: 14px;
609      font-weight: 600;
610      color: white;
611      margin-bottom: 8px;
612      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
613      letter-spacing: -0.2px;
614    }
615
616    input {
617      width: 100%;
618      padding: 14px 18px;
619      border-radius: 12px;
620      border: 2px solid rgba(255, 255, 255, 0.3);
621      font-size: 16px;
622      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
623      background: rgba(255, 255, 255, 0.15);
624      backdrop-filter: blur(20px) saturate(150%);
625      -webkit-backdrop-filter: blur(20px) saturate(150%);
626      font-family: inherit;
627      color: white;
628      font-weight: 500;
629      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
630        inset 0 1px 0 rgba(255, 255, 255, 0.25);
631      touch-action: manipulation;
632    }
633
634    input:focus {
635      outline: none;
636      border-color: rgba(255, 255, 255, 0.6);
637      background: rgba(255, 255, 255, 0.25);
638      backdrop-filter: blur(25px) saturate(160%);
639      -webkit-backdrop-filter: blur(25px) saturate(160%);
640      box-shadow: 
641        0 0 0 4px rgba(255, 255, 255, 0.15),
642        0 8px 30px rgba(0, 0, 0, 0.15),
643        inset 0 1px 0 rgba(255, 255, 255, 0.4);
644      transform: translateY(-2px);
645    }
646
647    input::placeholder {
648      color: rgba(255, 255, 255, 0.5);
649      font-weight: 400;
650    }
651
652    input.error {
653      border-color: rgba(239, 68, 68, 0.6);
654      background: rgba(239, 68, 68, 0.1);
655    }
656
657    input.error:focus {
658      box-shadow: 
659        0 0 0 4px rgba(239, 68, 68, 0.2),
660        0 8px 30px rgba(239, 68, 68, 0.2);
661    }
662
663    .password-hint {
664      font-size: 12px;
665      color: rgba(255, 255, 255, 0.7);
666      margin-top: 6px;
667      text-align: left;
668      font-weight: 400;
669    }
670
671    button {
672      width: 100%;
673      padding: 16px;
674      margin-top: 8px;
675      border-radius: 980px;
676      border: 1px solid rgba(255, 255, 255, 0.3);
677      background: rgba(255, 255, 255, 0.25);
678      backdrop-filter: blur(10px);
679      color: white;
680      font-size: 16px;
681      font-weight: 600;
682      cursor: pointer;
683      transition: all 0.3s;
684      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
685      font-family: inherit;
686      letter-spacing: -0.2px;
687      position: relative;
688      overflow: hidden;
689      touch-action: manipulation;
690    }
691
692    button::before {
693      content: "";
694      position: absolute;
695      inset: -40%;
696      background: radial-gradient(
697        120% 60% at 0% 0%,
698        rgba(255, 255, 255, 0.35),
699        transparent 60%
700      );
701      opacity: 0;
702      transform: translateX(-30%) translateY(-10%);
703      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
704      pointer-events: none;
705    }
706
707    button:hover::before {
708      opacity: 1;
709      transform: translateX(30%) translateY(10%);
710    }
711
712    button:hover {
713      background: rgba(255, 255, 255, 0.35);
714      transform: translateY(-2px);
715      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
716    }
717
718    button:active { transform: translateY(0); }
719
720    .message {
721      margin-top: 16px;
722      margin-bottom: 16px;
723      padding: 12px 16px;
724      border-radius: 10px;
725      font-size: 14px;
726      font-weight: 600;
727      text-align: left;
728      display: none;
729    }
730
731    .error-message {
732      color: white;
733      background: rgba(239, 68, 68, 0.3);
734      backdrop-filter: blur(10px);
735      border: 1px solid rgba(239, 68, 68, 0.4);
736    }
737
738    .success-message {
739      color: white;
740      background: rgba(16, 185, 129, 0.3);
741      backdrop-filter: blur(10px);
742      border: 1px solid rgba(16, 185, 129, 0.4);
743    }
744
745    .message.show { display: block; }
746
747    .secondary-actions {
748      display: flex;
749      flex-direction: column;
750      gap: 8px;
751      margin-top: 24px;
752      padding-top: 24px;
753    }
754
755    .back-btn {
756      width: 100%;
757      background: rgba(255, 255, 255, 0.15);
758      color: white;
759      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
760      font-weight: 600;
761      padding: 12px;
762      font-size: 15px;
763      margin-top: 0;
764    }
765
766    .back-btn:hover { background: rgba(255, 255, 255, 0.25); }
767
768    button.loading {
769      position: relative;
770      color: transparent;
771      pointer-events: none;
772    }
773
774    button.loading::after {
775      content: '';
776      position: absolute;
777      width: 20px;
778      height: 20px;
779      top: 50%;
780      left: 50%;
781      margin-left: -10px;
782      margin-top: -10px;
783      border: 3px solid rgba(255, 255, 255, 0.3);
784      border-radius: 50%;
785      border-top-color: white;
786      animation: spin 0.8s linear infinite;
787    }
788
789    @keyframes spin { to { transform: rotate(360deg); } }
790
791    /* Agreement text */
792    .agreement-text {
793      font-size: 13px;
794      color: rgba(255, 255, 255, 0.65);
795      line-height: 1.5;
796      margin-top: 20px;
797      margin-bottom: 4px;
798      text-align: center;
799    }
800
801    .agreement-link {
802      color: rgba(255, 255, 255, 0.9);
803      text-decoration: underline;
804      text-underline-offset: 2px;
805      cursor: pointer;
806      font-weight: 500;
807      transition: color 0.2s;
808    }
809
810    .agreement-link:hover {
811      color: white;
812    }
813
814    /* Modal overlay */
815    .modal-overlay {
816      display: none;
817      position: fixed;
818      inset: 0;
819      z-index: 1000;
820      background: rgba(0, 0, 0, 0.6);
821      backdrop-filter: blur(8px);
822      -webkit-backdrop-filter: blur(8px);
823      align-items: center;
824      justify-content: center;
825      padding: 20px;
826      animation: modalFadeIn 0.25s ease-out;
827    }
828
829    .modal-overlay.show {
830      display: flex;
831    }
832
833    @keyframes modalFadeIn {
834      from { opacity: 0; }
835      to { opacity: 1; }
836    }
837
838    .modal-container {
839      width: 100%;
840      max-width: 720px;
841      height: 85vh;
842      height: 85dvh;
843      background: rgba(var(--glass-water-rgb), 0.35);
844      backdrop-filter: blur(22px) saturate(140%);
845      -webkit-backdrop-filter: blur(22px) saturate(140%);
846      border-radius: 20px;
847      border: 1px solid rgba(255, 255, 255, 0.28);
848      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
849      display: flex;
850      flex-direction: column;
851      overflow: hidden;
852      animation: modalSlideUp 0.3s ease-out;
853    }
854
855    @keyframes modalSlideUp {
856      from { opacity: 0; transform: translateY(40px) scale(0.97); }
857      to { opacity: 1; transform: translateY(0) scale(1); }
858    }
859
860    .modal-header {
861      display: flex;
862      align-items: center;
863      justify-content: space-between;
864      padding: 16px 20px;
865      border-bottom: 1px solid rgba(255, 255, 255, 0.15);
866      flex-shrink: 0;
867    }
868
869    .modal-title {
870      font-size: 16px;
871      font-weight: 600;
872      color: white;
873      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
874    }
875
876    .modal-close {
877      width: 32px;
878      height: 32px;
879      min-width: 32px;
880      border-radius: 50%;
881      border: 1px solid rgba(255, 255, 255, 0.25);
882      background: rgba(255, 255, 255, 0.15);
883      color: white;
884      font-size: 18px;
885      cursor: pointer;
886      display: flex;
887      align-items: center;
888      justify-content: center;
889      padding: 0;
890      margin: 0;
891      transition: background 0.2s;
892      box-shadow: none;
893      backdrop-filter: none;
894    }
895
896    .modal-close:hover {
897      background: rgba(255, 255, 255, 0.3);
898      transform: none;
899      box-shadow: none;
900    }
901
902    .modal-close::before { display: none; }
903
904    .modal-body {
905      flex: 1;
906      overflow: hidden;
907    }
908
909    .modal-body iframe {
910      width: 100%;
911      height: 100%;
912      border: none;
913      background: transparent;
914    }
915
916    @media (max-width: 640px) {
917      body {
918        padding: 20px 16px;
919        justify-content: center;
920      }
921
922      .brand-header { margin-bottom: 24px; }
923      .brand-logo { font-size: 64px; }
924
925      .brand-title {
926        font-size: 42px;
927        letter-spacing: -1px;
928      }
929
930      .brand-subtitle { font-size: 16px; }
931      .register-container { padding: 32px 24px; }
932      h2 { font-size: 28px; }
933      input { font-size: 16px; }
934
935      .modal-container {
936        height: 90vh;
937        height: 90dvh;
938        border-radius: 16px;
939      }
940
941      .modal-overlay {
942        padding: 10px;
943      }
944    }
945
946    @media (min-width: 641px) and (max-width: 1024px) {
947      .register-container { max-width: 420px; }
948    }
949  </style>
950</head>
951
952<body>
953  <div class="brand-header">
954    <a href="/" style="text-decoration: none;">
955
956    <div class="brand-logo">🌳</div>
957    <h1 class="brand-title">TreeOS</h1></a>
958    <div class="brand-subtitle">Organize your life, efficiently</div>
959  </div>
960
961  <div class="register-container">
962    <h2>Create Account</h2>
963    <p class="subtitle">Sign up to get started with TreeOS</p>
964
965    <form id="registerForm">
966      <div class="input-group">
967        <label for="username">Username</label>
968        <input 
969          type="text"
970          id="username" 
971          placeholder="Choose a username"
972          required 
973          autocomplete="username"
974          autocapitalize="off"
975          autocorrect="off"
976        />
977      </div>
978
979      ${hasEmail ? `<div class="input-group">
980        <label for="email">Email</label>
981        <input
982          type="email"
983          id="email"
984          placeholder="Enter your email"
985          required
986          autocomplete="email"
987          autocapitalize="off"
988        />
989      </div>` : ""}
990
991      <div class="input-group">
992        <label for="password">Password</label>
993        <input 
994          type="password" 
995          id="password" 
996          placeholder="Create a password"
997          required 
998          autocomplete="new-password"
999        />
1000        <div class="password-hint">Must be at least 8 characters</div>
1001      </div>
1002
1003      <div class="input-group">
1004        <label for="confirmPassword">Confirm Password</label>
1005        <input 
1006          type="password" 
1007          id="confirmPassword" 
1008          placeholder="Confirm your password"
1009          required 
1010          autocomplete="new-password"
1011        />
1012      </div>
1013      <button type="submit" id="registerBtn">Create Account</button>
1014
1015      ${hasLegal ? `<div class="agreement-text">
1016        By creating an account, you agree to our
1017        <span class="agreement-link" onclick="openModal('terms')">Terms of Service</span>
1018        and
1019        <span class="agreement-link" onclick="openModal('privacy')">Privacy Policy</span>.
1020      </div>` : ""}
1021
1022    </form>
1023
1024    <div id="errorMessage" class="message error-message"></div>
1025    <div id="successMessage" class="message success-message">
1026      ${hasEmail ? "✓ Registration successful! Check your email to complete registration." : "✓ Registration successful! You can now log in."}
1027    </div>
1028
1029    <div class="secondary-actions">
1030      <button class="back-btn" onclick="window.location.href='/login'">
1031        ← Back to Login
1032      </button>
1033    </div>
1034  </div>
1035
1036  ${hasLegal ? `
1037  <div class="modal-overlay" id="termsModal">
1038    <div class="modal-container">
1039      <div class="modal-header">
1040        <span class="modal-title">Terms of Service</span>
1041        <button class="modal-close" onclick="closeModal('terms')">✕</button>
1042      </div>
1043      <div class="modal-body">
1044        <iframe src="/terms" title="Terms of Service"></iframe>
1045      </div>
1046    </div>
1047  </div>
1048  <div class="modal-overlay" id="privacyModal">
1049    <div class="modal-container">
1050      <div class="modal-header">
1051        <span class="modal-title">Privacy Policy</span>
1052        <button class="modal-close" onclick="closeModal('privacy')">✕</button>
1053      </div>
1054      <div class="modal-body">
1055        <iframe src="/privacy" title="Privacy Policy"></iframe>
1056      </div>
1057    </div>
1058  </div>
1059  ` : ""}
1060
1061  <script>
1062    const apiUrl = "${getLandUrl()}";
1063
1064    function openModal(type) {
1065      const id = type === 'terms' ? 'termsModal' : 'privacyModal';
1066      document.getElementById(id).classList.add('show');
1067      document.body.style.overflow = 'hidden';
1068    }
1069
1070    function closeModal(type) {
1071      const id = type === 'terms' ? 'termsModal' : 'privacyModal';
1072      document.getElementById(id).classList.remove('show');
1073      document.body.style.overflow = '';
1074    }
1075
1076    // Close modal on overlay click
1077    document.querySelectorAll('.modal-overlay').forEach(overlay => {
1078      overlay.addEventListener('click', (e) => {
1079        if (e.target === overlay) {
1080          overlay.classList.remove('show');
1081          document.body.style.overflow = '';
1082        }
1083      });
1084    });
1085
1086    // Close modal on Escape key
1087    document.addEventListener('keydown', (e) => {
1088      if (e.key === 'Escape') {
1089        document.querySelectorAll('.modal-overlay.show').forEach(m => {
1090          m.classList.remove('show');
1091        });
1092        document.body.style.overflow = '';
1093      }
1094    });
1095
1096    document.getElementById("registerForm").addEventListener("submit", async (e) => {
1097      e.preventDefault();
1098
1099      const username = document.getElementById("username").value.trim();
1100      const emailEl = document.getElementById("email");
1101      const email = emailEl ? emailEl.value.trim() : "";
1102      const password = document.getElementById("password").value;
1103      const confirmPassword = document.getElementById("confirmPassword").value;
1104
1105      const errorEl = document.getElementById("errorMessage");
1106      const successEl = document.getElementById("successMessage");
1107      const btn = document.getElementById("registerBtn");
1108      const passwordInput = document.getElementById("password");
1109      const confirmPasswordInput = document.getElementById("confirmPassword");
1110
1111      errorEl.classList.remove("show");
1112      successEl.classList.remove("show");
1113      passwordInput.classList.remove("error");
1114      confirmPasswordInput.classList.remove("error");
1115
1116      if (password.length < 8) {
1117        errorEl.textContent = "Password must be at least 8 characters long.";
1118        errorEl.classList.add("show");
1119        passwordInput.classList.add("error");
1120        passwordInput.focus();
1121        return;
1122      }
1123
1124      if (password !== confirmPassword) {
1125        errorEl.textContent = "Passwords do not match.";
1126        errorEl.classList.add("show");
1127        confirmPasswordInput.classList.add("error");
1128        confirmPasswordInput.focus();
1129        return;
1130      }
1131
1132      btn.classList.add("loading");
1133      btn.disabled = true;
1134
1135      try {
1136        const res = await fetch(\`\${apiUrl}/register\`, {
1137          method: "POST",
1138          headers: { "Content-Type": "application/json" },
1139          body: JSON.stringify(email ? { username, email, password } : { username, password })
1140        });
1141
1142        const data = await res.json();
1143
1144        if (!res.ok) {
1145          errorEl.textContent = data.message || "Registration failed. Please try again.";
1146          errorEl.classList.add("show");
1147          btn.classList.remove("loading");
1148          btn.disabled = false;
1149          return;
1150        }
1151
1152        document.getElementById("registerForm").reset();
1153        successEl.classList.add("show");
1154        btn.classList.remove("loading");
1155        btn.disabled = false;
1156
1157        setTimeout(() => {
1158          window.location.href = "/login";
1159        }, 7000);
1160
1161      } catch (err) {
1162        console.error(err);
1163        errorEl.textContent = "An error occurred. Please try again.";
1164        errorEl.classList.add("show");
1165        btn.classList.remove("loading");
1166        btn.disabled = false;
1167      }
1168    });
1169
1170    document.getElementById("confirmPassword").addEventListener("input", (e) => {
1171      const password = document.getElementById("password").value;
1172      const confirmPassword = e.target.value;
1173      if (confirmPassword && password !== confirmPassword) {
1174        e.target.classList.add("error");
1175      } else {
1176        e.target.classList.remove("error");
1177      }
1178    });
1179  </script>
1180</body>
1181</html>`);
1182}
1183
1184export function renderForgotPasswordPage(req, res) {
1185  res.setHeader("Content-Type", "text/html");
1186  res.send(`<!DOCTYPE html>
1187<html lang="en">
1188<head>
1189  <meta charset="UTF-8" />
1190  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
1191  <meta name="theme-color" content="#736fe6">
1192  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1193  <title>TreeOS - Reset Password</title>
1194
1195  <style>
1196    ${baseStyles}
1197
1198    body {
1199      display: flex;
1200      flex-direction: column;
1201      align-items: center;
1202      justify-content: center;
1203      overflow-y: auto;
1204    }
1205
1206    @keyframes fadeInDown {
1207      from { opacity: 0; transform: translateY(-30px); }
1208      to { opacity: 1; transform: translateY(0); }
1209    }
1210
1211    @keyframes slideUp {
1212      from { opacity: 0; transform: translateY(30px); }
1213      to { opacity: 1; transform: translateY(0); }
1214    }
1215
1216    @keyframes grow {
1217      0%, 100% { transform: scale(1); }
1218      50% { transform: scale(1.06); }
1219    }
1220
1221    @keyframes spin {
1222      to { transform: rotate(360deg); }
1223    }
1224
1225    .brand-header {
1226      position: relative;
1227      z-index: 1;
1228      margin-bottom: 32px;
1229      text-align: center;
1230      animation: fadeInDown 0.8s ease-out;
1231    }
1232
1233    .brand-logo {
1234      font-size: 80px;
1235      margin-bottom: 16px;
1236      display: inline-block;
1237      filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
1238      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
1239    }
1240
1241    .brand-title {
1242      font-size: 56px;
1243      font-weight: 600;
1244      color: white;
1245      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1246      letter-spacing: -1.5px;
1247      margin-bottom: 8px;
1248    }
1249
1250    .brand-subtitle {
1251      font-size: 18px;
1252      color: rgba(255, 255, 255, 0.85);
1253      font-weight: 400;
1254      letter-spacing: 0.2px;
1255    }
1256
1257    .forgot-container {
1258      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1259      backdrop-filter: blur(22px) saturate(140%);
1260      -webkit-backdrop-filter: blur(22px) saturate(140%);
1261      padding: 48px;
1262      border-radius: 16px;
1263      width: 100%;
1264      max-width: 460px;
1265      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1266        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1267      border: 1px solid rgba(255, 255, 255, 0.28);
1268      text-align: center;
1269      position: relative;
1270      z-index: 1;
1271      animation: slideUp 0.6s ease-out 0.2s both;
1272    }
1273
1274    h2 {
1275      font-size: 32px;
1276      font-weight: 600;
1277      color: white;
1278      margin-bottom: 8px;
1279      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1280      letter-spacing: -0.5px;
1281    }
1282
1283    .subtitle {
1284      font-size: 15px;
1285      color: rgba(255, 255, 255, 0.85);
1286      margin-bottom: 32px;
1287      line-height: 1.5;
1288      font-weight: 400;
1289    }
1290
1291    form {
1292      margin-bottom: 16px;
1293    }
1294
1295    .input-group {
1296      margin-bottom: 16px;
1297      text-align: left;
1298    }
1299
1300    label {
1301      display: block;
1302      font-size: 14px;
1303      font-weight: 600;
1304      color: white;
1305      margin-bottom: 8px;
1306      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1307      letter-spacing: -0.2px;
1308    }
1309
1310    input {
1311      width: 100%;
1312      padding: 14px 18px;
1313      border-radius: 12px;
1314      border: 2px solid rgba(255, 255, 255, 0.3);
1315      font-size: 16px;
1316      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1317      background: rgba(255, 255, 255, 0.15);
1318      backdrop-filter: blur(20px) saturate(150%);
1319      -webkit-backdrop-filter: blur(20px) saturate(150%);
1320      font-family: inherit;
1321      color: white;
1322      font-weight: 500;
1323      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
1324        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1325      touch-action: manipulation;
1326    }
1327
1328    input:focus {
1329      outline: none;
1330      border-color: rgba(255, 255, 255, 0.6);
1331      background: rgba(255, 255, 255, 0.25);
1332      backdrop-filter: blur(25px) saturate(160%);
1333      -webkit-backdrop-filter: blur(25px) saturate(160%);
1334      box-shadow:
1335        0 0 0 4px rgba(255, 255, 255, 0.15),
1336        0 8px 30px rgba(0, 0, 0, 0.15),
1337        inset 0 1px 0 rgba(255, 255, 255, 0.4);
1338      transform: translateY(-2px);
1339    }
1340
1341    input::placeholder {
1342      color: rgba(255, 255, 255, 0.5);
1343      font-weight: 400;
1344    }
1345
1346    input.error {
1347      border-color: rgba(239, 68, 68, 0.6);
1348      background: rgba(239, 68, 68, 0.1);
1349    }
1350
1351    input.error:focus {
1352      box-shadow:
1353        0 0 0 4px rgba(239, 68, 68, 0.2),
1354        0 8px 30px rgba(239, 68, 68, 0.2);
1355    }
1356
1357    button {
1358      width: 100%;
1359      padding: 16px;
1360      margin-top: 8px;
1361      border-radius: 980px;
1362      border: 1px solid rgba(255, 255, 255, 0.3);
1363      background: rgba(255, 255, 255, 0.25);
1364      backdrop-filter: blur(10px);
1365      color: white;
1366      font-size: 16px;
1367      font-weight: 600;
1368      cursor: pointer;
1369      transition: all 0.3s;
1370      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
1371      font-family: inherit;
1372      letter-spacing: -0.2px;
1373      position: relative;
1374      overflow: hidden;
1375      touch-action: manipulation;
1376    }
1377
1378    button::before {
1379      content: "";
1380      position: absolute;
1381      inset: -40%;
1382      background: radial-gradient(
1383        120% 60% at 0% 0%,
1384        rgba(255, 255, 255, 0.35),
1385        transparent 60%
1386      );
1387      opacity: 0;
1388      transform: translateX(-30%) translateY(-10%);
1389      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1390      pointer-events: none;
1391    }
1392
1393    button:hover::before {
1394      opacity: 1;
1395      transform: translateX(30%) translateY(10%);
1396    }
1397
1398    button:hover {
1399      background: rgba(255, 255, 255, 0.35);
1400      transform: translateY(-2px);
1401      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
1402    }
1403
1404    button:active { transform: translateY(0); }
1405
1406    button.loading {
1407      position: relative;
1408      color: transparent;
1409      pointer-events: none;
1410    }
1411
1412    button.loading::after {
1413      content: '';
1414      position: absolute;
1415      width: 20px;
1416      height: 20px;
1417      top: 50%;
1418      left: 50%;
1419      margin-left: -10px;
1420      margin-top: -10px;
1421      border: 3px solid rgba(255, 255, 255, 0.3);
1422      border-radius: 50%;
1423      border-top-color: white;
1424      animation: spin 0.8s linear infinite;
1425    }
1426
1427    .message {
1428      margin-top: 16px;
1429      margin-bottom: 16px;
1430      padding: 12px 16px;
1431      border-radius: 10px;
1432      font-size: 14px;
1433      font-weight: 600;
1434      text-align: left;
1435      display: none;
1436    }
1437
1438    .error-message {
1439      color: white;
1440      background: rgba(239, 68, 68, 0.3);
1441      backdrop-filter: blur(10px);
1442      border: 1px solid rgba(239, 68, 68, 0.4);
1443    }
1444
1445    .success-message {
1446      color: white;
1447      background: rgba(16, 185, 129, 0.3);
1448      backdrop-filter: blur(10px);
1449      border: 1px solid rgba(16, 185, 129, 0.4);
1450    }
1451
1452    .message.show { display: block; }
1453
1454    .secondary-actions {
1455      display: flex;
1456      flex-direction: column;
1457      gap: 8px;
1458      margin-top: 24px;
1459      padding-top: 24px;
1460    }
1461
1462    .back-btn {
1463      width: 100%;
1464      background: rgba(255, 255, 255, 0.15);
1465      color: white;
1466      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
1467      font-weight: 600;
1468      padding: 12px;
1469      font-size: 15px;
1470      margin-top: 0;
1471    }
1472
1473    .back-btn:hover {
1474      background: rgba(255, 255, 255, 0.25);
1475    }
1476
1477    @media (max-width: 640px) {
1478      body {
1479        padding: 20px 16px;
1480        justify-content: center;
1481      }
1482
1483      .brand-header { margin-bottom: 24px; }
1484      .brand-logo { font-size: 64px; }
1485
1486      .brand-title {
1487        font-size: 42px;
1488        letter-spacing: -1px;
1489      }
1490
1491      .brand-subtitle { font-size: 16px; }
1492      .forgot-container { padding: 32px 24px; }
1493      h2 { font-size: 28px; }
1494      input { font-size: 16px; }
1495    }
1496
1497    @media (min-width: 641px) and (max-width: 1024px) {
1498      .forgot-container { max-width: 420px; }
1499    }
1500  </style>
1501</head>
1502
1503<body>
1504  <div class="brand-header">
1505    <a href="/" style="text-decoration: none;">
1506      <div class="brand-logo">🌳</div>
1507      <h1 class="brand-title">TreeOS</h1>
1508    </a>
1509    <div class="brand-subtitle">Organize your life, efficiently</div>
1510  </div>
1511
1512  <div class="forgot-container">
1513    <h2>Reset Password</h2>
1514    <p class="subtitle">Enter your email address and we'll send you a link to reset your password.</p>
1515
1516    <form id="forgotForm">
1517      <div class="input-group">
1518        <label for="email">Email Address</label>
1519        <input
1520          type="email"
1521          id="email"
1522          placeholder="Enter your email"
1523          required
1524          autocomplete="email"
1525          autocapitalize="off"
1526        />
1527      </div>
1528
1529      <button type="submit" id="submitBtn">Send Reset Link</button>
1530    </form>
1531
1532    <div id="errorMessage" class="message error-message"></div>
1533    <div id="successMessage" class="message success-message">
1534      ✓ If an account exists for that email, a password reset link has been sent. Check your inbox.
1535    </div>
1536
1537    <div class="secondary-actions">
1538      <button class="back-btn" onclick="window.location.href='/login'">
1539        ← Back to Login
1540      </button>
1541    </div>
1542  </div>
1543
1544  <script>
1545    const apiUrl = "${getLandUrl()}";
1546    const EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
1547
1548    document.getElementById("forgotForm").addEventListener("submit", async (e) => {
1549      e.preventDefault();
1550
1551      const email = document.getElementById("email").value.trim();
1552      const emailInput = document.getElementById("email");
1553      const btn = document.getElementById("submitBtn");
1554      const errorEl = document.getElementById("errorMessage");
1555      const successEl = document.getElementById("successMessage");
1556
1557      errorEl.classList.remove("show");
1558      successEl.classList.remove("show");
1559      emailInput.classList.remove("error");
1560
1561      if (!email) {
1562        errorEl.textContent = "Please enter your email address.";
1563        errorEl.classList.add("show");
1564        emailInput.classList.add("error");
1565        emailInput.focus();
1566        return;
1567      }
1568
1569      if (!EMAIL_REGEX.test(email)) {
1570        errorEl.textContent = "Please enter a valid email address.";
1571        errorEl.classList.add("show");
1572        emailInput.classList.add("error");
1573        emailInput.focus();
1574        return;
1575      }
1576
1577      if (email.length > 320) {
1578        errorEl.textContent = "Email address is too long.";
1579        errorEl.classList.add("show");
1580        emailInput.classList.add("error");
1581        emailInput.focus();
1582        return;
1583      }
1584
1585      btn.classList.add("loading");
1586      btn.disabled = true;
1587
1588      try {
1589        await fetch(\`\${apiUrl}/forgot-password\`, {
1590          method: "POST",
1591          headers: { "Content-Type": "application/json" },
1592          body: JSON.stringify({ email })
1593        });
1594
1595        document.getElementById("forgotForm").reset();
1596        successEl.classList.add("show");
1597        btn.classList.remove("loading");
1598        btn.disabled = false;
1599
1600      } catch (err) {
1601        console.error(err);
1602        document.getElementById("forgotForm").reset();
1603        successEl.classList.add("show");
1604        btn.classList.remove("loading");
1605        btn.disabled = false;
1606      }
1607    });
1608  </script>
1609</body>
1610</html>`);
1611}
1612
1613
1// ─────────────────────────────────────────────────
2// Shared utilities for HTML renderers
3// ─────────────────────────────────────────────────
4
5// ─── HTML escaping ───────────────────────────────
6// One definitive implementation. Handles null/undefined,
7// coerces to string, escapes all 5 dangerous characters.
8
9export function esc(str) {
10  if (str == null) return "";
11  return String(str)
12    .replace(/&/g, "&amp;")
13    .replace(/</g, "&lt;")
14    .replace(/>/g, "&gt;")
15    .replace(/"/g, "&quot;")
16    .replace(/'/g, "&#039;");
17}
18
19// Alias for files that use the longer name
20export { esc as escapeHtml };
21
22// ─── Token sanitization ─────────────────────────
23// Share tokens contain only URL-safe characters.
24// Strip anything else to prevent injection into HTML output.
25const TOKEN_SAFE = /^[A-Za-z0-9\-_.~]+$/;
26
27/**
28 * Sanitize a share token value. Returns the token if safe, empty string otherwise.
29 * Use at every entry point where req.query.token enters the rendering pipeline.
30 */
31export function sanitizeToken(raw) {
32  if (!raw || typeof raw !== "string") return "";
33  return TOKEN_SAFE.test(raw) ? raw : "";
34}
35
36/**
37 * Build a token query string from a sanitized token.
38 * Always safe for href/action attributes.
39 */
40export function safeTokenQS(token) {
41  const safe = sanitizeToken(token);
42  return safe ? `?token=${encodeURIComponent(safe)}&html` : "?html";
43}
44
45// ─── Truncation ──────────────────────────────────
46
47export function truncate(str, len = 200) {
48  if (!str) return "";
49  const clean = esc(str);
50  return clean.length > len ? clean.slice(0, len) + "..." : clean;
51}
52
53// Raw truncate (no escaping, for pre-escaped or non-HTML contexts)
54export function truncateRaw(str, len = 24) {
55  if (!str) return "";
56  return str.length > len ? str.slice(0, len) + "..." : str;
57}
58
59// ─── Time formatting ─────────────────────────────
60
61export function formatTime(d) {
62  return d ? new Date(d).toLocaleString() : "--";
63}
64
65export function formatDuration(start, end) {
66  if (!start || !end) return null;
67  const ms = new Date(end) - new Date(start);
68  if (ms < 1000) return `${ms}ms`;
69  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
70  return `${(ms / 60000).toFixed(1)}m`;
71}
72
73export function timeAgo(date) {
74  if (!date) return "never";
75  const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
76  if (seconds < 0) return "just now";
77  if (seconds < 60) return seconds + "s ago";
78  if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
79  if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
80  return Math.floor(seconds / 86400) + "d ago";
81}
82
83// ─── Rainbow depth colors ────────────────────────
84
85export const rainbow = [
86  "#ff3b30",
87  "#ff9500",
88  "#ffcc00",
89  "#34c759",
90  "#32ade6",
91  "#5856d6",
92  "#af52de",
93];
94
95// ─── Action color mappings ───────────────────────
96// Two variants: CSS class names (for glass cards) and hex (for inline styles).
97// Driven from one authoritative map so they never drift.
98
99const ACTION_COLORS = {
100  create:          { cls: "glass-green",   hex: "#48bb78" },
101  delete:          { cls: "glass-red",     hex: "#c85050" },
102  branchLifecycle: { cls: "glass-red",     hex: "#c85050" },
103  editStatus:      { cls: "glass-blue",    hex: "#5082dc" },
104  editValue:       { cls: "glass-blue",    hex: "#5082dc" },
105  editGoal:        { cls: "glass-blue",    hex: "#5082dc" },
106  editSchedule:    { cls: "glass-blue",    hex: "#5082dc" },
107  editName:    { cls: "glass-blue",    hex: "#5082dc" },
108  editScript:      { cls: "glass-blue",    hex: "#5082dc" },
109  executeScript:   { cls: "glass-cyan",    hex: "#38bdd2" },
110  prestige:        { cls: "glass-gold",    hex: "#c8aa32" },
111  note:            { cls: "glass-purple",  hex: "#9b64dc" },
112  rawIdea:         { cls: "glass-purple",  hex: "#9b64dc" },
113  invite:          { cls: "glass-pink",    hex: "#d264a0" },
114  transaction:     { cls: "glass-orange",  hex: "#dc8c3c" },
115  trade:           { cls: "glass-orange",  hex: "#dc8c3c" },
116  purchase:        { cls: "glass-emerald", hex: "#34be82" },
117  updateParent:    { cls: "glass-teal",    hex: "#3caab4" },
118  updateChild: { cls: "glass-teal",    hex: "#3caab4" },
119  understanding:   { cls: "glass-indigo",  hex: "#6464d2" },
120};
121
122const DEFAULT_ACTION = { cls: "glass-default", hex: "#736fe6" };
123
124export function actionColorClass(action) {
125  return (ACTION_COLORS[action] || DEFAULT_ACTION).cls;
126}
127
128export function actionColorHex(action) {
129  return (ACTION_COLORS[action] || DEFAULT_ACTION).hex;
130}
131
132// ─── Action labels ───────────────────────────────
133
134const ACTION_LABELS = {
135  create: "Created",
136  editStatus: "Status",
137  editValue: "Values",
138  prestige: "Prestige",
139  trade: "Trade",
140  delete: "Deleted",
141  invite: "Invite",
142  editSchedule: "Schedule",
143  editGoal: "Goal",
144  transaction: "Transaction",
145  note: "Note",
146  updateParent: "Moved",
147  editScript: "Script",
148  executeScript: "Ran script",
149  updateChild: "Child",
150  editName: "Renamed",
151  rawIdea: "Raw idea",
152  branchLifecycle: "Branch",
153  purchase: "Purchase",
154  understanding: "Understanding",
155};
156
157export function actionLabel(action) {
158  return ACTION_LABELS[action] || action;
159}
160
161// ─── Media rendering ─────────────────────────────
162// lazy: uses data-src + lazy-media class (needs client-side IntersectionObserver)
163// immediate: uses src directly
164
165export function renderMedia(fileUrl, mimeType, { lazy = true } = {}) {
166  const srcAttr = lazy ? "data-src" : "src";
167  const cls = lazy ? ' class="lazy-media"' : "";
168  const loading = lazy ? ' loading="lazy"' : "";
169
170  if (mimeType.startsWith("image/")) {
171    return `<img ${srcAttr}="${fileUrl}"${loading}${cls} style="max-width:100%;" alt="" />`;
172  }
173  if (mimeType.startsWith("video/")) {
174    return `<video ${srcAttr}="${fileUrl}" controls${lazy ? ' preload="none"' : ""}${cls} style="max-width:100%;"></video>`;
175  }
176  if (mimeType.startsWith("audio/")) {
177    return `<audio ${srcAttr}="${fileUrl}" controls${lazy ? ' preload="none"' : ""}${cls}></audio>`;
178  }
179  if (mimeType === "application/pdf") {
180    return `<iframe ${srcAttr}="${fileUrl}"${loading}${cls} style="width:100%; height:90vh; border:none;"></iframe>`;
181  }
182  return "";
183}
184
185// ─── Chat chain grouping ─────────────────────────
186
187export function groupIntoChains(chats) {
188  const chainMap = new Map();
189  const chainOrder = [];
190  for (const chat of chats) {
191    const key = chat.rootChatId || chat._id;
192    if (!chainMap.has(key)) {
193      chainMap.set(key, { root: null, steps: [] });
194      chainOrder.push(key);
195    }
196    const chain = chainMap.get(key);
197    if (chat.chainIndex === 0 || chat._id === key) {
198      chain.root = chat;
199    } else {
200      chain.steps.push(chat);
201    }
202  }
203  return chainOrder
204    .map((key) => {
205      const chain = chainMap.get(key);
206      chain.steps.sort((a, b) => a.chainIndex - b.chainIndex);
207      return chain;
208    })
209    .filter((c) => c.root);
210}
211
212// ─── Mode labels ─────────────────────────────────
213
214export function modeLabel(path) {
215  if (!path) return "unknown";
216  if (path === "translator") return "Translator";
217  if (path.startsWith("tree:orchestrator:plan:")) {
218    return `Plan Step ${path.split(":")[3]}`;
219  }
220  const parts = path.split(":");
221  const labels = { home: "Home", tree: "Tree", rawIdea: "Raw Idea" };
222  const subLabels = {
223    default: "Default",
224    chat: "Chat",
225    structure: "Structure",
226    edit: "Edit",
227    be: "Be",
228    reflect: "Reflect",
229    navigate: "Navigate",
230    understand: "Understand",
231    "get-context": "Context",
232    respond: "Respond",
233    notes: "Notes",
234    start: "Start",
235    chooseRoot: "Choose Root",
236    complete: "Placed",
237    stuck: "Stuck",
238  };
239  const big = labels[parts[0]] || parts[0];
240  const sub = subLabels[parts[1]] || parts[1] || "";
241  return sub ? `${big} ${sub}` : big;
242}
243
244// ─── Source labels ───────────────────────────────
245
246export function sourceLabel(src) {
247  const map = {
248    user: "User",
249    api: "API",
250    orchestrator: "Chain",
251    background: "Background",
252    script: "Script",
253    system: "System",
254  };
255  return map[src] || src;
256}
257
1/**
2 * Shared HTML route helpers.
3 * Extensions import these to build their own ?html intercept routes.
4 */
5
6import { isHtmlEnabled } from "./config.js";
7
8/**
9 * Middleware: only proceed if ?html is in the query and HTML rendering is enabled.
10 * Otherwise skip to the next route (kernel JSON handler).
11 */
12export function htmlOnly(req, res, next) {
13  if (!("html" in req.query) || !isHtmlEnabled()) {
14    return next("route");
15  }
16  next();
17}
18
19/**
20 * Build a query string from allowed keys.
21 */
22export function buildQS(req, allowed = ["token", "html"]) {
23  const filtered = Object.entries(req.query)
24    .filter(([k]) => allowed.includes(k))
25    .map(([k, v]) => (v === "" ? k : `${k}=${encodeURIComponent(v)}`))
26    .join("&");
27  return filtered ? `?${filtered}` : "";
28}
29
30/**
31 * Build a token + html query string for redirects.
32 */
33export function tokenQS(req) {
34  const token = req.query.token ?? "";
35  return token ? `?token=${encodeURIComponent(token)}&html` : "?html";
36}
37
1/**
2 * HTML Rendering (Infrastructure)
3 *
4 * Server-rendered HTML pages for TreeOS lands. Provides:
5 * - page() layout wrapper with shared CSS
6 * - ?html intercept on API routes (delegates to registered renderers)
7 * - URL-based auth (share tokens, cookie auth)
8 * - registerPage() for extensions to mount their own pages
9 * - registerRenderer() for extensions to provide ?html renderers
10 *
11 * This extension is infrastructure. It ships no pages of its own.
12 * The treeos extension (or any OS distribution) registers its pages here.
13 */
14
15import crypto from "crypto";
16import router, { pageRouter } from "./routes.js";
17import { resolveHtmlShareAccess } from "./shareAuth.js";
18import urlAuth from "./urlAuth.js";
19import authenticateLite from "./authenticateLite.js";
20import { notFoundPage, errorHtml } from "./notFoundPage.js";
21import { resolvePublicRoot, isPublic, hasTreeLlm } from "./publicAccess.js";
22import { isHtmlEnabled } from "./config.js";
23import { sendError, ERR } from "../../seed/protocol.js";
24
25function generateShareToken() {
26  return crypto.randomBytes(16).toString("base64url");
27}
28
29/**
30 * Register an HTML page route on the page router (mounted at /, not /api/v1).
31 * Other extensions call this to add their own server-rendered pages.
32 */
33function registerPage(method, path, ...handlers) {
34  const m = method.toLowerCase();
35  if (typeof pageRouter[m] !== "function") {
36    throw new Error(`Invalid HTTP method: ${method}`);
37  }
38  pageRouter[m](path, ...handlers);
39}
40
41export async function init(core) {
42  const User = core.models.User;
43
44  // Share token and public tree access are handled by urlAuth directly.
45  // They are NOT registered as kernel auth strategies because they provide
46  // view-only access to HTML pages. The kernel's authenticate middleware
47  // should only accept full credentials (JWT, API keys). If share tokens
48  // were in the kernel pipeline, any POST route using authenticate would
49  // accept them and allow mutations.
50
51  // Write default htmlEnabled to .config if not set
52  const { getLandConfigValue, setLandConfigValue } = await import("../../seed/landConfig.js");
53  if (getLandConfigValue("htmlEnabled") === undefined || getLandConfigValue("htmlEnabled") === null) {
54    const envVal = process.env.ENABLE_FRONTEND_HTML === "false" ? "false" : "true";
55    await setLandConfigValue("htmlEnabled", envVal);
56  }
57
58  // Generate share token for new users
59  core.hooks.register("afterRegister", async ({ user }) => {
60    const freshUser = await User.findById(user._id).select("metadata").lean();
61    if (!freshUser) return;
62    const existing = core.userMetadata.getUserMeta(freshUser, "html");
63    if (existing?.shareToken) return;
64    await core.userMetadata.batchSetUserMeta(String(user._id), "html", { ...existing, shareToken: generateShareToken() });
65  }, "html-rendering");
66
67  // Detect optional extensions after boot
68  core.hooks.register("afterBoot", async () => {
69    try {
70      const { getExtension } = await import("../loader.js");
71      const { setEmailAvailable, setLegalAvailable } = await import("./routes.js");
72      setEmailAvailable(!!getExtension("email"));
73      setLegalAvailable(!!getExtension("legal"));
74    } catch {}
75  }, "html-rendering");
76
77  return {
78    router,
79    pageRouter,
80    exports: {
81      // Infrastructure (reusable by any OS distribution)
82      registerPage,
83      urlAuth,
84      authenticateLite,
85      notFoundPage,
86      errorHtml,
87      resolveHtmlShareAccess,
88      resolvePublicRoot,
89      isPublic,
90      hasTreeLlm,
91    },
92  };
93}
94
1export default {
2  name: "html-rendering",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "Server-rendered HTML pages, share token auth, and a page registration API for other extensions.\n\n" +
7    "Direct imports (used by extensions that build their own pages):\n" +
8    "  import { page } from '../html-rendering/html/layout.js'\n" +
9    "  import { esc, escapeHtml } from '../html-rendering/html/utils.js'\n" +
10    "  import { baseStyles, glassHeaderStyles, glassCardStyles } from '../html-rendering/html/baseStyles.js'\n" +
11    "  import { htmlOnly, buildQS, tokenQS } from '../html-rendering/htmlHelpers.js'\n" +
12    "  import urlAuth from '../html-rendering/urlAuth.js'\n" +
13    "  import authenticateLite from '../html-rendering/authenticateLite.js'\n" +
14    "  import { isHtmlEnabled } from '../html-rendering/config.js'",
15
16  needs: {
17    models: ["User", "Node"],
18    services: ["auth"],
19  },
20
21  optional: {},
22
23  provides: {
24    models: {},
25    routes: "./routes.js",
26    tools: false,
27    jobs: false,
28    energyActions: {},
29    sessionTypes: {},
30    authStrategies: true,
31    env: [],
32
33    cli: [
34      {
35        command: "cc", scope: ["tree", "land"],
36        description: "Command center. Tools, modes, extensions at this position.",
37        method: "GET",
38        endpoint: "/node/:nodeId/command-center",
39      },
40    ],
41
42    hooks: {
43      fires: [],
44      listens: ["afterRegister"],
45    },
46
47    // Documented exports (available via getExtension("html-rendering")?.exports)
48    //
49    // Infrastructure (no pages, no renderers):
50    //   registerPage(method, path, ...handlers)  - Mount a page route on the page router (at /, not /api/v1)
51    //   urlAuth                                  - Full auth middleware (JWT, share token, public, canopy)
52    //   authenticateLite                         - Lightweight auth for HTML page API calls
53    //   notFoundPage(req, res, message)          - Render a 404 error page
54    //   errorHtml(status, title, message)        - Render a generic error page
55    //   resolveHtmlShareAccess({ userId, nodeId, shareToken })  - Validate share tokens
56    //   resolvePublicRoot(nodeId)                - Resolve public tree access
57    //   isPublic(visibility)                     - Check if a visibility value is public
58    //   hasTreeLlm(root)                         - Check if a tree has an LLM assigned
59    //
60    // Direct imports (used by extensions that build their own pages):
61    //   import { page } from "../html-rendering/html/layout.js"       - Page wrapper with shared CSS
62    //   import { esc, escapeHtml } from "../html-rendering/html/utils.js"  - HTML escaping
63    //   import { htmlOnly, buildQS, tokenQS } from "../html-rendering/htmlHelpers.js"  - Route helpers
64    //   import urlAuth from "../html-rendering/urlAuth.js"            - Auth middleware
65    //
66    // Each extension owns its own pages and routes. html-rendering is infrastructure only.
67    // If this extension is not installed, all consuming extensions fall back to JSON responses.
68  },
69};
70
1import { sendError, ERR } from "../../seed/protocol.js";
2import { errorHtml } from "./html/error.js";
3import { isHtmlEnabled } from "./config.js";
4
5export { errorHtml };
6
7export function notFoundPage(
8  req,
9  res,
10  message = "This page doesn't exist or may have been moved.",
11) {
12  if (!isHtmlEnabled()) {
13    return sendError(res, 404, ERR.NODE_NOT_FOUND, message);
14  }
15  return res.status(404).send(errorHtml(404, "Page Not Found", message));
16}
17
1export {
2  renderLoginPage,
3  renderRegisterPage,
4  renderForgotPasswordPage,
5} from "./html/login.js";
6
1import Node from "../../seed/models/node.js";
2
3/**
4 * Check if a tree's visibility allows public access.
5 */
6export function isPublic(visibility) {
7  return visibility === "public";
8}
9
10/**
11 * Walk from any node up to its root and return public access info.
12 * Returns null if the node/tree doesn't exist or is a system tree.
13 */
14export async function resolvePublicRoot(nodeId) {
15  if (!nodeId) return null;
16
17  let node = await Node.findById(nodeId)
18    .select("parent rootOwner visibility llmDefault metadata")
19    .lean();
20
21  if (!node) return null;
22
23  while (!node.rootOwner || node.rootOwner === "SYSTEM") {
24    if (!node.parent) return null;
25
26    node = await Node.findById(node.parent)
27      .select("parent rootOwner llmDefault metadata systemRole")
28      .lean();
29
30    if (!node) return null;
31    if (node.systemRole) return null;
32  }
33
34  return {
35    rootId: node._id.toString(),
36    visibility: node.visibility || "private",
37    rootOwner: node.rootOwner,
38    llmDefault: node.llmDefault || null,
39  };
40}
41
42/**
43 * Check if a tree has LLM enabled (default slot set and not "none").
44 */
45export function hasTreeLlm(llmAssignments) {
46  if (!llmAssignments) return false;
47  if (llmAssignments.default === "none") return false;
48  return !!(llmAssignments.default);
49}
50
1import log from "../../seed/log.js";
2import express from "express";
3import crypto from "crypto";
4import jwt from "jsonwebtoken";
5import authenticate from "../../seed/middleware/authenticate.js";
6import urlAuth from "./urlAuth.js";
7import User from "../../seed/models/user.js";
8import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
9import { sendOk, sendError, ERR } from "../../seed/protocol.js";
10import {
11  renderLoginPage,
12  renderRegisterPage,
13  renderForgotPasswordPage,
14} from "./pages.js";
15import { isHtmlEnabled } from "./config.js";
16
17const router = express.Router();
18
19const URL_SAFE_REGEX = /^[A-Za-z0-9\-_.~]+$/;
20
21// Sanitize token query parameter before any rendering
22router.use((req, _res, next) => {
23  if (req.query.token && !URL_SAFE_REGEX.test(req.query.token)) {
24    req.query.token = "";
25  }
26  next();
27});
28
29
30// VERIFY token (returns share token + user info for frontend)
31router.post("/verify-token", authenticate, async (req, res) => {
32  try {
33    const user = await User.findById(req.userId)
34      .select("metadata")
35      .lean();
36
37    const htmlMeta = getUserMeta(user, "html");
38    const HTMLShareToken = htmlMeta?.shareToken || null;
39
40    let hasLlm = false;
41    try {
42      const fullUser = await User.findById(req.userId)
43        .select("llmDefault metadata")
44        .lean();
45      if (fullUser?.llmDefault) {
46        hasLlm = true;
47      } else {
48        let LlmConnection;
49        try { LlmConnection = (await import("../../seed/models/llmConnection.js")).default; } catch { }
50        if (LlmConnection) {
51          const connCount = await LlmConnection.countDocuments({ userId: req.userId });
52          hasLlm = connCount > 0;
53        }
54      }
55    } catch (err) {
56      log.error("HTML", "verify-token LLM check error:", err.message);
57    }
58
59    sendOk(res, {
60      userId: req.userId,
61      username: req.username,
62      HTMLShareToken,
63      hasLlm,
64    });
65  } catch (err) {
66    log.error("HTML", "verify-token error:", err.message);
67    sendError(res, 500, ERR.INTERNAL, "Failed to verify token");
68  }
69});
70
71// Server-side auth redirect. Checks httpOnly cookie, redirects to app or login.
72// No client-side JavaScript needed. Whitelist prevents open redirect.
73router.get("/auth-redirect", async (req, res) => {
74  const { to } = req.query;
75  const allowed = { chat: "/chat", dashboard: "/dashboard", setup: "/setup" };
76  const destination = allowed[to] || "/";
77
78  try {
79    const jwt = (await import("jsonwebtoken")).default;
80    const token = req.cookies?.token;
81    if (!token) return res.redirect(`/login?redirect=${encodeURIComponent(destination)}`);
82
83    const decoded = jwt.verify(token, process.env.JWT_SECRET);
84    if (!decoded?.userId) return res.redirect(`/login?redirect=${encodeURIComponent(destination)}`);
85
86    return res.redirect(destination);
87  } catch {
88    return res.redirect(`/login?redirect=${encodeURIComponent(destination)}`);
89  }
90});
91
92// Share token management (JSON API). HTML rendering handled by treeos htmlRoutes.
93router.get("/user/:userId/shareToken", authenticate, async (req, res, next) => {
94  try {
95    if ("html" in req.query) return next("route"); // treeos htmlRoutes handles HTML
96
97    const { userId } = req.params;
98    const user = await User.findById(userId).select("username metadata").lean();
99    if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
100
101    const htmlMeta = getUserMeta(user, "html");
102    return sendOk(res, { userId, shareToken: htmlMeta?.shareToken || null });
103  } catch (err) {
104    log.error("HTML", "Share token error:", err.message);
105    sendError(res, 500, ERR.INTERNAL, err.message);
106  }
107});
108
109// POST share token update (JWT only, never share token auth)
110router.post("/user/:userId/shareToken", authenticate, async (req, res) => {
111  try {
112    if (req.userId.toString() !== req.params.userId.toString()) {
113      return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
114    }
115    const user = await User.findById(req.userId);
116    if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
117
118    let { htmlShareToken } = req.body;
119    if (typeof htmlShareToken !== "string") {
120      return sendError(res, 400, ERR.INVALID_INPUT, "htmlShareToken must be a string");
121    }
122    htmlShareToken = htmlShareToken.trim();
123    if (htmlShareToken.length > 128 || htmlShareToken.length < 1) {
124      return sendError(res, 400, ERR.INVALID_INPUT, "htmlShareToken must be 1 to 128 characters");
125    }
126    if (!URL_SAFE_REGEX.test(htmlShareToken)) {
127      return sendError(res, 400, ERR.INVALID_INPUT, "htmlShareToken may only contain URL-safe characters");
128    }
129
130    setUserMeta(user, "html", { shareToken: htmlShareToken });
131    await user.save();
132
133    const token = req.query.token ?? "";
134    if ("html" in req.query) {
135      return res.redirect(`/api/v1/user/${req.params.userId}/shareToken?token=${encodeURIComponent(token)}&html`);
136    }
137    return sendOk(res, { htmlShareToken });
138  } catch (err) {
139    log.error("HTML", "Share token update error:", err.message);
140    sendError(res, 500, ERR.INTERNAL, err.message);
141  }
142});
143
144// Page routes (mounted at / not /api/v1)
145export const pageRouter = express.Router();
146
147// "/" owned by treeos-base extension (welcome page). If treeos-base not installed,
148// fall through to kernel 404. The "/" route is an OS-level concept, not kernel.
149
150let _hasEmailCached = false;
151let _hasLegalCached = false;
152// Set after boot via init
153export function setEmailAvailable(v) { _hasEmailCached = !!v; }
154export function setLegalAvailable(v) { _hasLegalCached = !!v; }
155
156pageRouter.get("/login", (req, res) => {
157  if (!isHtmlEnabled()) {
158    return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Server-rendered HTML is disabled.");
159  }
160  renderLoginPage(req, res, { hasEmail: _hasEmailCached });
161});
162
163pageRouter.get("/register", (req, res) => {
164  if (!isHtmlEnabled()) {
165    return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Server-rendered HTML is disabled.");
166  }
167  renderRegisterPage(req, res, { hasEmail: _hasEmailCached, hasLegal: _hasLegalCached });
168});
169
170pageRouter.get("/forgot-password", (req, res) => {
171  if (!isHtmlEnabled()) {
172    return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Server-rendered HTML is disabled.");
173  }
174  if (!_hasEmailCached) {
175    return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Email extension not installed.");
176  }
177  renderForgotPasswordPage(req, res);
178});
179
180export default router;
181
1// Share token authentication for HTML-rendered pages.
2// Moved from core/authenticate.js into the html-rendering extension.
3
4import log from "../../seed/log.js";
5import User from "../../seed/models/user.js";
6import { resolveRootNode } from "../../seed/tree/treeFetch.js";
7
8export async function resolveHtmlShareAccess({ userId, nodeId, shareToken }) {
9  if (!shareToken) {
10    return { allowed: false, reason: "Missing share token" };
11  }
12
13  // CASE 1: userId-based access
14  if (userId && !nodeId) {
15    const user = await User.findOne({
16      _id: userId,
17      "metadata.html.shareToken": shareToken,
18    })
19      .select("_id username")
20      .lean()
21      .exec();
22
23    if (!user) {
24      return { allowed: false, reason: "Invalid share token" };
25    }
26
27    return {
28      allowed: true,
29      matchedUserId: user._id,
30      matchedUsername: user.username,
31      scope: "user",
32    };
33  }
34
35  // CASE 2: nodeId-based access
36  if (nodeId) {
37    const rootNode = await resolveRootNode(nodeId);
38
39    const userIds = [
40      rootNode.rootOwner,
41      ...(rootNode.contributors || []),
42    ].filter(Boolean);
43
44    if (userIds.length === 0) {
45      return { allowed: false, reason: "No users associated with root" };
46    }
47
48    const matchedUser = await User.findOne({
49      _id: { $in: userIds },
50      "metadata.html.shareToken": shareToken,
51    })
52      .select("_id username")
53      .lean()
54      .exec();
55
56    if (!matchedUser) {
57      log.debug("Auth", "ShareAuth: DENIED nodeId=%s userIds=%j tokenPrefix=%s", nodeId, userIds, shareToken?.slice(0, 6));
58      return { allowed: false, reason: "Invalid share token for node" };
59    }
60
61    return {
62      allowed: true,
63      rootId: rootNode._id.toString(),
64      matchedUserId: matchedUser._id,
65      matchedUsername: matchedUser.username,
66      scope: "node",
67    };
68  }
69
70  return {
71    allowed: false,
72    reason: "userId or nodeId is required",
73  };
74}
75
1import log from "../../seed/log.js";
2import { sendError, ERR } from "../../seed/protocol.js";
3import jwt from "jsonwebtoken";
4import dotenv from "dotenv";
5import path from "path";
6import { fileURLToPath } from "url";
7import User from "../../seed/models/user.js";
8import { resolvePublicRoot, isPublic } from "./publicAccess.js";
9import { errorHtml } from "./notFoundPage.js";
10import { resolveHtmlShareAccess } from "./shareAuth.js";
11import { verifyCanopyToken, getLandIdentity } from "../../canopy/identity.js";
12import { getPeerByDomain, registerPeer } from "../../canopy/peers.js";
13import { lookupLandByDomain } from "../../canopy/horizon.js";
14import { authStrategies } from "../../seed/services.js";
15
16const __filename = fileURLToPath(import.meta.url);
17const __dirname = path.dirname(__filename);
18dotenv.config({ path: path.resolve(__dirname, "../..", ".env") });
19const JWT_SECRET = process.env.JWT_SECRET;
20
21function wantsHtml(req) {
22  return "html" in req.query || (req.headers.accept || "").includes("text/html");
23}
24
25function errorPage(res, status, title, message) {
26  return res.status(status).send(errorHtml(status, title, message));
27}
28
29export default async function urlAuth(req, res, next) {
30  try {
31    const authHeader = req.headers.authorization;
32
33    /* ===========================
34        0. CANOPY TOKEN AUTH (remote land users)
35    ============================ */
36    if (authHeader?.startsWith("CanopyToken ")) {
37      const canopyToken = authHeader.slice("CanopyToken ".length);
38
39      let unverified;
40      try {
41        const parts = canopyToken.split(".");
42        unverified = JSON.parse(Buffer.from(parts[1], "base64url").toString());
43      } catch {
44        return sendError(res, 401, ERR.UNAUTHORIZED, "Malformed CanopyToken");
45      }
46
47      let peer = await getPeerByDomain(unverified.iss);
48      if (!peer) {
49        try {
50          const horizonLand = await lookupLandByDomain(unverified.iss);
51          if (horizonLand?.baseUrl) {
52            const infoRes = await fetch(
53              `${horizonLand.baseUrl.replace(/\/+$/, "")}/canopy/info`,
54              { signal: AbortSignal.timeout(5000) }
55            );
56            if (infoRes.ok) {
57              const info = await infoRes.json();
58              if (info.domain === unverified.iss) {
59                peer = await registerPeer(horizonLand.baseUrl);
60              }
61            }
62          }
63        } catch (_) {}
64      }
65      if (!peer) return sendError(res, 403, ERR.FORBIDDEN, "Unknown land: " + unverified.iss);
66      if (peer.status === "blocked") return sendError(res, 403, ERR.FORBIDDEN, "Land blocked");
67
68      const { valid, payload } = await verifyCanopyToken(canopyToken, peer.publicKey);
69      if (!valid) return sendError(res, 401, ERR.UNAUTHORIZED, "Invalid CanopyToken");
70
71      const myDomain = getLandIdentity().domain;
72      if (payload.aud && payload.aud !== myDomain) {
73        return sendError(res, 401, ERR.UNAUTHORIZED, "CanopyToken audience mismatch");
74      }
75
76      const ghostUser = await User.findOne({
77        _id: payload.sub,
78        isRemote: true,
79        homeLand: payload.iss,
80      });
81
82      if (ghostUser) {
83        req.userId = ghostUser._id;
84        req.username = ghostUser.username;
85        req.authType = "canopy";
86        req.isHtmlShare = false;
87        return next();
88      }
89
90      // No ghost user. Check if tree is public (allow as authenticated visitor).
91      const nodeId = req.params?.nodeId || req.params?.rootId;
92      if (nodeId) {
93        const rootInfo = await resolvePublicRoot(nodeId);
94        if (rootInfo && isPublic(rootInfo.visibility)) {
95          req.isPublicAccess = true;
96          req.publicRootId = rootInfo.rootId;
97          req.publicRootOwner = rootInfo.rootOwner;
98          req.publicLlmDefault = rootInfo.llmDefault;
99          req.userId = null;
100          req.canopyVisitor = { userId: payload.sub, homeLand: payload.iss };
101          req.isHtmlShare = false;
102          return next();
103        }
104      }
105
106      return sendError(res, 403, ERR.FORBIDDEN, "Remote user not registered on this land");
107    }
108
109    /* ===========================
110        1. EXTENSION AUTH STRATEGIES (api-keys, etc.)
111    ============================ */
112    for (const { name, handler } of authStrategies) {
113      try {
114        const result = await handler(req);
115        if (result) {
116          req.userId = result.userId;
117          req.username = result.username;
118          req.authType = name;
119          req.isHtmlShare = false;
120          if (result.extra && typeof result.extra === "object") {
121            req.strategyExtra = Object.freeze({ ...result.extra });
122          }
123          return next();
124        }
125      } catch (strategyErr) {
126        if (strategyErr.status) {
127          const code = strategyErr.status === 401 ? ERR.UNAUTHORIZED
128            : strategyErr.status === 403 ? ERR.FORBIDDEN
129            : ERR.INTERNAL;
130          return sendError(res, strategyErr.status, code, strategyErr.message);
131        }
132      }
133    }
134
135    /* ===========================
136        1.5. JWT AUTH (Bearer token or cookie)
137    ============================ */
138    let jwtToken = null;
139    if (authHeader?.startsWith("Bearer ")) {
140      jwtToken = authHeader.slice(7).trim();
141    }
142    if (!jwtToken && req.cookies?.token) {
143      jwtToken = req.cookies.token;
144    }
145    if (jwtToken && JWT_SECRET) {
146      try {
147        const decoded = jwt.verify(jwtToken, JWT_SECRET);
148        req.userId = decoded.userId;
149        req.username = decoded.username;
150        req.authType = "jwt";
151        req.isHtmlShare = false;
152        return next();
153      } catch (_) {
154        // Invalid JWT, fall through to share token / public
155      }
156    }
157
158    /* ===========================
159        2. SHARE TOKEN AUTH
160    ============================ */
161    const shareToken =
162      req.query.token ||
163      req.params.token;
164
165    if (!shareToken) {
166      /* ===========================
167          3. PUBLIC TREE ACCESS (last resort, no credentials at all)
168      ============================ */
169      const nodeId = req.params?.nodeId || req.params?.rootId;
170      if (nodeId) {
171        const rootInfo = await resolvePublicRoot(nodeId);
172        if (rootInfo && isPublic(rootInfo.visibility)) {
173          req.isPublicAccess = true;
174          req.publicRootId = rootInfo.rootId;
175          req.publicRootOwner = rootInfo.rootOwner;
176          req.publicLlmDefault = rootInfo.llmDefault;
177          req.userId = null;
178          req.isHtmlShare = false;
179          return next();
180        }
181      }
182
183      if (wantsHtml(req)) {
184        return errorPage(res, 401, "Share Token Required",
185          "No share token was provided. You need a valid share link to view this page.");
186      }
187      return sendError(res, 401, ERR.UNAUTHORIZED, "No share token provided");
188    }
189
190    const userId =
191      req.params?.userId || req.body?.userId || req.query?.userId || null;
192
193    const shareNodeId =
194      req.params?.nodeId ||
195      req.body?.nodeId ||
196      req.query?.nodeId ||
197      req.params?.rootId ||
198      null;
199
200    if (!userId && !shareNodeId) {
201      if (wantsHtml(req)) {
202        return errorPage(res, 400, "Invalid Link",
203          "This link is missing required information. Please check that you have the full URL.");
204      }
205      return sendError(res, 400, ERR.INVALID_INPUT, "userId or nodeId is required for shared access");
206    }
207
208    const result = await resolveHtmlShareAccess({
209      userId,
210      nodeId: shareNodeId,
211      shareToken,
212    });
213
214    if (!result.allowed) {
215      if (wantsHtml(req)) {
216        return errorPage(res, 403, "Access Denied",
217          "You don't have access to this content. It may have been deleted, moved, or your share token is invalid. Ask the owner for a new link.");
218      }
219      return sendError(res, 403, ERR.FORBIDDEN, "Invalid or unauthorized share token");
220    }
221
222    req.userId = result.matchedUserId;
223    req.username = result.matchedUsername;
224    req.rootId = result.rootId ?? null;
225    req.isHtmlShare = true;
226
227    next();
228  } catch (err) {
229    log.error("Auth", "[urlAuth] error:", err);
230    if (wantsHtml(req)) {
231      return errorPage(res, 403, "Authorization Failed",
232        "Something went wrong while verifying your access. Please try again or request a new share link.");
233    }
234    sendError(res, 403, ERR.FORBIDDEN, "Share authorization failed");
235  }
236}
237

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star html-rendering

Comments

Loading comments...

Post comments from the CLI: treeos ext comment html-rendering "your comment"
Max 3 comments per extension. One star and one flag per user.