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>
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 | |