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, "&")
13 .replace(/</g, "<")
14 .replace(/>/g, ">")
15 .replace(/"/g, """)
16 .replace(/'/g, "'");
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
Loading comments...