diff --git a/next-env.d.ts b/next-env.d.ts
index 20e7bcfb..c4b7818f 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import './.next/dev/types/routes.d.ts';
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/next.config.ts b/next.config.ts
index b0315d54..ef37673a 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -84,11 +84,13 @@ const nextConfig: NextConfig = {
// visible in every screenshot from the iPhone testing pass.
devIndicators: false,
// LAN access from a real iPhone hits the dev server via the Mac's
- // local IP (e.g. 192.168.x.x), not localhost. Next 15 surfaces a
- // warning for cross-origin /_next/* fetches unless we allow-list the
- // origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so
- // any LAN device works without a config edit per network.
- ...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }),
+ // local IP (e.g. 192.168.x.x), not localhost. Next surfaces a warning
+ // and blocks cross-origin /_next/* fetches (incl. HMR) unless we
+ // allow-list the origins explicitly. When HMR is blocked the page
+ // never fully hydrates and form click handlers fall back to native
+ // submits — the symptom that bit us with a hard-coded IP. Wildcards
+ // cover any LAN device without a per-network config edit.
+ ...(isProd ? {} : { allowedDevOrigins: ['192.168.*.*', '10.*.*.*', '172.16.*.*', '172.20.*.*'] }),
// Native/CJS-leaning server-only packages — list here so Next doesn't
// bundle them into the route trace (slower cold start + risk that
// native bindings fail at runtime). Build-auditor C3+M3: socket.io
diff --git a/src/app/api/portal/auth/logout/route.ts b/src/app/api/portal/auth/logout/route.ts
index bb16cb7f..5446e293 100644
--- a/src/app/api/portal/auth/logout/route.ts
+++ b/src/app/api/portal/auth/logout/route.ts
@@ -1,10 +1,13 @@
-import { NextResponse } from 'next/server';
+import { NextResponse, type NextRequest } from 'next/server';
import { PORTAL_COOKIE } from '@/lib/portal/auth';
-import { env } from '@/lib/env';
-export async function POST(): Promise {
- const response = NextResponse.redirect(new URL('/portal/login', env.APP_URL));
+export async function POST(req: NextRequest): Promise {
+ // Build the redirect from the request URL so we stay on whatever host
+ // the user is actually browsing from (localhost, LAN IP, prod domain).
+ // Reading env.APP_URL here used to redirect phone-on-LAN users back
+ // to localhost.
+ const response = NextResponse.redirect(new URL('/portal/login', req.url));
response.cookies.delete(PORTAL_COOKIE);
diff --git a/src/app/api/v1/admin/branding/logo/route.ts b/src/app/api/v1/admin/branding/logo/route.ts
index 5a56d0f6..ec640d6c 100644
--- a/src/app/api/v1/admin/branding/logo/route.ts
+++ b/src/app/api/v1/admin/branding/logo/route.ts
@@ -9,7 +9,6 @@ import {
setPortLogo,
type LogoCrop,
} from '@/lib/services/logo.service';
-import { env } from '@/lib/env';
const MAX_RAW_BYTES = 5 * 1024 * 1024;
@@ -50,14 +49,13 @@ export const GET = withAuth(
if (!file) {
return NextResponse.json({ data: null });
}
- const baseUrl = env.APP_URL.replace(/\/+$/, '');
- // Stream from the public-by-id surface (gated on `category='branding'`)
- // so the URL works as a direct `
` — the authenticated
- // `/api/v1/files//preview` returns JSON, not image bytes.
+ // Path-only — the admin UI renders this as `
` and the
+ // browser resolves against the current origin. Stays valid whether
+ // the admin opens the page from localhost or a LAN IP.
return NextResponse.json({
data: {
fileId: file.id,
- previewUrl: `${baseUrl}/api/public/files/${file.id}`,
+ previewUrl: `/api/public/files/${file.id}`,
sizeBytes: file.sizeBytes,
mimeType: file.mimeType,
},
@@ -95,11 +93,10 @@ export const POST = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
- const baseUrl = env.APP_URL.replace(/\/+$/, '');
return NextResponse.json({
data: {
fileId: result.fileId,
- previewUrl: `${baseUrl}/api/public/files/${result.fileId}`,
+ previewUrl: `/api/public/files/${result.fileId}`,
warnings: result.warnings,
finalDimensions: processed.finalDimensions,
finalBytes: processed.finalBytes,
diff --git a/src/app/api/v1/admin/settings/image/route.ts b/src/app/api/v1/admin/settings/image/route.ts
index 283dc94d..83f4186c 100644
--- a/src/app/api/v1/admin/settings/image/route.ts
+++ b/src/app/api/v1/admin/settings/image/route.ts
@@ -6,7 +6,6 @@ import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { uploadFile } from '@/lib/services/files';
import { errorResponse, ValidationError } from '@/lib/errors';
-import { env } from '@/lib/env';
const MAX_BYTES = 5 * 1024 * 1024;
@@ -77,11 +76,11 @@ export const POST = withAuth(
},
);
- const baseUrl = env.APP_URL.replace(/\/+$/, '');
- // Branding assets must survive in email-inbox land where no session
- // cookie travels — route through the public-by-id surface gated on
- // `category='branding'` rather than the authenticated preview path.
- const url = `${baseUrl}/api/public/files/${record.id}`;
+ // Path-only so the in-app `
` resolves against whatever
+ // host the page was loaded from (localhost, LAN IP, prod domain).
+ // Email shell calls `absolutizeBrandingUrl()` to prepend APP_URL
+ // for mail clients, which have no origin context.
+ const url = `/api/public/files/${record.id}`;
return NextResponse.json({ data: { fileId: record.id, url } });
} catch (error) {
diff --git a/src/lib/branding/url.ts b/src/lib/branding/url.ts
new file mode 100644
index 00000000..74cbc8b5
--- /dev/null
+++ b/src/lib/branding/url.ts
@@ -0,0 +1,52 @@
+import { env } from '@/lib/env';
+
+const LOCAL_HOST_PATTERNS = [
+ /^localhost(:\d+)?$/i,
+ /^127\.\d+\.\d+\.\d+(:\d+)?$/,
+ /^0\.0\.0\.0(:\d+)?$/,
+ /^192\.168\.\d+\.\d+(:\d+)?$/,
+ /^10\.\d+\.\d+\.\d+(:\d+)?$/,
+ /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/,
+];
+
+/**
+ * Strip the scheme+host from a branding URL when the host is localhost
+ * or a private LAN address, leaving a path-only URL the browser can
+ * resolve against whatever origin it loaded the page from.
+ *
+ * Without this, a logo uploaded while the app ran at http://localhost:3000
+ * is forever pinned to that host — fine for the dev's Mac, broken from
+ * a phone on the LAN or any device with a different DNS view.
+ *
+ * Public-internet URLs (CDN, S3) pass through unchanged.
+ */
+export function normalizeBrandingUrl(url: string | null | undefined): string | null {
+ if (!url) return null;
+ const trimmed = url.trim();
+ if (!trimmed) return null;
+ if (trimmed.startsWith('/')) return trimmed;
+ try {
+ const parsed = new URL(trimmed);
+ const isLocal = LOCAL_HOST_PATTERNS.some((re) => re.test(parsed.host));
+ if (!isLocal) return trimmed;
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+ } catch {
+ return trimmed;
+ }
+}
+
+/**
+ * Email surfaces (rendered HTML inboxes) cannot resolve path-only URLs —
+ * the recipient's mail client has no origin context. Use this when
+ * emitting branding into an email shell to guarantee an absolute URL.
+ *
+ * Pass-through for URLs that are already absolute.
+ */
+export function absolutizeBrandingUrl(url: string | null | undefined): string | null {
+ if (!url) return null;
+ const trimmed = url.trim();
+ if (!trimmed) return null;
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
+ const base = env.APP_URL.replace(/\/+$/, '');
+ return `${base}${trimmed.startsWith('/') ? '' : '/'}${trimmed}`;
+}
diff --git a/src/lib/email/shell.ts b/src/lib/email/shell.ts
index 44f162f8..86f16c60 100644
--- a/src/lib/email/shell.ts
+++ b/src/lib/email/shell.ts
@@ -16,6 +16,8 @@
* function. Templates call `renderShell({ title, body, branding })`.
*/
+import { absolutizeBrandingUrl } from '@/lib/branding/url';
+
// Neutral defaults — no tenant-specific imagery leaks across ports.
// When branding hasn't been configured the email renders without a logo
// and on a plain off-white background. Admins upload their own assets via
@@ -42,8 +44,10 @@ interface ShellOpts {
}
export function renderShell({ title, body, branding }: ShellOpts): string {
- const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
- const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL;
+ // Branding URLs are stored path-only (so in-app rendering works across
+ // any host). Mail clients have no app origin, so re-absolutize here.
+ const logoUrl = absolutizeBrandingUrl(branding?.logoUrl ?? DEFAULT_LOGO_URL);
+ const backgroundUrl = absolutizeBrandingUrl(branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL);
const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? '';
diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts
index f4820032..7b17d164 100644
--- a/src/lib/services/port-config.ts
+++ b/src/lib/services/port-config.ts
@@ -8,6 +8,7 @@
* env var when neither is set.
*/
import { env } from '@/lib/env';
+import { normalizeBrandingUrl } from '@/lib/branding/url';
import { getSetting } from '@/lib/services/settings.service';
// ─── Setting key constants ───────────────────────────────────────────────────
@@ -572,8 +573,14 @@ export async function getPortBrandingConfig(portId: string): Promise | null = null;
+const DEV_ORIGIN_PATTERNS = [
+ /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/,
+ /^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
+ /^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/,
+ /^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/,
+];
+
+function socketCorsOrigin(
+ origin: string | undefined,
+ cb: (err: Error | null, allow?: boolean) => void,
+): void {
+ if (!origin) return cb(null, true);
+ if (process.env.NODE_ENV === 'production') {
+ return cb(null, origin === process.env.APP_URL);
+ }
+ cb(
+ null,
+ DEV_ORIGIN_PATTERNS.some((re) => re.test(origin)),
+ );
+}
+
/**
* Returns true if the user is a super-admin OR holds a userPortRoles row
* for the given portId. The Socket.IO auth middleware uses this to decide
@@ -77,7 +98,11 @@ export function initSocketServer(
path: '/socket.io/',
adapter: createAdapter(pubClient, subClient),
cors: {
- origin: process.env.APP_URL,
+ // In prod, lock to the canonical APP_URL. In dev, allow localhost
+ // + private-LAN origins so the same dev server serves the Mac
+ // (localhost) and a phone on Wi-Fi (192.168.x.x) without a config
+ // edit per network. Mirrors the trustedOrigins pattern in auth.
+ origin: socketCorsOrigin,
credentials: true,
},
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
diff --git a/src/providers/socket-provider.tsx b/src/providers/socket-provider.tsx
index 07e6873e..c925ec0e 100644
--- a/src/providers/socket-provider.tsx
+++ b/src/providers/socket-provider.tsx
@@ -67,7 +67,12 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
return;
}
- const s = io(process.env.NEXT_PUBLIC_APP_URL!, {
+ // Connect to whatever origin the page was loaded from — `io()` with
+ // no URL defaults to window.location. This used to read
+ // NEXT_PUBLIC_APP_URL, which baked the deploy-time canonical URL
+ // (localhost in dev) and broke realtime when the same dev server
+ // was hit from a LAN IP.
+ const s = io({
path: '/socket.io/',
withCredentials: true,
auth: { portId: currentPortId },