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 5 use: { baseURL: base, trace: "retain-on-failure" },
6 6 projects: [
7 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 18 if (!ok) { await audit({ label: email, ip }, "login.failed", "user", email, null, null); redirect("/login?error=Invalid+credentials"); }
19 19 const token = await createSession(u.id, { mfaPassed: !u.totp_enabled }); await setSessionCookie(token);
20 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 23 export async function totpAction(f: FormData) {
24 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 26 const allowed = await loginAllowed(ip, s.email); if (!allowed.ok) redirect("/login?step=totp&error=Too+many+attempts");
27 27 const ok = !!u?.totp_secret && verifySync({ token: code, secret: u.totp_secret }).valid; await recordAttempt(ip, s.email, ok);
28 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 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 10 <div className="min-h-dvh grid lg:grid-cols-[14rem_1fr]">
11 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 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 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 15 </nav>
16 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 10 test.skip(!email || !pw, "no admin credentials in env");
11 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 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 14 await page.goto("/admin/papers"); await expect(page.locator("table.data tbody tr").first()).toBeVisible();
14 15 await page.goto("/admin/ingest"); await expect(page.getByRole("heading", { name: "Manuscript ingestion" })).toBeVisible();
15 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 6 await expect(page.getByRole("heading", { name: "Latest working papers" })).toBeVisible(); await expect(page.locator("svg[role=img]").first()).toBeVisible();
7 7 });
8 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 10 const n = await page.locator("article").count(); expect(n).toBeGreaterThan(3);
11 11 await page.goto("/papers?view=table"); await expect(page.locator("table.data")).toBeVisible();
12 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 43 const h = await request.get("/api/health").then((r) => r.json()); expect(h.ok).toBe(true);
44 44 });
45 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