From 3ebbe514e0def026ebc97a6f68fe4b700645408a Mon Sep 17 00:00:00 2001 From: Scott Schroeder <56617491+scruffy359@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:06:45 -0400 Subject: [PATCH 1/6] Upgrade pnpm version and lockdown Node/pnpm versions. (#1) - upgrade PNPM support to latest version (11.5.2) - fixes build warning [WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.onlyBuiltDependencies". See https://pnpm.io/settings for the new home of each setting. - add package.json engine settings to ensure NODE and PNPM versions are consistent between local dev and docker. - add .nvmrc in case developer is using NVM. - add .node-version file. - lock down pnpm version in docker. --- .node-version | 1 + .nvmrc | 1 + Dockerfile | 4 ++-- package.json | 12 +++++------- pnpm-workspace.yaml | 4 ++++ 5 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 .node-version create mode 100644 .nvmrc create mode 100644 pnpm-workspace.yaml diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..941d7c07 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.22.3 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..6bb90051 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v22.22.3 diff --git a/Dockerfile b/Dockerfile index 0601f69e..1be8e568 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,12 +12,12 @@ ENV NEXT_TELEMETRY_DISABLED=1 WORKDIR /app -RUN corepack enable pnpm +RUN corepack enable pnpm && corepack prepare pnpm@11.5.2 --activate # Install dependencies only when needed FROM base AS deps -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile --prod=false \ && rm -rf ~/.npm ~/.pnpm-store /root/.cache diff --git a/package.json b/package.json index cd96c3e7..d04b0e45 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "byos-nextjs", "version": "0.2.2", "private": true, + "engines": { + "node": "22.22.3", + "pnpm": "11.5.2" + }, "scripts": { "screenshots": "node scripts/take-screenshots.mjs", "generate:sql": "node scripts/generate-sql-statements.js && pnpm format", @@ -90,11 +94,5 @@ "tailwindcss": "^4.2.4", "typescript": "^6.0.3" }, - "pnpm": { - "onlyBuiltDependencies": [ - "@biomejs/biome", - "@tailwindcss/oxide", - "unrs-resolver" - ] - } + "packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..54b065b8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + msw: true + sharp: true + unrs-resolver: true From 02cf307aa01fccdefe7c5713b9463eaa7255a080 Mon Sep 17 00:00:00 2001 From: scruffy359 <56617491+scruffy359@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:13:49 -0400 Subject: [PATCH 2/6] Fix (sign-in/sign-out): infinite redirect loop and Signing In button after Sign Out. - fix infinite redirect between setup/signin when AUTH_ENABLED=true. add missing `await connection()`. - use browser navigation instead of push/refresh as refresh will use cached useState. --- app/(auth)/sign-in/page.tsx | 3 +++ app/(auth)/sign-in/sign-in-form.tsx | 20 ++++++++++---------- app/layout.tsx | 16 ++-------------- components/nav-user.tsx | 6 ++---- lib/auth/auth.ts | 2 ++ lib/utils.ts | 12 ++++++++++++ 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index 777e187e..7651ee47 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { connection } from "next/server"; import { Suspense } from "react"; import { getDatabaseSetupStatus } from "@/lib/database/utils"; import SignInForm from "./sign-in-form"; @@ -6,6 +7,7 @@ import SignInForm from "./sign-in-form"; // Next.js Cache Components require that uncached data fetches live inside a // boundary, so the DB probe is isolated to a child component. async function SignInWithDbCheck() { + await connection(); const setup = await getDatabaseSetupStatus(); if (setup.needsSetup) { redirect("/setup"); @@ -15,6 +17,7 @@ async function SignInWithDbCheck() { } export default function SignInPage() { + console.log({ where: "SignInPage" }); return ( }> diff --git a/app/(auth)/sign-in/sign-in-form.tsx b/app/(auth)/sign-in/sign-in-form.tsx index da74c87a..e71296ab 100644 --- a/app/(auth)/sign-in/sign-in-form.tsx +++ b/app/(auth)/sign-in/sign-in-form.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -21,23 +20,24 @@ interface SignInFormProps { } export default function SignInForm({ dbReady, dbError }: SignInFormProps) { - const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.SubmitEvent) => { e.preventDefault(); if (!dbReady) return; setError(""); setIsLoading(true); try { - const { data, error: authError } = await authClient.signIn.email({ - email, - password, - }); + const { data: session, error: authError } = await authClient.signIn.email( + { + email, + password, + }, + ); if (authError) { setError(authError.message || "Failed to sign in. Please try again."); @@ -45,9 +45,9 @@ export default function SignInForm({ dbReady, dbError }: SignInFormProps) { return; } - if (data) { - router.push("/"); - router.refresh(); + if (session) { + // explicitly use brower navigation to force root ("/") page reload. + window.location.href = "/"; } } catch (_err) { setError("An unexpected error occurred. Please try again."); diff --git a/app/layout.tsx b/app/layout.tsx index ca96fcc3..e491fd7b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,31 +4,19 @@ import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { getAllFontVariables } from "@/lib/fonts"; -import { cn } from "@/lib/utils"; +import { cn, getAppBaseUrl } from "@/lib/utils"; const META_THEME_COLORS = { light: "#ffffff", dark: "#09090b", }; -function getMetadataBase(): URL { - const raw = process.env.NEXT_PUBLIC_BASE_URL?.trim(); - if (raw) { - try { - return new URL(raw); - } catch { - // fall through to the default below - } - } - return new URL("http://localhost:3000"); -} - const APP_NAME = "TRMNL BYOS"; const APP_DESCRIPTION = "Self-hosted server and device management dashboard for TRMNL e-ink displays."; export const metadata: Metadata = { - metadataBase: getMetadataBase(), + metadataBase: getAppBaseUrl(), title: { default: APP_NAME, template: `%s · ${APP_NAME}`, diff --git a/components/nav-user.tsx b/components/nav-user.tsx index 6ce7fbe9..5ed74b11 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -10,7 +10,6 @@ import { } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, @@ -41,7 +40,6 @@ interface NavUserProps { export function NavUser({ user }: NavUserProps) { const { isMobile } = useSidebar(); - const router = useRouter(); const getUserInitials = () => { if (user.name) { @@ -59,8 +57,8 @@ export function NavUser({ user }: NavUserProps) { const handleSignOut = async () => { await authClient.signOut(); - router.push("/sign-in"); - router.refresh(); + // explicitly use brower navigation to force sign-in page reload. + window.location.href = "/sign-in"; }; return ( diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index 6316c9ae..ce3be8f5 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -2,6 +2,7 @@ import { betterAuth } from "better-auth"; import { admin } from "better-auth/plugins"; import { Pool } from "pg"; import { sendEmail } from "@/lib/email"; +import { getAppBaseUrl } from "../utils"; const AUTH_ENABLED = process.env.AUTH_ENABLED !== "false"; const BYOS_MONO_USER_ID = "byos_mono_user"; @@ -52,6 +53,7 @@ function createAuth() { } return betterAuth({ + baseURL: getAppBaseUrl().toString(), database: pool, emailAndPassword: { enabled: true, diff --git a/lib/utils.ts b/lib/utils.ts index ac680b30..c0ff0194 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function getAppBaseUrl(): URL { + const raw = process.env.NEXT_PUBLIC_BASE_URL?.trim(); + if (raw) { + try { + return new URL(raw); + } catch { + // fall through to the default below + } + } + return new URL("http://localhost:3000"); +} From 0221dbe1ae30bb5bb569080ab9bb071518bac288 Mon Sep 17 00:00:00 2001 From: scruffy359 <56617491+scruffy359@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:15:32 -0400 Subject: [PATCH 3/6] Fix (sign-in/sign-out): infinite redirect loop and Signing In button after Sign Out. - fix infinite redirect between setup/signin when AUTH_ENABLED=true. add missing `await connection()`. - use browser navigation instead of push/refresh as refresh will use cached useState. --- app/(auth)/sign-in/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index 7651ee47..2cd3308a 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -17,7 +17,6 @@ async function SignInWithDbCheck() { } export default function SignInPage() { - console.log({ where: "SignInPage" }); return ( }> From fc4a6e60938de3988682cd89f74563b830f9cde3 Mon Sep 17 00:00:00 2001 From: scruffy359 <56617491+scruffy359@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:27:16 -0400 Subject: [PATCH 4/6] Fix (sign-in/sign-out): infinite redirect loop and Signing In button after Sign Out. - fix infinite redirect between setup/signin when AUTH_ENABLED=true. add missing `await connection()`. - use browser navigation instead of push/refresh as refresh will use cached useState. --- app/(auth)/sign-in/sign-in-form.tsx | 2 +- components/nav-user.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(auth)/sign-in/sign-in-form.tsx b/app/(auth)/sign-in/sign-in-form.tsx index e71296ab..f49f31b8 100644 --- a/app/(auth)/sign-in/sign-in-form.tsx +++ b/app/(auth)/sign-in/sign-in-form.tsx @@ -46,7 +46,7 @@ export default function SignInForm({ dbReady, dbError }: SignInFormProps) { } if (session) { - // explicitly use brower navigation to force root ("/") page reload. + // explicitly use browser navigation to force root ("/") page reload. window.location.href = "/"; } } catch (_err) { diff --git a/components/nav-user.tsx b/components/nav-user.tsx index 5ed74b11..7ea279cb 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -57,7 +57,7 @@ export function NavUser({ user }: NavUserProps) { const handleSignOut = async () => { await authClient.signOut(); - // explicitly use brower navigation to force sign-in page reload. + // explicitly use browser navigation to force sign-in page reload. window.location.href = "/sign-in"; }; From 32e5b5606b6be1c47d3ee678f205441fce885d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bouteiller?= Date: Sun, 28 Jun 2026 20:28:20 +0300 Subject: [PATCH 5/6] fix(auth): runtime base URL + sign-up/sign-out navigation hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve the app base URL at runtime via getAppBaseUrl() (BETTER_AUTH_URL > Vercel URL > localhost) and pass it to better-auth as origin; drop build-time-only NEXT_PUBLIC_BASE_URL (all consumers are server-side) — the browser renderer now targets the loopback origin - apply the sign-in navigation fix to sign-up; use location.replace() on sign-out so Back can't reach an authenticated page - type the sign-up submit handler as React.SubmitEvent (FormEvent is deprecated) BREAKING CHANGE: NEXT_PUBLIC_BASE_URL is no longer read. Set BETTER_AUTH_URL or rely on Vercel auto-detection. --- .env.example | 10 ++++------ app/(auth)/sign-in/sign-in-form.tsx | 13 +++++-------- app/(auth)/sign-up/sign-up-form.tsx | 8 ++------ components/nav-user.tsx | 3 +-- lib/auth/auth.ts | 2 +- lib/recipes/renderers/browser.ts | 3 +-- lib/utils.ts | 16 +++++++++++----- 7 files changed, 25 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index 75cc9515..67135dff 100644 --- a/.env.example +++ b/.env.example @@ -3,12 +3,6 @@ # Format: postgres://[user]:[password]@[host]:[port]/[database]?[options] DATABASE_URL=postgres://postgres:password@localhost:5432/byos_db?sslmode=disable -# Public Base URL (Optional) -# Absolute URL where this instance is reachable, e.g. https://byos.example.com -# Used for absolute metadata/Open Graph URLs and the browser renderer. -# Defaults to http://localhost:3000 when unset. -NEXT_PUBLIC_BASE_URL= - # React Renderer Configuration # Options: "takumi" (default) or "satori" or "browser" # Controls which rendering engine is used for screen generation @@ -34,6 +28,10 @@ FORCE_WIKIPEDIA_RESERVOIR=false # Set to "false" to disable authentication (mono-user mode) AUTH_ENABLED=true BETTER_AUTH_SECRET=your_better_auth_secret_32_characters_min +# Public URL of this instance, used by better-auth (origin checks, email links) +# and for absolute metadata/Open Graph URLs. Auto-detected on Vercel; otherwise +# defaults to http://localhost:3000. +BETTER_AUTH_URL= # The first account created during setup becomes admin automatically. # TRMNL Live Proxy (Optional) diff --git a/app/(auth)/sign-in/sign-in-form.tsx b/app/(auth)/sign-in/sign-in-form.tsx index f49f31b8..c03d9e67 100644 --- a/app/(auth)/sign-in/sign-in-form.tsx +++ b/app/(auth)/sign-in/sign-in-form.tsx @@ -32,12 +32,10 @@ export default function SignInForm({ dbReady, dbError }: SignInFormProps) { setIsLoading(true); try { - const { data: session, error: authError } = await authClient.signIn.email( - { - email, - password, - }, - ); + const { data, error: authError } = await authClient.signIn.email({ + email, + password, + }); if (authError) { setError(authError.message || "Failed to sign in. Please try again."); @@ -45,8 +43,7 @@ export default function SignInForm({ dbReady, dbError }: SignInFormProps) { return; } - if (session) { - // explicitly use browser navigation to force root ("/") page reload. + if (data) { window.location.href = "/"; } } catch (_err) { diff --git a/app/(auth)/sign-up/sign-up-form.tsx b/app/(auth)/sign-up/sign-up-form.tsx index d83dd795..edfe10e7 100644 --- a/app/(auth)/sign-up/sign-up-form.tsx +++ b/app/(auth)/sign-up/sign-up-form.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -16,7 +15,6 @@ import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth/auth-client"; export default function SignUpForm() { - const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -24,7 +22,7 @@ export default function SignUpForm() { const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.SubmitEvent) => { e.preventDefault(); setError(""); @@ -56,9 +54,7 @@ export default function SignUpForm() { } if (data) { - // Redirect to home page on successful sign up - router.push("/"); - router.refresh(); + window.location.href = "/"; } } catch (_err) { setError("An unexpected error occurred. Please try again."); diff --git a/components/nav-user.tsx b/components/nav-user.tsx index 7ea279cb..04a04aee 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -57,8 +57,7 @@ export function NavUser({ user }: NavUserProps) { const handleSignOut = async () => { await authClient.signOut(); - // explicitly use browser navigation to force sign-in page reload. - window.location.href = "/sign-in"; + window.location.replace("/sign-in"); }; return ( diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index ce3be8f5..6f2ba898 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -53,7 +53,7 @@ function createAuth() { } return betterAuth({ - baseURL: getAppBaseUrl().toString(), + baseURL: getAppBaseUrl().origin, database: pool, emailAndPassword: { enabled: true, diff --git a/lib/recipes/renderers/browser.ts b/lib/recipes/renderers/browser.ts index c0be392e..43f1448d 100644 --- a/lib/recipes/renderers/browser.ts +++ b/lib/recipes/renderers/browser.ts @@ -53,8 +53,7 @@ export async function renderWithBrowser( options: RenderWithBrowserOptions = {}, ): Promise { const port = process.env.PORT || 3000; - const baseUrl = - process.env.NEXT_PUBLIC_BASE_URL ?? `http://127.0.0.1:${port}`; + const baseUrl = `http://127.0.0.1:${port}`; const params = new URLSearchParams({ width: String(width), height: String(height), diff --git a/lib/utils.ts b/lib/utils.ts index c0ff0194..64a2a876 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -6,13 +6,19 @@ export function cn(...inputs: ClassValue[]) { } export function getAppBaseUrl(): URL { - const raw = process.env.NEXT_PUBLIC_BASE_URL?.trim(); - if (raw) { - try { + const candidates: Array = [ + process.env.BETTER_AUTH_URL, + process.env.VERCEL_PROJECT_PRODUCTION_URL && + `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`, + process.env.VERCEL_URL && `https://${process.env.VERCEL_URL}`, + ]; + + for (const candidate of candidates) { + const raw = candidate?.trim(); + if (raw && URL.canParse(raw)) { return new URL(raw); - } catch { - // fall through to the default below } } + return new URL("http://localhost:3000"); } From 0745aef03017078d9adbc0e4ba71908e7a02ab6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bouteiller?= Date: Sun, 28 Jun 2026 20:30:28 +0300 Subject: [PATCH 6/6] refactor: replace deprecated React.FormEvent with precise event types React.FormEvent is deprecated in @types/react. Use React.SubmitEvent for form onSubmit handlers and React.SyntheticEvent where a handler is shared between a form submit and a button click. --- app/(app)/device/[friendly_id]/client-page.tsx | 2 +- app/(auth)/recover/page.tsx | 4 ++-- components/device/device-edit-form.tsx | 2 +- components/recipes/screen-params-form.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/(app)/device/[friendly_id]/client-page.tsx b/app/(app)/device/[friendly_id]/client-page.tsx index aa66c33c..f2fb9954 100644 --- a/app/(app)/device/[friendly_id]/client-page.tsx +++ b/app/(app)/device/[friendly_id]/client-page.tsx @@ -260,7 +260,7 @@ export default function DeviceClientPage({ }; // Handle form submission - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); // Validate API key diff --git a/app/(auth)/recover/page.tsx b/app/(auth)/recover/page.tsx index d756d3bc..ad14b2cc 100644 --- a/app/(auth)/recover/page.tsx +++ b/app/(auth)/recover/page.tsx @@ -45,7 +45,7 @@ function RecoverPageContent() { } }, [error]); - const handleRequestReset = async (e: React.FormEvent) => { + const handleRequestReset = async (e: React.SubmitEvent) => { e.preventDefault(); setErrorMessage(""); setSuccessMessage(""); @@ -77,7 +77,7 @@ function RecoverPageContent() { } }; - const handleResetPassword = async (e: React.FormEvent) => { + const handleResetPassword = async (e: React.SubmitEvent) => { e.preventDefault(); setErrorMessage(""); setSuccessMessage(""); diff --git a/components/device/device-edit-form.tsx b/components/device/device-edit-form.tsx index aceac6b8..c066f825 100644 --- a/components/device/device-edit-form.tsx +++ b/components/device/device-edit-form.tsx @@ -75,7 +75,7 @@ interface DeviceEditFormProps { onRegenerateFriendlyId: () => void; onAddTimeRange: () => void; onRemoveTimeRange: (index: number) => void; - onSubmit: (e: React.FormEvent) => void; + onSubmit: (e: React.SubmitEvent) => void; onCancel: () => void; } diff --git a/components/recipes/screen-params-form.tsx b/components/recipes/screen-params-form.tsx index 7725ec18..1e0b74ce 100644 --- a/components/recipes/screen-params-form.tsx +++ b/components/recipes/screen-params-form.tsx @@ -132,7 +132,7 @@ export function ScreenParamsForm({ [values, initial], ); - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = (event: React.SubmitEvent) => { event.preventDefault(); setFormStatus("idle"); setStatusMessage("");