fix(scan): /scan had no way to log in, and blocked LAN access was stale

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.
This commit is contained in:
Dave 2026-10-04 14:44:48 +02:00
parent 80608602ad
commit 7b844b2e51
4 changed files with 95 additions and 15 deletions

View file

@ -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`).

View file

@ -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 (
<div className="flex min-h-dvh items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-[3px] border-primary border-t-transparent" />
</div>
);
}
if (!user) return null;
return <>{children}</>;
}

View file

@ -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 <ScanAuthGate>{children}</ScanAuthGate>;
}

View file

@ -361,11 +361,13 @@ export default function ScanPage() {
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<QrCode className="h-10 w-10 text-white/60" />
<p className="text-sm text-white/80">
{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"}
</p>
</div>
) : null}
@ -567,9 +569,17 @@ export default function ScanPage() {
) : (
<section className="flex flex-col gap-4">
{support && !support.secureContext ? (
<p className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
This page is not on HTTPS, so the browser blocks the camera. Use the office
network&apos;s secure address, or type the number below.
<p
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900"
data-testid="scan-insecure-warning"
>
<strong>Camera is unavailable on this address.</strong> Browsers only expose
a camera over HTTPS, and this page is on plain HTTP &mdash; so the scanner
will not run here even though the phone has a camera. Typing the delivery
note number below still works.
<br />
To scan for real, open the app on its <code>https://</code> address, or use a
secure tunnel to this dev server.
</p>
) : null}