Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-cats-return.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/cli/src/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
error = i18n.t.auth.twoFactorInvalidCode;
return;
}
await goto("/admin", { invalidateAll: true });
await goto("/account", { invalidateAll: true });
}

function downloadCodes(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ async function openHydratedLogin(page: Page, redirect?: string): Promise<void> {
await page.waitForLoadState("networkidle");
}

async function expectAccountPage(page: Page): Promise<void> {
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 }) => {
Expand All @@ -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();
Expand All @@ -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 }) => {
Expand All @@ -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();
Expand All @@ -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 () => {
Expand All @@ -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();
Expand Down