mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
56053e2d6a
commit
a64363258d
5 changed files with 53 additions and 37 deletions
12
server/src/plugins/service-admin-http/html-response.ts
Normal file
12
server/src/plugins/service-admin-http/html-response.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* 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 { 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) => {
|
||||
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 ------------------------------------------------------
|
||||
|
|
@ -22,7 +23,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
|||
const newPw = body?.new_password ?? "";
|
||||
|
||||
if (!current || !newPw) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: user.totp_enabled,
|
||||
error: "Both current and new password required.",
|
||||
|
|
@ -30,7 +31,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
|||
}
|
||||
|
||||
if (newPw.length < 12) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: user.totp_enabled,
|
||||
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);
|
||||
if (!valid) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: user.totp_enabled,
|
||||
error: "Current password incorrect.",
|
||||
|
|
@ -64,7 +65,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
|||
const user = event.context.user!;
|
||||
|
||||
if (user.totp_enabled) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: true,
|
||||
error: "TOTP already enabled.",
|
||||
|
|
@ -81,7 +82,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
|||
totp_secret_encrypted: encrypted,
|
||||
});
|
||||
|
||||
return html(TotpEnrollPage({
|
||||
return htmlPage(TotpEnrollPage({
|
||||
user: user.username,
|
||||
secret,
|
||||
provisioningUri: uri,
|
||||
|
|
@ -97,7 +98,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
|||
const code = (body?.code ?? "").trim().replace(/\s/g, "");
|
||||
|
||||
if (!code || code.length !== 6) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: false,
|
||||
error: "Enter a valid 6-digit code.",
|
||||
|
|
@ -105,7 +106,7 @@ export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
|||
}
|
||||
|
||||
if (!user.totp_secret_encrypted) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: false,
|
||||
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 valid = deps.auth.verifyTotpCode(secret, code);
|
||||
if (!valid) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: false,
|
||||
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);
|
||||
if (!valid) {
|
||||
return html(AccountPage({
|
||||
return htmlPage(AccountPage({
|
||||
user: user.username,
|
||||
totpEnabled: true,
|
||||
error: "Password incorrect.",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* 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 {
|
||||
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 html(OverviewPage({
|
||||
return htmlPage(OverviewPage({
|
||||
user: user.username,
|
||||
cameraCount: cameras.length,
|
||||
kioskCount: kiosks.length,
|
||||
|
|
@ -49,12 +50,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
for (const cam of cameras) {
|
||||
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) => {
|
||||
const user = event.context.user!;
|
||||
return html(CameraNewPage({ user: user.username }));
|
||||
return htmlPage(CameraNewPage({ user: user.username }));
|
||||
});
|
||||
|
||||
app.post("/admin/cameras/new", async (event) => {
|
||||
|
|
@ -92,7 +93,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return html(CameraNewPage({
|
||||
return htmlPage(CameraNewPage({
|
||||
user: user.username,
|
||||
error: errors.join(" "),
|
||||
values: body,
|
||||
|
|
@ -131,14 +132,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
const user = event.context.user!;
|
||||
const kiosks = deps.repo.listKiosks();
|
||||
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) -------------
|
||||
|
||||
app.get("/admin/templates", (event) => {
|
||||
const user = event.context.user!;
|
||||
return html(SimpleListPage({
|
||||
return htmlPage(SimpleListPage({
|
||||
user: user.username,
|
||||
pageTitle: "Layout Templates",
|
||||
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) => {
|
||||
const user = event.context.user!;
|
||||
return html(SimpleListPage({
|
||||
return htmlPage(SimpleListPage({
|
||||
user: user.username,
|
||||
pageTitle: "Layouts",
|
||||
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) => {
|
||||
const user = event.context.user!;
|
||||
const displays = deps.repo.listDisplays();
|
||||
return html(SimpleListPage({
|
||||
return htmlPage(SimpleListPage({
|
||||
user: user.username,
|
||||
pageTitle: "Displays",
|
||||
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) => {
|
||||
const user = event.context.user!;
|
||||
const labels = deps.repo.listLabels();
|
||||
return html(SimpleListPage({
|
||||
return htmlPage(SimpleListPage({
|
||||
user: user.username,
|
||||
pageTitle: "Labels",
|
||||
description: "Labels route cameras, layouts, and kiosks to each other across sites.",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* 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 { 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) => {
|
||||
const q = getQuery(event) as Record<string, string | undefined>;
|
||||
const welcome = q["welcome"] === "1";
|
||||
return html(LoginPage({ welcome }));
|
||||
return htmlPage(LoginPage({ welcome }));
|
||||
});
|
||||
|
||||
app.post("/auth/login", async (event) => {
|
||||
|
|
@ -27,18 +28,18 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
const password = body?.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);
|
||||
if (!user || !user.is_active) {
|
||||
return html(LoginPage({ error: "Invalid credentials.", username }));
|
||||
return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
|
||||
}
|
||||
|
||||
if (user.locked_until) {
|
||||
const lockEnd = new Date(user.locked_until);
|
||||
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();
|
||||
}
|
||||
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, {
|
||||
|
|
@ -91,7 +92,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (!resolved || !resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
return html(TotpPage({}));
|
||||
return htmlPage(TotpPage({}));
|
||||
});
|
||||
|
||||
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, "");
|
||||
|
||||
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;
|
||||
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 valid = deps.auth.verifyTotpCode(secret, code);
|
||||
if (!valid) {
|
||||
return html(TotpPage({ error: "Invalid code. Try again." }));
|
||||
return htmlPage(TotpPage({ error: "Invalid code. Try again." }));
|
||||
}
|
||||
|
||||
deps.repo.setSessionTotpPending(session.id, false);
|
||||
|
|
@ -137,7 +138,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (!resolved || !resolved.session.totp_pending) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
return html(RecoveryPage({}));
|
||||
return htmlPage(RecoveryPage({}));
|
||||
});
|
||||
|
||||
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, "");
|
||||
|
||||
if (!code) {
|
||||
return html(RecoveryPage({ error: "Enter a recovery code." }));
|
||||
return htmlPage(RecoveryPage({ error: "Enter a recovery code." }));
|
||||
}
|
||||
|
||||
const { user, session } = resolved;
|
||||
|
|
@ -162,7 +163,7 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
|
||||
const result = await deps.auth.consumeRecoveryCode(code, hashedCodes);
|
||||
if (!result.ok) {
|
||||
return html(RecoveryPage({ error: "Invalid recovery code." }));
|
||||
return htmlPage(RecoveryPage({ error: "Invalid recovery code." }));
|
||||
}
|
||||
|
||||
deps.repo.updateUser(user.id, {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* 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 { SetupPage } from "../../web-templates/auth-pages.js";
|
||||
|
||||
|
|
@ -10,7 +11,7 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (deps.repo.isSetupComplete()) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||
}
|
||||
return html(SetupPage({}));
|
||||
return htmlPage(SetupPage({}));
|
||||
});
|
||||
|
||||
app.post("/setup", async (event) => {
|
||||
|
|
@ -33,7 +34,7 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue