@podosoft/podokit 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +3 -1
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  16. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  18. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +3 -1
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -680
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +30 -11
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +63 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +23 -7
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +137 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  64. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  65. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
  67. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
  68. package/dist/templates/modules/auth/module.manifest.json +10 -0
  69. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  70. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  71. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  72. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  73. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  74. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  75. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  76. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  77. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
  78. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  79. package/dist/templates/todo/dot-gitignore +6 -0
  80. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  81. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  82. package/dist/update.js +74 -8
  83. package/package.json +1 -1
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
@@ -16,6 +16,16 @@ async function seedSession(
16
16
  data: { email: account.email, password: account.password },
17
17
  });
18
18
  expect(res.ok(), `sign-in ${account.email}`).toBeTruthy();
19
+ // Keep repeated local runs from filling better-auth's 100-session response
20
+ // before the newly created current session can appear in account tests. The
21
+ // endpoint clears at most one response page, so drain several pages when a
22
+ // long-lived local database has accumulated them.
23
+ for (let attempt = 0; attempt < 10; attempt += 1) {
24
+ const sessionsResponse = await ctx.get("/api/auth/list-sessions");
25
+ const sessions = sessionsResponse.ok() ? (await sessionsResponse.json()) as unknown : [];
26
+ if (!Array.isArray(sessions) || sessions.length <= 1) break;
27
+ expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
28
+ }
19
29
  await ctx.storageState({ path });
20
30
  await ctx.dispose();
21
31
  }
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { ready } from "../helpers/hydration";
4
+ import { smsSinkReachable, clearSms, waitForSmsOtp } from "../helpers/sms";
5
+
6
+ // Each test drives a fresh disposable account (via a real UI login) so the shared
7
+ // admin/user sessions are never mutated.
8
+ test.use({ storageState: anonState });
9
+
10
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
11
+ const origin = { origin: base };
12
+ const PW = "Podokit3e-Str0ng!pw";
13
+
14
+ async function loginFresh(page: import("@playwright/test").Page, playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"], email: string): Promise<void> {
15
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
16
+ await api.post("/api/auth/sign-up/email", { data: { email, password: PW, name: "Acc" } });
17
+ await api.dispose();
18
+ await ready(page, `/login?redirect=${encodeURIComponent("/admin/account")}`);
19
+ await page.locator("#email").fill(email);
20
+ await page.locator("#password").fill(PW);
21
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
22
+ await expect(page).toHaveURL(/\/admin\/account/);
23
+ }
24
+
25
+ test("account: register and verify a phone number @smoke", async ({ page, playwright }) => {
26
+ test.skip(!(await smsSinkReachable()), "sms sink unreachable");
27
+ await loginFresh(page, playwright, `acc-ph-${Date.now()}@example.com`);
28
+ const phoneInput = page.locator("#phone");
29
+ test.skip((await phoneInput.count()) === 0, "phone number feature disabled");
30
+
31
+ const phone = `+1555${String(Date.now()).slice(-7)}`;
32
+ await clearSms();
33
+ await phoneInput.fill(phone);
34
+ await page.getByRole("button", { name: "Send code" }).click();
35
+ const code = await waitForSmsOtp(phone);
36
+ await page.getByPlaceholder("Verification code").fill(code);
37
+ await page.getByRole("button", { name: "Verify", exact: true }).click();
38
+ await expect(page.getByText("Phone verified")).toBeVisible();
39
+ });
40
+
41
+ test("account: change email address @smoke", async ({ page, playwright }) => {
42
+ await loginFresh(page, playwright, `acc-em-${Date.now()}@example.com`);
43
+ await page.locator("#email").fill(`changed-${Date.now()}@example.com`);
44
+ await page.getByRole("button", { name: "Change email" }).click();
45
+ await expect(page.getByText(/Email address updated|approve the change/)).toBeVisible();
46
+ });
47
+
48
+ test("account: delete my own account @smoke", async ({ page, playwright }) => {
49
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
50
+ await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: PW } });
51
+ try {
52
+ // Account deletion is a server toggle (off by default) — enable it, then poll
53
+ // until the capability is live (config store has a short TTL).
54
+ await admin.put("/api/account/auth-config", { data: { server: { allowDelete: true } } });
55
+ await expect(async () => {
56
+ const caps = await (await admin.get("/api/account/capabilities")).json();
57
+ expect(caps.deleteAccount).toBe(true);
58
+ }).toPass({ timeout: 8000 });
59
+
60
+ await loginFresh(page, playwright, `acc-del-${Date.now()}@example.com`);
61
+ await page.getByRole("button", { name: "Danger zone" }).click();
62
+ await page.getByRole("button", { name: "Delete account" }).first().click();
63
+ await page.locator("#del-pw").fill(PW);
64
+ // The auth instance rebuilds a few seconds after allowDelete is enabled, so
65
+ // the first delete can be rejected — retry the confirm until it goes through.
66
+ await expect(async () => {
67
+ await page.getByRole("dialog").getByRole("button", { name: "Delete account" }).click();
68
+ await expect(page).toHaveURL(/\/login/, { timeout: 3000 });
69
+ }).toPass({ timeout: 15000 });
70
+ } finally {
71
+ await admin.put("/api/account/auth-config", { data: { server: { allowDelete: false } } });
72
+ await admin.dispose();
73
+ }
74
+ });
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
+ import { ADMIN } from "../helpers/accounts";
3
4
 
4
5
  // admin storageState (project default)
5
6
  test("account opens on the profile section @smoke", async ({ page }) => {
@@ -9,7 +10,7 @@ test("account opens on the profile section @smoke", async ({ page }) => {
9
10
  await expect(page.getByLabel("Email")).toHaveValue("admin@example.com");
10
11
  // save is enabled only once the name changes
11
12
  await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
12
- await page.getByLabel("Name", { exact: true }).fill("Admin Renamed");
13
+ await page.getByLabel("Name", { exact: true }).fill(`Admin Preview ${Date.now()}`);
13
14
  await expect(page.getByRole("button", { name: "Save changes" })).toBeEnabled();
14
15
  });
15
16
 
@@ -82,7 +83,13 @@ test("account shows a phone field when enabled @smoke", async ({ page }) => {
82
83
 
83
84
  test("admin can update their profile name", async ({ page }) => {
84
85
  await ready(page, "/admin/account");
85
- await page.getByLabel("Name", { exact: true }).fill("Admin Renamed");
86
+ const name = page.getByLabel("Name", { exact: true });
87
+ await name.fill(`Admin Updated ${Date.now()}`);
88
+ await page.getByRole("button", { name: "Save changes" }).click();
89
+ await expect(page.getByText("Profile updated")).toBeVisible();
90
+ await ready(page, "/admin/account");
91
+ const refreshedName = page.getByLabel("Name", { exact: true });
92
+ await refreshedName.fill(ADMIN.name);
86
93
  await page.getByRole("button", { name: "Save changes" }).click();
87
94
  await expect(page.getByText("Profile updated")).toBeVisible();
88
95
  });
@@ -0,0 +1,63 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // admin storageState (project default). The Appearance controls live in their
5
+ // own "Appearance" settings tab.
6
+ async function openAppearance(page: import("@playwright/test").Page): Promise<void> {
7
+ await ready(page, "/admin/settings");
8
+ await page.getByRole("tab", { name: "Appearance" }).click();
9
+ }
10
+
11
+ test("appearance: has its own tab with theme controls @smoke", async ({ page }) => {
12
+ await ready(page, "/admin/settings");
13
+ await expect(page.getByRole("tab", { name: "Appearance" })).toBeVisible();
14
+ await page.getByRole("tab", { name: "Appearance" }).click();
15
+ await expect(page.getByRole("heading", { name: "Appearance" })).toBeVisible();
16
+ await expect(page.getByRole("heading", { name: "Featured themes" })).toBeVisible();
17
+ await expect(page.getByRole("button", { name: "Default", exact: true })).toBeVisible();
18
+ await expect(page.getByRole("button", { name: "Violet" })).toBeVisible();
19
+ await expect(page.getByRole("button", { name: "Zinc" })).toBeHidden();
20
+ await expect(page.getByLabel("Brand color").first()).toBeVisible();
21
+ await expect(page.getByText("Corner style", { exact: true })).toBeVisible();
22
+ await expect(page.getByText("Preview", { exact: true })).toBeVisible();
23
+ });
24
+
25
+ test("appearance: keeps the full preset catalog behind a disclosure", async ({ page }) => {
26
+ await openAppearance(page);
27
+ await page.getByRole("button", { name: "Show more themes" }).click();
28
+ await expect(page.getByRole("button", { name: "Zinc" })).toBeVisible();
29
+ await expect(page.getByRole("button", { name: "GitHub" })).toBeVisible();
30
+ await page.getByRole("button", { name: "Show fewer themes" }).click();
31
+ await expect(page.getByRole("button", { name: "Zinc" })).toBeHidden();
32
+ });
33
+
34
+ test("appearance: preview light/dark toggle switches modes", async ({ page }) => {
35
+ await openAppearance(page);
36
+ await expect(page.getByRole("button", { name: "Light" })).toBeVisible();
37
+ await expect(page.getByRole("button", { name: "Dark" })).toBeVisible();
38
+ await page.getByRole("button", { name: "Dark" }).click();
39
+ });
40
+
41
+ test("appearance: advanced reveals per-token pickers", async ({ page }) => {
42
+ await openAppearance(page);
43
+ await page.getByRole("button", { name: "Fine-tune colors" }).click();
44
+ await expect(page.getByRole("button", { name: "Clear color adjustments" })).toBeVisible();
45
+ await expect(page.getByLabel("Primary light")).toBeVisible();
46
+ await page.getByRole("button", { name: "Dark", exact: true }).last().click();
47
+ await expect(page.getByLabel("Primary dark")).toBeVisible();
48
+ });
49
+
50
+ test("appearance: pick a preset, save, apply live, then restore @smoke", async ({ page }) => {
51
+ await openAppearance(page);
52
+ await page.getByRole("button", { name: "Slate" }).click();
53
+ await page.getByRole("button", { name: "Medium" }).click();
54
+ await page.getByRole("button", { name: "Save changes" }).click();
55
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
56
+ // Applied globally via the scoped override stylesheet.
57
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBeGreaterThan(0);
58
+
59
+ // "Restore defaults" reverts everything in one click and persists.
60
+ await page.getByRole("button", { name: "Restore defaults" }).click();
61
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
62
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBe(0);
63
+ });
@@ -43,3 +43,14 @@ test("reset-password without a token is disabled", async ({ page }) => {
43
43
  await expect(page.getByRole("alert")).toBeVisible();
44
44
  await expect(page.getByRole("button", { name: "Update password" })).toBeDisabled();
45
45
  });
46
+
47
+ test("signup creates an account and enters the app @smoke", async ({ page }) => {
48
+ await ready(page, "/signup");
49
+ const email = `signup-${Date.now()}@example.com`;
50
+ await page.getByLabel("Name").fill("New User");
51
+ await page.getByLabel("Email").fill(email);
52
+ await page.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
53
+ await page.getByRole("button", { name: "Create account" }).click();
54
+ // Verification off → straight into the app; on → the verify-email page.
55
+ await expect(page).toHaveURL(/\/admin|\/verify-email/);
56
+ });
@@ -9,12 +9,17 @@ import { ready } from "../helpers/hydration";
9
9
  // `finally` so a mid-test failure still leaves the shared site settings clean.
10
10
 
11
11
  async function saveGeneral(page: Page): Promise<void> {
12
+ const saved = page.waitForResponse(
13
+ (response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
14
+ );
12
15
  await page.getByRole("button", { name: "Save changes" }).click();
13
- await expect(page.getByText("General settings saved.")).toBeVisible();
16
+ expect((await saved).ok()).toBeTruthy();
17
+ await expect(page.getByText("General settings saved.").last()).toBeVisible();
14
18
  }
15
19
 
16
- test("general: footer text and links render on public pages @smoke", async ({ page }) => {
20
+ test("general: footer text and links render on public pages @smoke", async ({ page, browser }) => {
17
21
  await ready(page, "/admin/settings");
22
+ const origin = new URL(page.url()).origin;
18
23
  const footer = page.getByLabel("Footer text");
19
24
  const support = page.getByLabel("Support email");
20
25
  const origFooter = await footer.inputValue();
@@ -23,9 +28,12 @@ test("general: footer text and links render on public pages @smoke", async ({ pa
23
28
  await support.fill("help@example.com");
24
29
  await saveGeneral(page);
25
30
  try {
26
- await page.goto("/");
27
- await expect(page.getByText("© 2026 PodoKit Test")).toBeVisible();
28
- await expect(page.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
31
+ const ctx = await browser.newContext({ storageState: anonState });
32
+ const anon = await ctx.newPage();
33
+ await anon.goto(`${origin}/login`);
34
+ await expect(anon.getByText("© 2026 PodoKit Test")).toBeVisible();
35
+ await expect(anon.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
36
+ await ctx.close();
29
37
  } finally {
30
38
  await ready(page, "/admin/settings");
31
39
  await page.getByLabel("Footer text").fill(origFooter);
@@ -4,8 +4,20 @@ import { anonState, userState } from "../helpers/accounts";
4
4
  test.describe("unauthenticated", () => {
5
5
  test.use({ storageState: anonState });
6
6
  test("protected route redirects to login @smoke", async ({ page }) => {
7
- await page.goto("/admin");
8
- await expect(page).toHaveURL(/\/login\?redirect=/);
7
+ for (const route of ["/admin", "/account"]) {
8
+ await page.goto(route);
9
+ await expect(page).toHaveURL(/\/login\?redirect=/);
10
+ }
11
+ });
12
+
13
+ test("private and authentication routes prevent search indexing @smoke", async ({ request }) => {
14
+ for (const route of ["/admin", "/account", "/login", "/api/account/me"]) {
15
+ const response = await request.get(route, { maxRedirects: 0 });
16
+ expect(response.headers()["x-robots-tag"]).toBe("noindex, nofollow");
17
+ }
18
+
19
+ const landing = await request.get("/");
20
+ expect(landing.headers()["x-robots-tag"]).toBeUndefined();
9
21
  });
10
22
  });
11
23
 
@@ -19,4 +31,11 @@ test.describe("normal user (non-admin)", () => {
19
31
  expect((await page.goto("/admin/sessions"))?.status()).toBe(403);
20
32
  expect((await page.goto("/admin/settings"))?.status()).toBe(403);
21
33
  });
34
+
35
+ test("account is available without the admin shell @smoke", async ({ page }) => {
36
+ await page.goto("/account");
37
+ await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
38
+ await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
39
+ await expect(page.getByRole("link", { name: "Overview" })).toHaveCount(0);
40
+ });
22
41
  });
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+ import { USER } from "../helpers/accounts";
4
+
5
+ // admin storageState (project default)
6
+
7
+ test("admin can impersonate a user and stop @smoke", async ({ page }) => {
8
+ await ready(page, "/admin/users");
9
+ await page.locator("#toolbar-search").fill(USER.email);
10
+ await page.getByRole("button", { name: "Search", exact: true }).click();
11
+
12
+ const row = page.getByRole("row", { name: new RegExp(USER.email) });
13
+ await row.getByRole("button").click();
14
+ await page.getByRole("menuitem", { name: "Impersonate" }).click();
15
+
16
+ // Impersonating drops us into the app with a banner offering to stop.
17
+ await expect(page).toHaveURL(/\/admin(\/|$)/);
18
+ const stop = page.getByRole("button", { name: "Stop impersonating" });
19
+ await expect(stop).toBeVisible();
20
+
21
+ await stop.click();
22
+ await expect(page.getByRole("button", { name: "Stop impersonating" })).toHaveCount(0);
23
+ });
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { ready } from "../helpers/hydration";
4
+ import { mailpitReachable, clearMailpit, waitForOtp } from "../helpers/mailpit";
5
+
6
+ test.use({ storageState: anonState });
7
+
8
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
9
+ const origin = { origin: base };
10
+
11
+ test("login: magic link shows a sent confirmation @smoke", async ({ page, playwright }) => {
12
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
13
+ const caps = await (await api.get("/api/account/capabilities")).json();
14
+ await api.dispose();
15
+ test.skip(!caps?.magicLink, "magic link disabled");
16
+
17
+ await ready(page, "/login");
18
+ await page.locator("#email").fill(`ml-${Date.now()}@example.com`);
19
+ await page.getByRole("button", { name: /sign-in link/i }).click();
20
+ await expect(page.getByTestId("magic-link-sent")).toBeVisible();
21
+ });
22
+
23
+ test("login: an emailed one-time code signs a user in @smoke", async ({ page, playwright }) => {
24
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
25
+ const caps = await (await api.get("/api/account/capabilities")).json();
26
+ test.skip(!caps?.emailOtp, "email otp disabled");
27
+ test.skip(!(await mailpitReachable()), "mailpit unreachable");
28
+
29
+ const email = `otp-${Date.now()}@example.com`;
30
+ await api.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "OTP" } });
31
+ await api.dispose();
32
+ await clearMailpit();
33
+
34
+ await ready(page, "/login");
35
+ await page.locator("#email").fill(email);
36
+ await page.getByRole("button", { name: "Email me a code" }).click();
37
+ const otp = await waitForOtp(email);
38
+ await page.getByLabel("One-time code").fill(otp);
39
+ await page.getByRole("button", { name: "Verify code" }).click();
40
+ await expect(page).toHaveURL(/\/admin/);
41
+ });
@@ -26,14 +26,18 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff @sm
26
26
  const jwks = await (await page.request.get(disc.jwks_uri)).json();
27
27
  expect(Array.isArray(jwks.keys) && jwks.keys.length).toBeTruthy();
28
28
 
29
- // Dynamic client registration returns usable credentials.
30
- const reg = await (
31
- await page.request.post("/api/auth/oauth2/create-client", {
32
- data: { client_name: "E2E RP", redirect_uris: [REDIRECT] },
33
- headers,
34
- })
35
- ).json();
36
- expect(reg.client_id).toBeTruthy();
29
+ // Dynamic client registration returns usable credentials. Retry: right after
30
+ // the provider is enabled the auth instance may still be rebuilding.
31
+ let reg: { client_id?: string; client_secret?: string } = {};
32
+ await expect(async () => {
33
+ reg = await (
34
+ await page.request.post("/api/auth/oauth2/create-client", {
35
+ data: { client_name: "E2E RP", redirect_uris: [REDIRECT] },
36
+ headers,
37
+ })
38
+ ).json();
39
+ expect(reg.client_id).toBeTruthy();
40
+ }).toPass({ timeout: 8000 });
37
41
  expect(reg.client_secret).toBeTruthy();
38
42
 
39
43
  // Authorize validates the client + PKCE and hands off to the consent page with
@@ -53,3 +53,38 @@ test("create an org with a non-ascii name, a parent, and a manager @smoke", asyn
53
53
  await page.getByRole("row", { name: new RegExp(parent) }).getByRole("button", { name: "Delete" }).first().click();
54
54
  await expect(page.getByText("Organization deleted").first()).toBeVisible();
55
55
  });
56
+
57
+ test("organization manage: remove a member, invite one, and cancel the invite @smoke", async ({ page }) => {
58
+ await ready(page, "/admin/organizations");
59
+ const newBtn = page.getByRole("button", { name: "New organization" });
60
+ test.skip((await newBtn.count()) === 0, "organizations not enabled");
61
+
62
+ const name = `Mng ${Date.now()}`;
63
+ await newBtn.click();
64
+ await page.getByLabel("Name", { exact: true }).fill(name);
65
+ // Seed a specific non-owner member (the admin is always the owner, so pick the
66
+ // seeded user — .first() is order-dependent and could hit the owner).
67
+ await page.locator("label", { hasText: "user@example.com" }).getByRole("checkbox").click();
68
+ await page.getByRole("dialog").getByRole("button", { name: "Create", exact: true }).click();
69
+ await expect(page.getByText("Organization created").first()).toBeVisible();
70
+
71
+ try {
72
+ await page.getByRole("row", { name: new RegExp(name) }).getByRole("button", { name: "Manage" }).click();
73
+ const dialog = page.getByRole("dialog");
74
+
75
+ // The seeded non-owner member is removable.
76
+ const remove = dialog.getByRole("button", { name: "Remove" }).first();
77
+ await expect(remove).toBeVisible();
78
+ await remove.click();
79
+
80
+ // Invite a member by email → pending invitation appears → cancel it.
81
+ await dialog.locator("#invite-email").fill(`invitee-${Date.now()}@example.com`);
82
+ await dialog.getByRole("button", { name: "Invite", exact: true }).click();
83
+ await expect(page.getByText("Invitation sent").first()).toBeVisible();
84
+ await dialog.getByRole("button", { name: "Cancel", exact: true }).first().click();
85
+ } finally {
86
+ await page.keyboard.press("Escape").catch(() => undefined);
87
+ await page.getByRole("row", { name: new RegExp(name) }).getByRole("button", { name: "Delete" }).first().click();
88
+ await expect(page.getByText("Organization deleted").first()).toBeVisible();
89
+ }
90
+ });
@@ -18,7 +18,7 @@ test("register a passkey and sign in with it @smoke", async ({ page, baseURL })
18
18
  const email = `pk-${Date.now()}@example.com`;
19
19
  const pw = "Podokit3e-Str0ng!pw";
20
20
  const signup = await page.context().request.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "PK" }, headers: origin });
21
- expect(signup.ok()).toBeTruthy();
21
+ expect(signup.ok(), await signup.text()).toBeTruthy();
22
22
 
23
23
  await ready(page, "/admin/account");
24
24
  await page.getByRole("button", { name: "Security" }).click();
@@ -0,0 +1,63 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // admin storageState (project default)
5
+
6
+ test("settings: add and remove a social login provider @smoke", async ({ page }) => {
7
+ const dialog = page.getByRole("dialog");
8
+ const addBtn = dialog.getByRole("button", { name: "Add provider" });
9
+ const openConfigure = async (): Promise<void> => {
10
+ await page.getByRole("tab", { name: "Authentication" }).click();
11
+ await page.getByRole("button", { name: "Configure" }).click();
12
+ };
13
+
14
+ await ready(page, "/admin/settings");
15
+ // The auth-config catalog can be momentarily unavailable right after another
16
+ // spec toggles auth config (the auth instance rebuilds). Reload until the
17
+ // catalog is loaded (Add provider becomes enabled).
18
+ await expect(async () => {
19
+ await page.reload();
20
+ await openConfigure();
21
+ await expect(addBtn).toBeEnabled({ timeout: 3000 });
22
+ }).toPass({ timeout: 25000 });
23
+
24
+ await addBtn.click();
25
+ const provider = dialog.locator("#social-provider");
26
+ await expect(provider).toBeVisible();
27
+ const callback = await dialog.locator("code").textContent();
28
+ const providerId = callback?.trim().split("/").pop() ?? ""; // first addable provider (not hard-coded)
29
+ expect(providerId).not.toBe("");
30
+ try {
31
+ await dialog.locator("#social-id").fill("test-client-id");
32
+ await dialog.locator("#social-secret").fill("test-secret");
33
+ await dialog.getByRole("button", { name: "Save", exact: true }).click();
34
+ // Save returns to the list view, where the new provider row has an enable switch.
35
+ await expect(dialog.getByRole("switch").first()).toBeVisible();
36
+ } finally {
37
+ if (providerId) {
38
+ await page.request
39
+ .put("/api/account/auth-config", { data: { social: { [providerId]: { delete: true } } } })
40
+ .catch(() => undefined);
41
+ }
42
+ }
43
+ });
44
+
45
+ test("settings: open and save the SMTP dialog @smoke", async ({ page }) => {
46
+ await ready(page, "/admin/settings");
47
+ await page.getByRole("tab", { name: "Authentication" }).click();
48
+ await page.getByRole("button", { name: "SMTP settings" }).click();
49
+ const dialog = page.getByRole("dialog");
50
+ try {
51
+ // Point at the same Mailpit the env already uses, so saving can't re-route mail
52
+ // for the other email specs.
53
+ await dialog.locator("#smtp-host").fill("localhost");
54
+ await dialog.locator("#smtp-port").fill("1125");
55
+ await dialog.getByRole("button", { name: "Save", exact: true }).click();
56
+ await expect(page.getByText("Setting updated").first()).toBeVisible();
57
+ } finally {
58
+ // Reset SMTP so the config store falls back to environment for later specs.
59
+ await page.request
60
+ .put("/api/account/auth-config", { data: { smtp: { enabled: false, host: "", port: 587, user: "", from: "", secure: false } } })
61
+ .catch(() => undefined);
62
+ }
63
+ });
@@ -1,8 +1,17 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type Page } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
4
  // admin storageState (project default)
5
5
 
6
+ async function saveGeneral(page: Page): Promise<void> {
7
+ const saved = page.waitForResponse(
8
+ (response) => response.url().endsWith("/api/site/settings") && response.request().method() === "PUT",
9
+ );
10
+ await page.getByRole("button", { name: "Save changes" }).click();
11
+ expect((await saved).ok()).toBeTruthy();
12
+ await expect(page.getByText("General settings saved.").last()).toBeVisible();
13
+ }
14
+
6
15
  test("settings has General and Authentication tabs @smoke", async ({ page }) => {
7
16
  await ready(page, "/admin/settings");
8
17
  await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
@@ -23,14 +32,12 @@ test("general settings: edit the site name, save, and apply live @smoke", async
23
32
  const name = page.getByLabel("Site name");
24
33
  const original = await name.inputValue();
25
34
  await name.fill("PodoKit Test Site");
26
- await page.getByRole("button", { name: "Save changes" }).click();
27
- await expect(page.getByText("General settings saved.")).toBeVisible();
35
+ await saveGeneral(page);
28
36
  // the browser tab title reflects the new name live (no reload)
29
37
  await expect(page).toHaveTitle("PodoKit Test Site");
30
38
  // restore so other specs start clean
31
39
  await name.fill(original);
32
- await page.getByRole("button", { name: "Save changes" }).click();
33
- await expect(page.getByText("General settings saved.")).toBeVisible();
40
+ await saveGeneral(page);
34
41
  });
35
42
 
36
43
  test("settings lists the auth features under the Authentication tab @smoke", async ({ page }) => {
@@ -49,8 +56,8 @@ test("sidebar: back-to-home link returns to the landing page @smoke", async ({ p
49
56
  // the home link lives in the sidebar footer, above the user menu
50
57
  await page.getByRole("link", { name: "Back to home" }).click();
51
58
  await expect(page).toHaveURL(/\/$/);
52
- // the landing page shows the "Open admin" entry point
53
- await expect(page.getByRole("link", { name: "Open admin" })).toBeVisible();
59
+ // The starter landing route is app-owned and remains intact when the module is added.
60
+ await expect(page.getByRole("heading", { name: "app" })).toBeVisible();
54
61
  });
55
62
 
56
63
  test("settings is reachable from the sidebar @smoke", async ({ page }) => {
@@ -73,3 +80,12 @@ test("admin can toggle a feature flag and it applies live @smoke", async ({ page
73
80
  await toggle.click();
74
81
  await expect(toggle).toBeChecked();
75
82
  });
83
+
84
+ test("settings: the require-two-factor toggle is available @smoke", async ({ page }) => {
85
+ await ready(page, "/admin/settings");
86
+ await page.getByRole("tab", { name: "Authentication" }).click();
87
+ // The policy toggle renders under Authentication; its enforcement is covered by
88
+ // the require-2fa api/ui specs. Not toggled here — turning it on would gate this
89
+ // admin session.
90
+ await expect(page.getByRole("switch", { name: "Require two-factor" })).toBeVisible();
91
+ });