fix: use Response wrapper instead of h3 tagged template html()

h3 v2's html() is a tagged template literal, not a function that
accepts a string. JSX-rendered markup passed directly causes
"first.reduce is not a function". Created htmlPage() helper that
wraps markup in a proper Response with text/html content type.
This commit is contained in:
Mitchell R 2026-05-10 02:50:16 +02:00
parent 56053e2d6a
commit a64363258d
No known key found for this signature in database
5 changed files with 53 additions and 37 deletions

View file

@ -0,0 +1,12 @@
/**
* Return an HTML response from JSX-rendered markup.
*
* h3 v2's html() is a tagged template literal only — can't pass
* a string/object directly. This helper wraps JSX output in a
* proper Response with text/html content type.
*/
export function htmlPage(markup: unknown): Response {
return new Response(String(markup), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}

View file

@ -1,7 +1,8 @@
/** /**
* Account management routes password change, TOTP enrollment. * Account management routes password change, TOTP enrollment.
*/ */
import { type H3, html, readBody } from "h3"; import { type H3, readBody } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js"; import type { AdminDeps } from "./index.js";
import { AccountPage, TotpEnrollPage } from "../../web-templates/admin-pages.js"; import { AccountPage, TotpEnrollPage } from "../../web-templates/admin-pages.js";
@ -10,7 +11,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/account", (event) => { app.get("/admin/account", (event) => {
const user = event.context.user!; const user = event.context.user!;
return html(AccountPage({ user: user.username, totpEnabled: user.totp_enabled })); return htmlPage(AccountPage({ user: user.username, totpEnabled: user.totp_enabled }));
}); });
// ---- Change password ------------------------------------------------------ // ---- Change password ------------------------------------------------------
@ -22,7 +23,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
const newPw = body?.new_password ?? ""; const newPw = body?.new_password ?? "";
if (!current || !newPw) { if (!current || !newPw) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: user.totp_enabled, totpEnabled: user.totp_enabled,
error: "Both current and new password required.", error: "Both current and new password required.",
@ -30,7 +31,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
} }
if (newPw.length < 12) { if (newPw.length < 12) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: user.totp_enabled, totpEnabled: user.totp_enabled,
error: "New password must be at least 12 characters.", error: "New password must be at least 12 characters.",
@ -39,7 +40,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
const valid = await deps.auth.verifyPassword(current, user.password_hash); const valid = await deps.auth.verifyPassword(current, user.password_hash);
if (!valid) { if (!valid) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: user.totp_enabled, totpEnabled: user.totp_enabled,
error: "Current password incorrect.", error: "Current password incorrect.",
@ -64,7 +65,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
const user = event.context.user!; const user = event.context.user!;
if (user.totp_enabled) { if (user.totp_enabled) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: true, totpEnabled: true,
error: "TOTP already enabled.", error: "TOTP already enabled.",
@ -81,7 +82,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
totp_secret_encrypted: encrypted, totp_secret_encrypted: encrypted,
}); });
return html(TotpEnrollPage({ return htmlPage(TotpEnrollPage({
user: user.username, user: user.username,
secret, secret,
provisioningUri: uri, provisioningUri: uri,
@ -97,7 +98,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
const code = (body?.code ?? "").trim().replace(/\s/g, ""); const code = (body?.code ?? "").trim().replace(/\s/g, "");
if (!code || code.length !== 6) { if (!code || code.length !== 6) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: false, totpEnabled: false,
error: "Enter a valid 6-digit code.", error: "Enter a valid 6-digit code.",
@ -105,7 +106,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
} }
if (!user.totp_secret_encrypted) { if (!user.totp_secret_encrypted) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: false, totpEnabled: false,
error: "No TOTP enrollment in progress. Start again.", error: "No TOTP enrollment in progress. Start again.",
@ -115,7 +116,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted); const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted);
const valid = deps.auth.verifyTotpCode(secret, code); const valid = deps.auth.verifyTotpCode(secret, code);
if (!valid) { if (!valid) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: false, totpEnabled: false,
error: "Invalid code. Scan the QR code again and enter the current code.", error: "Invalid code. Scan the QR code again and enter the current code.",
@ -147,7 +148,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
const valid = await deps.auth.verifyPassword(password, user.password_hash); const valid = await deps.auth.verifyPassword(password, user.password_hash);
if (!valid) { if (!valid) {
return html(AccountPage({ return htmlPage(AccountPage({
user: user.username, user: user.username,
totpEnabled: true, totpEnabled: true,
error: "Password incorrect.", error: "Password incorrect.",

View file

@ -1,7 +1,8 @@
/** /**
* Admin page routes overview, cameras, kiosks, labels, etc. * Admin page routes overview, cameras, kiosks, labels, etc.
*/ */
import { type H3, html, readBody } from "h3"; import { type H3, readBody } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js"; import type { AdminDeps } from "./index.js";
import { import {
OverviewPage, OverviewPage,
@ -25,7 +26,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000; return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000;
}); });
return html(OverviewPage({ return htmlPage(OverviewPage({
user: user.username, user: user.username,
cameraCount: cameras.length, cameraCount: cameras.length,
kioskCount: kiosks.length, kioskCount: kiosks.length,
@ -49,12 +50,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
for (const cam of cameras) { for (const cam of cameras) {
streamCounts.set(cam.id, deps.repo.listCameraStreams(cam.id).length); streamCounts.set(cam.id, deps.repo.listCameraStreams(cam.id).length);
} }
return html(CamerasPage({ user: user.username, cameras, streamCounts })); return htmlPage(CamerasPage({ user: user.username, cameras, streamCounts }));
}); });
app.get("/admin/cameras/new", (event) => { app.get("/admin/cameras/new", (event) => {
const user = event.context.user!; const user = event.context.user!;
return html(CameraNewPage({ user: user.username })); return htmlPage(CameraNewPage({ user: user.username }));
}); });
app.post("/admin/cameras/new", async (event) => { app.post("/admin/cameras/new", async (event) => {
@ -92,7 +93,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
} }
if (errors.length > 0) { if (errors.length > 0) {
return html(CameraNewPage({ return htmlPage(CameraNewPage({
user: user.username, user: user.username,
error: errors.join(" "), error: errors.join(" "),
values: body, values: body,
@ -131,14 +132,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const user = event.context.user!; const user = event.context.user!;
const kiosks = deps.repo.listKiosks(); const kiosks = deps.repo.listKiosks();
const pending = deps.repo.listPendingPairingCodes(); const pending = deps.repo.listPendingPairingCodes();
return html(KiosksPage({ user: user.username, kiosks, pendingCodes: pending })); return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending }));
}); });
// ---- Simple list pages (templates, layouts, displays, labels) ------------- // ---- Simple list pages (templates, layouts, displays, labels) -------------
app.get("/admin/templates", (event) => { app.get("/admin/templates", (event) => {
const user = event.context.user!; const user = event.context.user!;
return html(SimpleListPage({ return htmlPage(SimpleListPage({
user: user.username, user: user.username,
pageTitle: "Layout Templates", pageTitle: "Layout Templates",
description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.", description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.",
@ -149,7 +150,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/layouts", (event) => { app.get("/admin/layouts", (event) => {
const user = event.context.user!; const user = event.context.user!;
return html(SimpleListPage({ return htmlPage(SimpleListPage({
user: user.username, user: user.username,
pageTitle: "Layouts", pageTitle: "Layouts",
description: "A layout binds cameras and other content into a template's regions for one display.", description: "A layout binds cameras and other content into a template's regions for one display.",
@ -161,7 +162,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/displays", (event) => { app.get("/admin/displays", (event) => {
const user = event.context.user!; const user = event.context.user!;
const displays = deps.repo.listDisplays(); const displays = deps.repo.listDisplays();
return html(SimpleListPage({ return htmlPage(SimpleListPage({
user: user.username, user: user.username,
pageTitle: "Displays", pageTitle: "Displays",
description: "Physical HDMI displays. Primary display created during setup.", description: "Physical HDMI displays. Primary display created during setup.",
@ -176,7 +177,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/labels", (event) => { app.get("/admin/labels", (event) => {
const user = event.context.user!; const user = event.context.user!;
const labels = deps.repo.listLabels(); const labels = deps.repo.listLabels();
return html(SimpleListPage({ return htmlPage(SimpleListPage({
user: user.username, user: user.username,
pageTitle: "Labels", pageTitle: "Labels",
description: "Labels route cameras, layouts, and kiosks to each other across sites.", description: "Labels route cameras, layouts, and kiosks to each other across sites.",

View file

@ -1,7 +1,8 @@
/** /**
* Authentication routes: login, TOTP, recovery, logout. * Authentication routes: login, TOTP, recovery, logout.
*/ */
import { type H3, readBody, html, getCookie, setCookie, deleteCookie, getQuery, getRequestHeader } from "h3"; import { type H3, readBody, getCookie, setCookie, deleteCookie, getQuery, getRequestHeader } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js"; import type { AdminDeps } from "./index.js";
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js"; import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
@ -18,7 +19,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
app.get("/auth/login", (event) => { app.get("/auth/login", (event) => {
const q = getQuery(event) as Record<string, string | undefined>; const q = getQuery(event) as Record<string, string | undefined>;
const welcome = q["welcome"] === "1"; const welcome = q["welcome"] === "1";
return html(LoginPage({ welcome })); return htmlPage(LoginPage({ welcome }));
}); });
app.post("/auth/login", async (event) => { app.post("/auth/login", async (event) => {
@ -27,18 +28,18 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
const password = body?.password ?? ""; const password = body?.password ?? "";
if (!username || !password) { if (!username || !password) {
return html(LoginPage({ error: "Username and password required.", username })); return htmlPage(LoginPage({ error: "Username and password required.", username }));
} }
const user = deps.repo.getUserByUsername(username); const user = deps.repo.getUserByUsername(username);
if (!user || !user.is_active) { if (!user || !user.is_active) {
return html(LoginPage({ error: "Invalid credentials.", username })); return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
} }
if (user.locked_until) { if (user.locked_until) {
const lockEnd = new Date(user.locked_until); const lockEnd = new Date(user.locked_until);
if (lockEnd > new Date()) { if (lockEnd > new Date()) {
return html(LoginPage({ error: "Account locked. Try again later.", username })); return htmlPage(LoginPage({ error: "Account locked. Try again later.", username }));
} }
} }
@ -50,7 +51,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString(); patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString();
} }
deps.repo.updateUser(user.id, patch); deps.repo.updateUser(user.id, patch);
return html(LoginPage({ error: "Invalid credentials.", username })); return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
} }
deps.repo.updateUser(user.id, { deps.repo.updateUser(user.id, {
@ -91,7 +92,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
if (!resolved || !resolved.session.totp_pending) { if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
return html(TotpPage({})); return htmlPage(TotpPage({}));
}); });
app.post("/auth/totp", async (event) => { app.post("/auth/totp", async (event) => {
@ -108,18 +109,18 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
const code = (body?.code ?? "").trim().replace(/\s/g, ""); const code = (body?.code ?? "").trim().replace(/\s/g, "");
if (!code || code.length !== 6) { if (!code || code.length !== 6) {
return html(TotpPage({ error: "Enter a 6-digit code." })); return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
} }
const { user, session } = resolved; const { user, session } = resolved;
if (!user.totp_secret_encrypted) { if (!user.totp_secret_encrypted) {
return html(TotpPage({ error: "TOTP not configured for this account." })); return htmlPage(TotpPage({ error: "TOTP not configured for this account." }));
} }
const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted); const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted);
const valid = deps.auth.verifyTotpCode(secret, code); const valid = deps.auth.verifyTotpCode(secret, code);
if (!valid) { if (!valid) {
return html(TotpPage({ error: "Invalid code. Try again." })); return htmlPage(TotpPage({ error: "Invalid code. Try again." }));
} }
deps.repo.setSessionTotpPending(session.id, false); deps.repo.setSessionTotpPending(session.id, false);
@ -137,7 +138,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
if (!resolved || !resolved.session.totp_pending) { if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
return html(RecoveryPage({})); return htmlPage(RecoveryPage({}));
}); });
app.post("/auth/recovery", async (event) => { app.post("/auth/recovery", async (event) => {
@ -154,7 +155,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
const code = (body?.code ?? "").trim().toUpperCase().replace(/\s/g, ""); const code = (body?.code ?? "").trim().toUpperCase().replace(/\s/g, "");
if (!code) { if (!code) {
return html(RecoveryPage({ error: "Enter a recovery code." })); return htmlPage(RecoveryPage({ error: "Enter a recovery code." }));
} }
const { user, session } = resolved; const { user, session } = resolved;
@ -162,7 +163,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
const result = await deps.auth.consumeRecoveryCode(code, hashedCodes); const result = await deps.auth.consumeRecoveryCode(code, hashedCodes);
if (!result.ok) { if (!result.ok) {
return html(RecoveryPage({ error: "Invalid recovery code." })); return htmlPage(RecoveryPage({ error: "Invalid recovery code." }));
} }
deps.repo.updateUser(user.id, { deps.repo.updateUser(user.id, {

View file

@ -1,7 +1,8 @@
/** /**
* First-run setup routes. * First-run setup routes.
*/ */
import { type H3, readBody, html } from "h3"; import { type H3, readBody } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js"; import type { AdminDeps } from "./index.js";
import { SetupPage } from "../../web-templates/auth-pages.js"; import { SetupPage } from "../../web-templates/auth-pages.js";
@ -10,7 +11,7 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
if (deps.repo.isSetupComplete()) { if (deps.repo.isSetupComplete()) {
return new Response(null, { status: 302, headers: { location: "/admin/" } }); return new Response(null, { status: 302, headers: { location: "/admin/" } });
} }
return html(SetupPage({})); return htmlPage(SetupPage({}));
}); });
app.post("/setup", async (event) => { app.post("/setup", async (event) => {
@ -33,7 +34,7 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
} }
if (errors.length > 0) { if (errors.length > 0) {
return html(SetupPage({ error: errors.join(" "), username })); return htmlPage(SetupPage({ error: errors.join(" "), username }));
} }
const hash = await deps.auth.hashPassword(password); const hash = await deps.auth.hashPassword(password);