Actually ran this for the first time. Three real bugs + two polish items.
1. NextAuth catch-all was eating local-auth routes
src/app/api/auth/[...nextauth]/route.ts is a catch-all that claims
everything under /api/auth/*. When AUTH_SECRET is set (i.e. outside
of DEV_BYPASS_AUTH), NextAuth's handlers absorbed my static
/api/auth/login, /api/auth/change-password etc. routes and returned
404 for them.
→ Moved to /api/local-auth/*. Updated all four client pages to
match. Added /api/local-auth to the middleware's authn-bypass
allow-list alongside /api/auth.
2. XLSX header matcher too greedy on "team"
The HEADER_MATCHERS entry for clientTeamRaw was ["team"], and
findIndex used substring match. That matched "Creative Team Member
Deliverable is Assigned to" (assignee column) BEFORE the literal
"Team" column. Result: client-team values on imported projects were
the assignee names ("gabrielle", "matt", "sergio").
→ Two-pass buildColumnMap: exact equality first (claims the
literal "Team" cell for clientTeamRaw), substring fallback second
(handles the verbose "Creative Team Member…" header for assignee).
Already-claimed columns are excluded from subsequent passes.
3. exceljs hyperlink cells not unwrapped
Project Name cells in the Dow tracker are a mix of plain strings
(for rows Dow edited manually) and exceljs hyperlink objects
(rows auto-linked to the OMG brief — shape `{ text, hyperlink }`).
The old extractColumns only unwrapped richText and formula.result;
hyperlink objects fell through and Zod rejected them with
"projectName: Invalid input". 24 of 27 rows from the real XLSX
failed with this before; now 26/27 pass (the 1 remaining error is
a genuinely missing omgNumber, correctly flagged).
→ Extracted unwrapCell() that handles hyperlink, richText, formula,
error, and Date cells.
4. DEV_BYPASS_AUTH defaulted to "true" in .env.example
Anyone copying .env.example verbatim got a mock session pointing at
the HP-era "dev-user-001" which doesn't exist in the Dow DB,
causing mysterious P2025 errors on user.update. Also leaves the app
wide open — nobody's auth is actually checked.
→ Default to "false" in .env.example with a DANGEROUS warning.
5. layout.tsx metadata description still said "HP CG department"
→ Fixed to "the Dow Jones studio".
Verified end-to-end on a fresh local DB:
- Login as seeded admin ✓
- Forced password change on first login ✓
- XLSX import: 27 rows → 26 created, 1 error (missing omg number) ✓
- 267 deliverables across 5 client teams ✓
- Invited a CLIENT_VIEWER, assigned to Brand team only ✓
- Brand tester sees 1 project; admin sees 18 ✓
- Brand tester gets 403 on POST /api/projects ✓
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 lines
3 KiB
TypeScript
89 lines
3 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
function safeCompare(a: string, b: string): boolean {
|
|
if (a.length !== b.length) return false;
|
|
// Note: Edge runtime may not have crypto.timingSafeEqual,
|
|
// so we use a constant-time comparison loop as fallback
|
|
let result = 0;
|
|
for (let i = 0; i < a.length; i++) {
|
|
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
}
|
|
return result === 0;
|
|
}
|
|
|
|
export function middleware(request: NextRequest) {
|
|
// Dev bypass: skip all auth checks for local testing.
|
|
// Safety: only honoured when Entra ID credentials are NOT configured,
|
|
// preventing accidental bypass in production.
|
|
if (
|
|
process.env.DEV_BYPASS_AUTH === "true" &&
|
|
process.env.NODE_ENV !== "production"
|
|
) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// API key auth: allow external API access with X-API-Key header
|
|
if (pathname.startsWith("/api/") && process.env.API_KEY) {
|
|
const apiKey = request.headers.get("x-api-key");
|
|
if (apiKey && safeCompare(apiKey, process.env.API_KEY)) {
|
|
return NextResponse.next();
|
|
}
|
|
}
|
|
|
|
const isAuthPage =
|
|
pathname.startsWith("/login") ||
|
|
pathname.startsWith("/forgot-password") ||
|
|
pathname.startsWith("/reset-password");
|
|
const isPendingPage = pathname.startsWith("/pending");
|
|
const isApiAuth = pathname.startsWith("/api/auth");
|
|
// Local-auth endpoints live under /api/local-auth/ to avoid colliding with
|
|
// NextAuth's [...nextauth] catch-all. forgot-password and reset-password are
|
|
// accessible without a session; login is obviously pre-session.
|
|
const isLocalAuth = pathname.startsWith("/api/local-auth/");
|
|
const isHealthCheck = pathname === "/api/health";
|
|
// Webhooks are authenticated per-endpoint via HMAC (see /api/webhooks/omg).
|
|
const isWebhook = pathname.startsWith("/api/webhooks/");
|
|
|
|
// Always allow auth API routes, health checks, and webhook receivers
|
|
if (isApiAuth || isLocalAuth || isHealthCheck || isWebhook) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Check for session cookie (Auth.js database sessions)
|
|
const sessionToken =
|
|
request.cookies.get("authjs.session-token")?.value ||
|
|
request.cookies.get("__Secure-authjs.session-token")?.value;
|
|
|
|
const isLoggedIn = !!sessionToken;
|
|
|
|
// Redirect logged-in users away from login page
|
|
if (isAuthPage && isLoggedIn) {
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = "/dashboard";
|
|
return NextResponse.redirect(url);
|
|
}
|
|
|
|
// Allow authenticated users to access the pending page
|
|
if (isPendingPage && isLoggedIn) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Redirect unauthenticated users to login (API routes get 401 JSON instead of redirect)
|
|
if (!isAuthPage && !isLoggedIn) {
|
|
if (pathname.startsWith("/api/")) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = "/login";
|
|
return NextResponse.redirect(url);
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
};
|