Smoke-test fixes: routing collision + XLSX parser + defaults
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>
This commit is contained in:
parent
a4107ae23d
commit
fe3f91c7ef
12 changed files with 77 additions and 20 deletions
|
|
@ -14,9 +14,11 @@ AZURE_REDIRECT_URI=""
|
|||
# No client secret — SPA registrations use PKCE in the browser (no AUTH_URL needed)
|
||||
|
||||
# ─── Dev Auth Bypass (local development only) ───────────
|
||||
# Set to "true" to skip SSO and auto-login as dev admin user.
|
||||
# Ignored when NODE_ENV=production.
|
||||
DEV_BYPASS_AUTH="true"
|
||||
# Set to "true" to skip all auth and auto-login as the DEV_USER_ID user.
|
||||
# DANGEROUS — leaves the app wide open. Ignored in production.
|
||||
# Default is "false" so the real local-auth flow is exercised on first
|
||||
# run (log in as the seed admin — see DEPLOY.md / seed-dow.ts).
|
||||
DEV_BYPASS_AUTH="false"
|
||||
DEV_USER_ID="dev-user-001"
|
||||
|
||||
# ─── App ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default function ChangePasswordPage() {
|
|||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`${BASE_PATH}/api/auth/change-password`, {
|
||||
const res = await fetch(`${BASE_PATH}/api/local-auth/change-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default function ForgotPasswordPage() {
|
|||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_PATH}/api/auth/forgot-password`, {
|
||||
const res = await fetch(`${BASE_PATH}/api/local-auth/forgot-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function CredentialsLogin() {
|
|||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_PATH}/api/auth/login`, {
|
||||
const res = await fetch(`${BASE_PATH}/api/local-auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default function ResetPasswordPage({ params }: Props) {
|
|||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`${BASE_PATH}/api/auth/reset-password`, {
|
||||
const res = await fetch(`${BASE_PATH}/api/local-auth/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const jetbrainsMono = JetBrains_Mono({
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dow Jones Studio Tracker",
|
||||
description: "Production pipeline tracker for HP CG department",
|
||||
description: "Production pipeline tracker for the Dow Jones studio",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -396,12 +396,40 @@ function buildColumnMap(
|
|||
): Partial<Record<keyof Omit<ParsedRow, "_rowNumber">, number>> {
|
||||
const map: Partial<Record<keyof Omit<ParsedRow, "_rowNumber">, number>> = {};
|
||||
const normalized = headerCells.map((c) => normalizeHeader(c));
|
||||
const used = new Set<number>();
|
||||
|
||||
// Two-pass matching so the more-specific headers claim their column first.
|
||||
// PASS 1: exact match (after normalization). This is how "Team" → clientTeamRaw
|
||||
// wins over "Creative Team Member Deliverable is Assigned to" (which has "team"
|
||||
// as a substring but isn't exactly "team").
|
||||
for (const [key, matchers] of Object.entries(HEADER_MATCHERS) as [
|
||||
keyof Omit<ParsedRow, "_rowNumber">,
|
||||
string[],
|
||||
][]) {
|
||||
const idx = normalized.findIndex((h) => matchers.some((m) => h.includes(m)));
|
||||
if (idx !== -1) map[key] = idx;
|
||||
const idx = normalized.findIndex(
|
||||
(h, i) => !used.has(i) && matchers.some((m) => h === m)
|
||||
);
|
||||
if (idx !== -1) {
|
||||
map[key] = idx;
|
||||
used.add(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// PASS 2: substring match for keys that didn't get an exact hit (verbose
|
||||
// headers like "Creative Team Member Deliverable is Assigned to"). Only
|
||||
// columns not already claimed above are considered.
|
||||
for (const [key, matchers] of Object.entries(HEADER_MATCHERS) as [
|
||||
keyof Omit<ParsedRow, "_rowNumber">,
|
||||
string[],
|
||||
][]) {
|
||||
if (map[key] !== undefined) continue;
|
||||
const idx = normalized.findIndex(
|
||||
(h, i) => !used.has(i) && matchers.some((m) => h.includes(m))
|
||||
);
|
||||
if (idx !== -1) {
|
||||
map[key] = idx;
|
||||
used.add(idx);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
|
@ -416,15 +444,7 @@ function extractColumns(
|
|||
const get = <K extends keyof Omit<ParsedRow, "_rowNumber">>(key: K): unknown => {
|
||||
const idx = columnMap[key];
|
||||
if (idx === undefined) return undefined;
|
||||
const cell = data[idx];
|
||||
// Unwrap exceljs rich-text objects
|
||||
if (cell && typeof cell === "object" && "richText" in (cell as any)) {
|
||||
return (cell as any).richText.map((t: any) => t.text).join("");
|
||||
}
|
||||
if (cell && typeof cell === "object" && "result" in (cell as any)) {
|
||||
return (cell as any).result;
|
||||
}
|
||||
return cell;
|
||||
return unwrapCell(data[idx]);
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -445,6 +465,37 @@ function extractColumns(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap an exceljs cell value to a primitive string/number/Date that Zod can
|
||||
* validate. exceljs returns rich objects for hyperlinks, rich text, formulas,
|
||||
* and errors — each shape surfaces the useful value on a different property.
|
||||
*/
|
||||
function unwrapCell(cell: unknown): unknown {
|
||||
if (cell === null || cell === undefined) return cell;
|
||||
if (typeof cell !== "object") return cell;
|
||||
if (cell instanceof Date) return cell;
|
||||
|
||||
const c = cell as Record<string, unknown>;
|
||||
|
||||
// Hyperlink cells: { text: "label", hyperlink: "url" }
|
||||
if (typeof c.text === "string") return c.text;
|
||||
|
||||
// Rich text: { richText: [{ text, font }, ...] }
|
||||
if (Array.isArray(c.richText)) {
|
||||
return (c.richText as Array<{ text?: string }>)
|
||||
.map((r) => r.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Formula result: { formula, result }
|
||||
if ("result" in c) return c.result;
|
||||
|
||||
// Error cells: { error: "#N/A" } — treat as empty
|
||||
if (typeof c.error === "string") return null;
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
function isEmptyRow(row: ParsedRow): boolean {
|
||||
const { _rowNumber: _r, ...fields } = row;
|
||||
return Object.values(fields).every(
|
||||
|
|
|
|||
|
|
@ -39,12 +39,16 @@ export function middleware(request: NextRequest) {
|
|||
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 || isHealthCheck || isWebhook) {
|
||||
if (isApiAuth || isLocalAuth || isHealthCheck || isWebhook) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue