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:
parent
80608602ad
commit
7b844b2e51
4 changed files with 95 additions and 15 deletions
|
|
@ -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`).
|
||||
|
|
|
|||
49
src/app/scan/auth-gate.tsx
Normal file
49
src/app/scan/auth-gate.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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 — 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}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue