From 7b844b2e518aa78b73a9bb54d6d8ce69cf9f78b0 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 4 Oct 2026 14:44:48 +0200 Subject: [PATCH] fix(scan): /scan had no way to log in, and blocked LAN access was stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate reasons the phone could not use the scanner, only one of which is about auth. 1. NO LOGIN PATH. `(app)/layout.tsx` is the app's auth gate — it redirects an unauthenticated user to /login. /scan is a separate route group and inherits only the ROOT layout, which provides `AuthProvider` — and `AuthProvider` merely EXPOSES the session, it never renders a login form. So /scan rendered the scanner for anyone, the first API call came back "Not authenticated.", and there was no way forward from the page: the operator had to guess that /login existed somewhere else in the app. Now gated to `/login?next=/scan`, with the path run through `isSafeNextPath`. The gate lives in its own client component because `metadata` cannot be exported from a "use client" module — caught by loading the page, not by tsc. 2. THE LAN ENTRY WAS BLOCKED, and the allowlist could never have worked. `allowedDevOrigins` is matched by `isCsrfOriginAllowed`, which splits the host on "." and compares label by label, treating `*` as exactly ONE label. Checked against Next 16.2.6: isCsrfOriginAllowed("192.168.1.35", ["*"]) -> false isCsrfOriginAllowed("192.168.1.35", ["192.168.*"]) -> false isCsrfOriginAllowed("192.168.1.35", ["192.168.1.*"]) -> true So the `"*"` that had been sitting in the list for months did nothing, and the list hard-coded three individual LAN addresses (.42, .122, .128) that go stale whenever the router hands out a new DHCP lease. When the lease moved to .35 the dev server refused the phone's dev resources and the page hung on the spinner forever. Now wildcards the last label of the subnet. Also separates the two failure states the UI conflated: "Camera blocked — this page is not on HTTPS" is now distinct from "this device has no camera", because on a phone over plain HTTP those look identical and the first is the one you can actually fix. --- next.config.ts | 26 ++++++++++++++++---- src/app/scan/auth-gate.tsx | 49 ++++++++++++++++++++++++++++++++++++++ src/app/scan/layout.tsx | 9 +++++-- src/app/scan/page.tsx | 26 +++++++++++++------- 4 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 src/app/scan/auth-gate.tsx diff --git a/next.config.ts b/next.config.ts index 86d29ea..d626a76 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,11 +10,27 @@ const nextConfig: NextConfig = { // through webpack's loader — so pdfjs-dist's init runs in a clean CJS // context, the way its author expects. serverExternalPackages: ["pdf-parse", "pdfjs-dist", "@napi-rs/canvas"], - // Dev server blocks cross-origin HMR requests by default in Next 16. The - // app is normally loaded on `localhost`, but we also load it on - // `127.0.0.1` (and on the LAN IP for phone testing). Listing both keeps - // the HMR client from hanging on the first load. - allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.42", "192.168.1.122", "192.168.1.128", "coolify.mywire.org", "*"], + // Dev server blocks cross-origin requests to its own dev resources by + // default in Next 16, which shows up as a page stuck on the loading + // spinner when you open the app from your phone on the LAN. + // + // IMPORTANT — this list is matched with `isCsrfOriginAllowed`, which + // splits the host on "." and compares label by label, treating `*` as + // exactly ONE label. Two consequences, both learned the hard way: + // + // - A bare "*" is useless. It does not mean "any host" — checked + // against Next 16.2.6, isCsrfOriginAllowed("192.168.1.35", ["*"]) + // is false. It was in this list for months doing nothing. + // - "192.168.*" is also useless for the same reason: it is two + // labels against four. + // - "192.168.1.*" DOES work, and survives the router handing out a + // different address on a new DHCP lease. The previous version + // hard-coded individual IPs (.42, .122, .128); when the lease + // moved to .35 the phone silently stopped loading the app. + // + // So: wildcard the last label of the LAN subnet, keep the real + // hostname, and leave the stale per-address entries out. + allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.*", "coolify.mywire.org"], // The agent (and any external tools) sometimes reach for the PocketBase // collection name verbatim — `order_items`, `stock_items`, `quote_items` // — and get a 404 because the route folders use hyphens (`order-items`). diff --git a/src/app/scan/auth-gate.tsx b/src/app/scan/auth-gate.tsx new file mode 100644 index 0000000..1b9f5c8 --- /dev/null +++ b/src/app/scan/auth-gate.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useAuth } from "@/components/auth-provider"; +import { isSafeNextPath } from "@/lib/safe-next-path"; + +/** + * Auth gate for the standalone /scan PWA. + * + * `(app)/layout.tsx` is the app's auth gate — it redirects an + * unauthenticated user to /login. /scan is a separate route group and + * inherits only the ROOT layout, which provides `AuthProvider` — and + * `AuthProvider` merely EXPOSES the session, it never renders a login + * form. So without this gate the page rendered the scanner for anyone + * and the first API call came back "Not authenticated." with no way + * forward from the page: the operator had to guess that `/login` + * existed somewhere else in the app. + * + * `/login?next=/scan` brings them straight back here after signing in; + * `(auth)/login/page.tsx` already honours `next`, and the value is run + * through `isSafeNextPath` so it can only ever be a same-origin path. + * + * Lives in its own file rather than in `layout.tsx` because `metadata` + * cannot be exported from a "use client" module. + */ +export function ScanAuthGate({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !user) { + const next = isSafeNextPath("/scan") ? "/scan" : "/dashboard"; + router.push(`/login?next=${encodeURIComponent(next)}`); + } + }, [user, loading, router]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + return <>{children}; +} diff --git a/src/app/scan/layout.tsx b/src/app/scan/layout.tsx index 58818d0..e9639ec 100644 --- a/src/app/scan/layout.tsx +++ b/src/app/scan/layout.tsx @@ -1,10 +1,15 @@ import type { Metadata, Viewport } from "next"; +import { ScanAuthGate } from "./auth-gate"; /** * /scan is its own route group (outside `(app)`) so it gets a * dedicated PWA manifest — "add to home screen" should give the - * operator a scanner, not the full CRM. Mirrors the pattern already + * operator a scanner, not the CRM. Mirrors the pattern already * used by `(counter)` for `/manifest-counter.json`. + * + * Stays a Server Component because `metadata` cannot be exported + * from a `"use client"` module. The auth gate is a separate client + * component — see `./auth-gate.tsx` for why it is needed at all. */ export const metadata: Metadata = { title: "DN Scanner · WFDASH", @@ -20,5 +25,5 @@ export const viewport: Viewport = { }; export default function ScanLayout({ children }: { children: React.ReactNode }) { - return children; + return {children}; } diff --git a/src/app/scan/page.tsx b/src/app/scan/page.tsx index ea986f1..50efc5d 100644 --- a/src/app/scan/page.tsx +++ b/src/app/scan/page.tsx @@ -361,11 +361,13 @@ export default function ScanPage() {

- {support?.camera - ? note - ? "Camera off — press Start camera to scan the next note" - : "Camera is off" - : "This browser will not give the page a camera"} + {support?.secureContext === false + ? "Camera blocked — this page is not on HTTPS" + : support?.camera + ? note + ? "Camera off — press Start camera to scan the next note" + : "Camera is off" + : "This device has no camera the browser will share"}

) : null} @@ -567,9 +569,17 @@ export default function ScanPage() { ) : (
{support && !support.secureContext ? ( -

- This page is not on HTTPS, so the browser blocks the camera. Use the office - network's secure address, or type the number below. +

+ Camera is unavailable on this address. Browsers only expose + a camera over HTTPS, and this page is on plain HTTP — so the scanner + will not run here even though the phone has a camera. Typing the delivery + note number below still works. +
+ To scan for real, open the app on its https:// address, or use a + secure tunnel to this dev server.

) : null}