From 8a48e8751109fcd357322f713acb63a7407a5f6c Mon Sep 17 00:00:00 2001 From: korone Date: Fri, 7 Aug 2026 16:56:44 +0900 Subject: [PATCH] fix: return 2FA users to account --- .changeset/calm-cats-return.md | 5 ++ packages/cli/src/add.test.ts | 16 +++++++ .../web/src/routes/account/+page.server.ts | 6 ++- .../web/src/routes/setup-2fa/+page.server.ts | 2 +- .../web/src/routes/setup-2fa/+page.svelte | 2 +- .../files/tests/ui/account-flows.ui.spec.ts | 6 ++- .../files/tests/ui/two-factor.ui.spec.ts | 47 ++++++++++++++++--- 7 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 .changeset/calm-cats-return.md diff --git a/.changeset/calm-cats-return.md b/.changeset/calm-cats-return.md new file mode 100644 index 00000000..68d30111 --- /dev/null +++ b/.changeset/calm-cats-return.md @@ -0,0 +1,5 @@ +--- +"@podosoft/podokit": patch +--- + +Return regular users to the shared account page after mandatory two-factor enrollment and keep the account route behind the enrollment gate. diff --git a/packages/cli/src/add.test.ts b/packages/cli/src/add.test.ts index 46667115..cf307a7e 100644 --- a/packages/cli/src/add.test.ts +++ b/packages/cli/src/add.test.ts @@ -772,6 +772,22 @@ describe("addModule (auth / better-auth)", () => { const guards = readFileSync(join(project, "apps/web/src/lib/server/guards.ts"), "utf8"); expect(guards).toContain('error(503, "Service temporarily unavailable")'); expect(guards).toContain("export function isPublicPath"); + const setupTwoFactorLoader = readFileSync( + join(project, "apps/web/src/routes/setup-2fa/+page.server.ts"), + "utf8", + ); + expect(setupTwoFactorLoader).toContain('redirect(302, "/account")'); + expect(setupTwoFactorLoader).not.toContain('redirect(302, "/admin")'); + const setupTwoFactorPage = readFileSync( + join(project, "apps/web/src/routes/setup-2fa/+page.svelte"), + "utf8", + ); + expect(setupTwoFactorPage).toContain('goto("/account", { invalidateAll: true })'); + const accountLoader = readFileSync( + join(project, "apps/web/src/routes/account/+page.server.ts"), + "utf8", + ); + expect(accountLoader).toContain("await loadProtectedLayout({ locals, fetch })"); // i18n: message catalog + language switch expect(existsSync(join(project, "apps/web/src/lib/i18n/messages.ts"))).toBe(true); expect(existsSync(join(project, "apps/web/src/lib/components/language-switch.svelte"))).toBe(true); diff --git a/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts b/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts index a38679ec..0aafbfef 100644 --- a/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +++ b/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts @@ -1,4 +1,8 @@ import { loadAccountData } from "$lib/account-data.server"; +import { loadProtectedLayout } from "$lib/server/protected-layout"; import type { PageServerLoad } from "./$types"; -export const load: PageServerLoad = ({ locals, fetch }) => loadAccountData(locals, fetch); +export const load: PageServerLoad = async ({ locals, fetch }) => { + await loadProtectedLayout({ locals, fetch }); + return loadAccountData(locals, fetch); +}; diff --git a/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts b/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts index 78d61f76..ccbe2e9b 100644 --- a/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +++ b/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts @@ -8,6 +8,6 @@ export const load: PageServerLoad = ({ locals }) => { requireBackendAvailable(locals); if (!locals.user) redirect(302, "/login"); const user = locals.user as App.Locals["user"] & { twoFactorEnabled?: boolean }; - if (user.twoFactorEnabled) redirect(302, "/admin"); + if (user.twoFactorEnabled) redirect(302, "/account"); return { user: locals.user }; }; diff --git a/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte b/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte index 4d98e14b..fc2a826e 100644 --- a/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +++ b/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte @@ -51,7 +51,7 @@ error = i18n.t.auth.twoFactorInvalidCode; return; } - await goto("/admin", { invalidateAll: true }); + await goto("/account", { invalidateAll: true }); } function downloadCodes(): void { diff --git a/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts b/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts index 60a9dbd0..c1938f6f 100644 --- a/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +++ b/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts @@ -15,11 +15,13 @@ async function loginFresh(page: import("@playwright/test").Page, playwright: imp const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin }); await api.post("/api/auth/sign-up/email", { data: { email, password: PW, name: "Acc" } }); await api.dispose(); - await ready(page, `/login?redirect=${encodeURIComponent("/admin/account")}`); + await ready(page, `/login?redirect=${encodeURIComponent("/account")}`); await page.locator("#email").fill(email); await page.locator("#password").fill(PW); await page.getByRole("button", { name: "Sign in", exact: true }).click(); - await expect(page).toHaveURL(/\/admin\/account/); + await expect(page).toHaveURL((url) => url.pathname === "/account"); + await expect(page.getByRole("heading", { name: "Account" })).toBeVisible(); + await expect(page.getByLabel("Name", { exact: true })).toBeVisible(); } test("account: register and verify a phone number", async ({ page, playwright }) => { diff --git a/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts b/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts index e5c67b0a..b3a30bd5 100644 --- a/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +++ b/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts @@ -15,6 +15,12 @@ async function openHydratedLogin(page: Page, redirect?: string): Promise { await page.waitForLoadState("networkidle"); } +async function expectAccountPage(page: Page): Promise { + await expect(page).toHaveURL((url) => url.pathname === "/account"); + await expect(page.getByRole("heading", { name: "Account" })).toBeVisible(); + await expect(page.getByLabel("Name", { exact: true })).toBeVisible(); +} + // A disposable account (never the shared admin/user) so enabling 2FA here can't // invalidate the seeded sessions the other auth specs rely on. test("two-factor: sign in with a backup code from the login page", async ({ page, playwright }) => { @@ -31,7 +37,7 @@ test("two-factor: sign in with a backup code from the login page", async ({ page await api.dispose(); // Password sign-in hands off to the second-factor step (no session yet). - await openHydratedLogin(page, "/admin/account"); + await openHydratedLogin(page, "/account"); await page.getByLabel("Email").fill(email); await page.getByLabel("Password").fill(pw); await page.getByRole("button", { name: "Sign in", exact: true }).click(); @@ -52,7 +58,30 @@ test("two-factor: sign in with a backup code from the login page", async ({ page // The real code completes the login. await page.getByLabel("Backup code").fill(backupCode); await page.getByRole("button", { name: "Verify", exact: true }).click(); - await expect(page).toHaveURL(/\/admin\/account/); + await expectAccountPage(page); +}); + +test("two-factor: sign in with an authenticator code from the login page", async ({ page, playwright }) => { + const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin }); + const caps = await (await api.get("/api/account/capabilities")).json(); + test.skip(!caps?.twoFactor, "two-factor not enabled"); + + const email = `tf-totp-ui-${Date.now()}@example.com`; + const pw = "Podokit3e-Str0ng!pw"; + await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "UI" } }); + const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json(); + await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } }); + await api.dispose(); + + await openHydratedLogin(page, "/account"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(pw); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page.getByTestId("two-factor-step")).toBeVisible(); + await page.getByLabel("Authentication code").fill(totpCode(enable.totpURI)); + await page.getByRole("button", { name: "Verify", exact: true }).click(); + + await expectAccountPage(page); }); test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => { @@ -74,7 +103,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({ await probe.dispose(); // Signing in toward a protected route lands on the mandatory enrolment page. - await openHydratedLogin(page, "/admin"); + await openHydratedLogin(page, "/account"); await page.getByLabel("Email").fill(email); await page.getByLabel("Password").fill(pw); await page.getByRole("button", { name: "Sign in", exact: true }).click(); @@ -91,8 +120,12 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({ await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri)); await page.getByRole("button", { name: "Activate and continue" }).click(); - // Enrolled → the gate lets them into the app (no longer bounced to /setup-2fa). - await expect(page).toHaveURL(/\/admin/); + // Enrolled → the gate lets the regular user into their account page. + await expectAccountPage(page); + + // An enrolled user who revisits setup is returned to the same non-admin page. + await page.goto("/setup-2fa"); + await expectAccountPage(page); } finally { await admin.put("/api/account/settings", { data: { require2fa: false } }); await expect(async () => { @@ -119,14 +152,14 @@ test("account: regenerate backup codes shows a fresh set", async ({ page, playwr await api.dispose(); // Sign in (backup-code path) straight to the account page. - await openHydratedLogin(page, "/admin/account"); + await openHydratedLogin(page, "/account"); await page.getByLabel("Email").fill(email); await page.getByLabel("Password").fill(pw); await page.getByRole("button", { name: "Sign in", exact: true }).click(); await page.getByRole("button", { name: "Use a backup code instead" }).click(); await page.getByLabel("Backup code").fill(backupCode); await page.getByRole("button", { name: "Verify", exact: true }).click(); - await expect(page).toHaveURL(/\/admin\/account/); + await expectAccountPage(page); // Security → regenerate backup codes → a fresh set is shown. await page.getByRole("button", { name: "Security" }).click();