SPB Git

admin: redirect straight to the password-change page after first login; no prefetch on admin nav; e2e locator fixes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 2, 2026) parent f9849d6 Browse files

5 changed files +8 −7

modified playwright.config.ts +1 −1
@@ -5,6 +5,6 @@ export default defineConfig({
5 use: { baseURL: base, trace: "retain-on-failure" }, 5 use: { baseURL: base, trace: "retain-on-failure" },
6 projects: [ 6 projects: [
7 { name: "desktop", use: { ...devices["Desktop Chrome"] } }, 7 { name: "desktop", use: { ...devices["Desktop Chrome"] } },
8− { name: "iphone", use: { ...devices["iPhone 14"] }, grepInvert: /@desktop-only/ }, 8+ { name: "iphone", use: { ...devices["iPhone 14"], browserName: "chromium" }, grepInvert: /@desktop-only/ },
9 ], 9 ],
10 }); 10 });
modified src/app/(auth)/login/actions.ts +3 −3
@@ -18,14 +18,14 @@ export async function loginAction(f: FormData) {
18 if (!ok) { await audit({ label: email, ip }, "login.failed", "user", email, null, null); redirect("/login?error=Invalid+credentials"); } 18 if (!ok) { await audit({ label: email, ip }, "login.failed", "user", email, null, null); redirect("/login?error=Invalid+credentials"); }
19 const token = await createSession(u.id, { mfaPassed: !u.totp_enabled }); await setSessionCookie(token); 19 const token = await createSession(u.id, { mfaPassed: !u.totp_enabled }); await setSessionCookie(token);
20 await sql`UPDATE users SET last_login_at = now() WHERE id = ${u.id}`; await audit({ id: u.id, label: email, ip }, "login.success", "user", u.id, null, { mfa: u.totp_enabled }); 20 await sql`UPDATE users SET last_login_at = now() WHERE id = ${u.id}`; await audit({ id: u.id, label: email, ip }, "login.success", "user", u.id, null, { mfa: u.totp_enabled });
21− redirect(u.totp_enabled ? "/login?step=totp" : "/admin"); 21+ redirect(u.totp_enabled ? "/login?step=totp" : u.must_change_password ? "/admin/account?must_change=1" : "/admin");
22 } 22 }
23 export async function totpAction(f: FormData) { 23 export async function totpAction(f: FormData) {
24 await assertSameOrigin(); const s = await getSession(); if (!s) redirect("/login"); 24 await assertSameOrigin(); const s = await getSession(); if (!s) redirect("/login");
25− const [u] = await sql`SELECT totp_secret FROM users WHERE id = ${s.id}`; const code = String(f.get("code") ?? "").trim(); const ip = clientIp(await headers()); 25+ const [u] = await sql`SELECT totp_secret, must_change_password FROM users WHERE id = ${s.id}`; const code = String(f.get("code") ?? "").trim(); const ip = clientIp(await headers());
26 const allowed = await loginAllowed(ip, s.email); if (!allowed.ok) redirect("/login?step=totp&error=Too+many+attempts"); 26 const allowed = await loginAllowed(ip, s.email); if (!allowed.ok) redirect("/login?step=totp&error=Too+many+attempts");
27 const ok = !!u?.totp_secret && verifySync({ token: code, secret: u.totp_secret }).valid; await recordAttempt(ip, s.email, ok); 27 const ok = !!u?.totp_secret && verifySync({ token: code, secret: u.totp_secret }).valid; await recordAttempt(ip, s.email, ok);
28 if (!ok) { await audit({ id: s.id, label: s.email, ip }, "login.totp_failed", "user", s.id, null, null); redirect("/login?step=totp&error=Invalid+code"); } 28 if (!ok) { await audit({ id: s.id, label: s.email, ip }, "login.totp_failed", "user", s.id, null, null); redirect("/login?step=totp&error=Invalid+code"); }
29 await markMfaPassed(s.sessionId); const t = await rotateSession(s.sessionId); await setSessionCookie(t); await audit({ id: s.id, label: s.email, ip }, "login.totp_ok", "user", s.id, null, null); 29 await markMfaPassed(s.sessionId); const t = await rotateSession(s.sessionId); await setSessionCookie(t); await audit({ id: s.id, label: s.email, ip }, "login.totp_ok", "user", s.id, null, null);
30− redirect("/admin"); 30+ redirect(u.must_change_password ? "/admin/account?must_change=1" : "/admin");
31 } 31 }
modified src/app/admin/layout.tsx +1 −1
@@ -10,7 +10,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
10 <div className="min-h-dvh grid lg:grid-cols-[14rem_1fr]"> 10 <div className="min-h-dvh grid lg:grid-cols-[14rem_1fr]">
11 <aside className="border-b lg:border-b-0 lg:border-r border-line bg-bg-3/50 p-4 lg:sticky lg:top-0 lg:h-dvh overflow-auto"> 11 <aside className="border-b lg:border-b-0 lg:border-r border-line bg-bg-3/50 p-4 lg:sticky lg:top-0 lg:h-dvh overflow-auto">
12 <Link href="/" className="serif text-lg text-fg no-underline">Research Working Papers</Link><p className="text-xs text-fg-3 mb-4">Administration · {u.email}</p> 12 <Link href="/" className="serif text-lg text-fg no-underline">Research Working Papers</Link><p className="text-xs text-fg-3 mb-4">Administration · {u.email}</p>
13− <nav className="flex lg:flex-col flex-wrap gap-1 text-sm">{NAV.map(([h, l]) => <Link key={h} href={h} className="px-2.5 py-1.5 rounded-md text-fg-2 no-underline hover:bg-bg-2 hover:text-fg">{l}</Link>)} 13+ <nav className="flex lg:flex-col flex-wrap gap-1 text-sm">{NAV.map(([h, l]) => <Link key={h} href={h} prefetch={false} className="px-2.5 py-1.5 rounded-md text-fg-2 no-underline hover:bg-bg-2 hover:text-fg">{l}</Link>)}
14 <form action={accountAction} className="mt-2"><input type="hidden" name="_csrf" value={csrfToken(u)} /><input type="hidden" name="op" value="logout" /><button className="px-2.5 py-1.5 rounded-md text-fg-3 hover:bg-bg-2 hover:text-fg w-full text-left">Sign out</button></form> 14 <form action={accountAction} className="mt-2"><input type="hidden" name="_csrf" value={csrfToken(u)} /><input type="hidden" name="op" value="logout" /><button className="px-2.5 py-1.5 rounded-md text-fg-3 hover:bg-bg-2 hover:text-fg w-full text-left">Sign out</button></form>
15 </nav> 15 </nav>
16 {u.mustChangePassword && <p className="mt-4 text-xs text-bad">Change your initial password in Account.</p>} 16 {u.mustChangePassword && <p className="mt-4 text-xs text-bad">Change your initial password in Account.</p>}
modified tests/e2e/admin.spec.ts +1 −0
@@ -10,6 +10,7 @@ test.describe("admin @desktop-only", () => {
10 test.skip(!email || !pw, "no admin credentials in env"); 10 test.skip(!email || !pw, "no admin credentials in env");
11 await page.goto("/login"); await page.getByLabel("E-mail").fill(email!); await page.getByLabel("Password").fill(pw!); await page.getByRole("button", { name: "Sign in" }).click(); 11 await page.goto("/login"); await page.getByLabel("E-mail").fill(email!); await page.getByLabel("Password").fill(pw!); await page.getByRole("button", { name: "Sign in" }).click();
12 await page.waitForURL(/\/admin/); await expect(page.getByRole("heading", { name: /Dashboard|Account/ })).toBeVisible(); 12 await page.waitForURL(/\/admin/); await expect(page.getByRole("heading", { name: /Dashboard|Account/ })).toBeVisible();
13+ if (page.url().includes("must_change")) { await expect(page.getByText(/change the initial password/i)).toBeVisible(); return; } // first login: password change enforced before anything else
13 await page.goto("/admin/papers"); await expect(page.locator("table.data tbody tr").first()).toBeVisible(); 14 await page.goto("/admin/papers"); await expect(page.locator("table.data tbody tr").first()).toBeVisible();
14 await page.goto("/admin/ingest"); await expect(page.getByRole("heading", { name: "Manuscript ingestion" })).toBeVisible(); 15 await page.goto("/admin/ingest"); await expect(page.getByRole("heading", { name: "Manuscript ingestion" })).toBeVisible();
15 await page.goto("/admin/audit"); await expect(page.locator("table.data tbody tr").first()).toBeVisible(); 16 await page.goto("/admin/audit"); await expect(page.locator("table.data tbody tr").first()).toBeVisible();
modified tests/e2e/public.spec.ts +2 −2
@@ -6,7 +6,7 @@ test.describe("public site", () => {
6 await expect(page.getByRole("heading", { name: "Latest working papers" })).toBeVisible(); await expect(page.locator("svg[role=img]").first()).toBeVisible(); 6 await expect(page.getByRole("heading", { name: "Latest working papers" })).toBeVisible(); await expect(page.locator("svg[role=img]").first()).toBeVisible();
7 }); 7 });
8 test("library filters, sorts and switches display modes", async ({ page }) => { 8 test("library filters, sorts and switches display modes", async ({ page }) => {
9− await page.goto("/papers?domain=real-estate-economics&sort=number"); await expect(page.getByText(/results?$/).first()).toBeVisible(); 9+ await page.goto("/papers?domain=real-estate-economics&sort=number"); await expect(page.getByText(/^\d+ results?$/)).toBeVisible();
10 const n = await page.locator("article").count(); expect(n).toBeGreaterThan(3); 10 const n = await page.locator("article").count(); expect(n).toBeGreaterThan(3);
11 await page.goto("/papers?view=table"); await expect(page.locator("table.data")).toBeVisible(); 11 await page.goto("/papers?view=table"); await expect(page.locator("table.data")).toBeVisible();
12 await page.goto("/papers?view=cards&q=hedonic"); expect(await page.locator("article").count()).toBeGreaterThan(0); 12 await page.goto("/papers?view=cards&q=hedonic"); expect(await page.locator("article").count()).toBeGreaterThan(0);
@@ -43,6 +43,6 @@ test.describe("public site", () => {
43 const h = await request.get("/api/health").then((r) => r.json()); expect(h.ok).toBe(true); 43 const h = await request.get("/api/health").then((r) => r.json()); expect(h.ok).toBe(true);
44 }); 44 });
45 test("mobile navigation works", async ({ page, isMobile }) => { 45 test("mobile navigation works", async ({ page, isMobile }) => {
46− test.skip(!isMobile, "mobile only"); await page.goto("/"); await page.getByRole("button", { name: "Menu" }).click(); await page.getByRole("link", { name: "Papers" }).first().click(); await expect(page).toHaveURL(/\/papers$/); 46+ test.skip(!isMobile, "mobile only"); await page.goto("/"); await page.getByRole("button", { name: "Menu" }).click(); await page.locator("#mobile-nav").getByRole("link", { name: "Papers", exact: true }).click(); await expect(page).toHaveURL(/\/papers$/);
47 }); 47 });
48 }); 48 });
49 49
Menu

Theme