@podosoft/podokit 0.4.0 → 0.5.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 (141) hide show
  1. package/README.md +2 -2
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +5 -1
  4. package/dist/assemble.d.ts +14 -0
  5. package/dist/assemble.js +60 -0
  6. package/dist/create.d.ts +2 -0
  7. package/dist/create.js +7 -0
  8. package/dist/eject.d.ts +11 -0
  9. package/dist/eject.js +27 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +122 -1
  12. package/dist/inspect.d.ts +43 -0
  13. package/dist/inspect.js +82 -0
  14. package/dist/lockfile.d.ts +80 -0
  15. package/dist/lockfile.js +193 -0
  16. package/dist/templates/base/dot-env.example +11 -6
  17. package/dist/templates/fullstack-nest-svelte/README.md +2 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
  23. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  29. package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
  30. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
  31. package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
  33. package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
  34. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
  63. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
  64. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
  66. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
  67. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
  68. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
  72. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +52 -0
  81. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
  82. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
  83. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
  84. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
  85. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
  86. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
  87. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
  88. package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
  90. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
  91. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
  92. package/dist/templates/modules/audit-log/module.manifest.json +15 -9
  93. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
  94. package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
  104. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
  106. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
  113. package/dist/templates/modules/auth/module.manifest.json +54 -20
  114. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
  115. package/dist/templates/modules/bullmq/module.manifest.json +4 -4
  116. package/dist/templates/modules/file-upload/module.manifest.json +3 -3
  117. package/dist/templates/modules/job-progress/module.manifest.json +9 -9
  118. package/dist/templates/modules/logging/module.manifest.json +3 -3
  119. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
  120. package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
  121. package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
  122. package/dist/templates/modules/redis/module.manifest.json +4 -4
  123. package/dist/templates/modules/sse/module.manifest.json +4 -4
  124. package/dist/templates/todo/README.md +2 -2
  125. package/dist/templates/todo/apps/api/package.json +1 -0
  126. package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
  127. package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
  128. package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
  129. package/dist/templates/todo/apps/api/src/main.ts +1 -1
  130. package/dist/templates/todo/apps/web/package.json +2 -2
  131. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  132. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  134. package/dist/templates/todo/dot-env.example +10 -5
  135. package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
  136. package/dist/templates/todo/tests/README.md +1 -1
  137. package/dist/templates/todo/tests/package.json +2 -1
  138. package/dist/templates/todo/tests/playwright.config.ts +1 -1
  139. package/dist/update.d.ts +49 -0
  140. package/dist/update.js +158 -0
  141. package/package.json +2 -2
@@ -5,17 +5,84 @@ import { ready } from "../helpers/hydration";
5
5
  test("account opens on the profile section @smoke", async ({ page }) => {
6
6
  await ready(page, "/admin/account");
7
7
  await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
8
- await expect(page.getByLabel("Name")).toBeVisible();
8
+ await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
9
9
  await expect(page.getByLabel("Email")).toHaveValue("admin@example.com");
10
10
  // save is enabled only once the name changes
11
11
  await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
12
- await page.getByLabel("Name").fill("Admin Renamed");
12
+ await page.getByLabel("Name", { exact: true }).fill("Admin Renamed");
13
13
  await expect(page.getByRole("button", { name: "Save changes" })).toBeEnabled();
14
14
  });
15
15
 
16
+ test("account exposes a change-email action when the address is edited @smoke", async ({ page }) => {
17
+ await ready(page, "/admin/account");
18
+ const email = page.getByLabel("Email");
19
+ await expect(email).toHaveValue("admin@example.com");
20
+ // no action until the address actually changes
21
+ await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
22
+ await email.fill("admin+changed@example.com");
23
+ await expect(page.getByRole("button", { name: "Change email" })).toBeVisible();
24
+ // revert without submitting — don't mutate the shared admin session
25
+ await email.fill("admin@example.com");
26
+ await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
27
+ });
28
+
29
+ test("account shows a username field when enabled @smoke", async ({ page }) => {
30
+ await ready(page, "/admin/account");
31
+ const username = page.getByLabel("Username", { exact: true });
32
+ test.skip((await username.count()) === 0, "username not enabled");
33
+ await expect(username).toBeVisible();
34
+ });
35
+
36
+ test("account can create and revoke an API key when enabled @smoke", async ({ page }) => {
37
+ await ready(page, "/admin/account");
38
+ const nav = page.getByRole("button", { name: "API keys" });
39
+ test.skip((await nav.count()) === 0, "api keys not enabled");
40
+ await nav.click();
41
+ const name = `ui-${Date.now()}`;
42
+ await page.getByLabel("Name", { exact: true }).fill(name);
43
+ await page.getByRole("button", { name: "Create key" }).click();
44
+ // the full key is shown once in a dialog
45
+ await expect(page.getByText("API key created")).toBeVisible();
46
+ await page.getByRole("button", { name: "Done" }).click();
47
+ // it now appears in the list; revoke it to clean up
48
+ const row = page.getByRole("row", { name: new RegExp(name) });
49
+ await expect(row).toBeVisible();
50
+ await row.getByRole("button", { name: "Revoke" }).click();
51
+ await expect(page.getByText("Key revoked")).toBeVisible();
52
+ });
53
+
54
+ test("account can register and remove a passkey when enabled @smoke", async ({ page }) => {
55
+ await ready(page, "/admin/account");
56
+ await page.getByRole("button", { name: "Security" }).click();
57
+ const addBtn = page.getByRole("button", { name: "Add passkey" });
58
+ test.skip((await addBtn.count()) === 0, "passkeys not enabled");
59
+ // A virtual authenticator lets the WebAuthn ceremony resolve without hardware.
60
+ const client = await page.context().newCDPSession(page);
61
+ await client.send("WebAuthn.enable");
62
+ await client.send("WebAuthn.addVirtualAuthenticator", {
63
+ options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
64
+ });
65
+ const pkName = `pk-${Date.now()}`;
66
+ await page.getByLabel("Name", { exact: true }).fill(pkName);
67
+ await addBtn.click();
68
+ await expect(page.getByText("Passkey added")).toBeVisible();
69
+ const row = page.getByRole("row", { name: new RegExp(pkName) });
70
+ await expect(row).toBeVisible();
71
+ // remove it so the shared admin account keeps no leftover credential
72
+ await row.getByRole("button", { name: "Remove" }).click();
73
+ await expect(page.getByText("Passkey removed")).toBeVisible();
74
+ });
75
+
76
+ test("account shows a phone field when enabled @smoke", async ({ page }) => {
77
+ await ready(page, "/admin/account");
78
+ const phone = page.getByLabel("Phone number", { exact: true });
79
+ test.skip((await phone.count()) === 0, "phone number not enabled");
80
+ await expect(phone).toBeVisible();
81
+ });
82
+
16
83
  test("admin can update their profile name", async ({ page }) => {
17
84
  await ready(page, "/admin/account");
18
- await page.getByLabel("Name").fill("Admin Renamed");
85
+ await page.getByLabel("Name", { exact: true }).fill("Admin Renamed");
19
86
  await page.getByRole("button", { name: "Save changes" }).click();
20
87
  await expect(page.getByText("Profile updated")).toBeVisible();
21
88
  });
@@ -37,13 +104,17 @@ test("account nav shows the core sections", async ({ page }) => {
37
104
  await expect(page.getByRole("button", { name: "Sessions" })).toBeVisible();
38
105
  });
39
106
 
40
- test("two-factor setup is available when enabled", async ({ page }) => {
107
+ test("two-factor setup shows a scannable QR code when enabled @smoke", async ({ page }) => {
41
108
  await ready(page, "/admin/account");
42
109
  await page.getByRole("button", { name: "Security" }).click();
43
110
  const heading = page.getByText("Two-factor authentication");
44
111
  test.skip((await heading.count()) === 0, "two-factor not enabled");
45
- await expect(heading).toBeVisible();
46
- await expect(page.getByRole("button", { name: "Enable" })).toBeVisible();
112
+ await expect(page.getByRole("button", { name: "Enable", exact: true })).toBeVisible();
113
+ // Start setup (password + Enable) and confirm the QR renders. Stop before
114
+ // verifying so 2FA stays inactive and the shared admin session keeps working.
115
+ await page.locator("#tf-on-pw").fill("Podokit3e-Str0ng!pw");
116
+ await page.getByRole("button", { name: "Enable", exact: true }).click();
117
+ await expect(page.getByRole("img", { name: "TOTP QR code" })).toBeVisible();
47
118
  });
48
119
 
49
120
  test("danger zone offers account deletion when enabled", async ({ page }) => {
@@ -21,5 +21,5 @@ test("sidebar navigates to sessions and account", async ({ page }) => {
21
21
  await page.getByRole("menuitem", { name: "Account" }).click();
22
22
  await expect(page).toHaveURL(/\/admin\/account/);
23
23
  await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
24
- await expect(page.getByLabel("Name")).toBeVisible(); // profile section
24
+ await expect(page.getByLabel("Name", { exact: true })).toBeVisible(); // profile section (exact: "Username" also contains "Name")
25
25
  });
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // Sorting via the shared DataTable. Users is server-sorted (manual), audit is
5
+ // client-sorted — both drive the same clickable headers + aria-sort.
6
+ test("users table sorts by a column header (server) @smoke", async ({ page }) => {
7
+ await ready(page, "/admin/users");
8
+ const header = page.getByRole("columnheader", { name: /Name/ });
9
+ // Click the sort button *inside* the header — a bare name match collides with
10
+ // the sidebar user button (a "Renamed" account contains "name").
11
+ const sortByName = header.getByRole("button");
12
+ const firstName = () => page.getByRole("row").nth(1).getByRole("cell").first().innerText();
13
+
14
+ await sortByName.click();
15
+ await expect(header).toHaveAttribute("aria-sort", "ascending");
16
+ await page.waitForTimeout(400); // server refetch
17
+ const asc = await firstName();
18
+
19
+ await sortByName.click();
20
+ await expect(header).toHaveAttribute("aria-sort", "descending");
21
+ await page.waitForTimeout(400);
22
+ const desc = await firstName();
23
+
24
+ expect(asc).not.toBe(desc); // order flipped
25
+ });
26
+
27
+ test("audit table sorts client-side", async ({ page }) => {
28
+ await ready(page, "/admin/audit");
29
+ test.skip((await page.getByRole("row").count()) < 3, "not enough audit entries");
30
+ await page.getByRole("button", { name: "When" }).click(); // default desc -> asc
31
+ await expect(page.getByRole("columnheader", { name: /When/ })).toHaveAttribute("aria-sort", "ascending");
32
+ });
@@ -0,0 +1,60 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+ import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
4
+
5
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
+ const origin = { origin: base };
7
+
8
+ // Verification badges/actions are gated by the emailVerification capability. The
9
+ // seeded suite runs with verification off, so assert the gating here (nothing
10
+ // leaks in); the enabled state is covered manually / under a verification run.
11
+ test("verification badges stay hidden when the feature is off", async ({ page }) => {
12
+ await ready(page, "/admin/users");
13
+ const caps = await (await page.request.get("/api/account/capabilities")).json();
14
+ test.skip(caps.emailVerification === true, "email verification enabled");
15
+ await expect(page.getByText("Verified", { exact: true })).toHaveCount(0);
16
+ await expect(page.getByText("Unverified", { exact: true })).toHaveCount(0);
17
+ });
18
+
19
+ // Admin can trigger a password-reset email for a user (not verification-gated).
20
+ test("admin sends a password reset email to a user", async ({ page }) => {
21
+ test.skip(!(await mailpitReachable()), "Mailpit not available");
22
+ await ready(page, "/admin/users");
23
+ const email = `admin-reset-${Date.now()}@example.com`;
24
+ await page.request.post("/api/auth/admin/create-user", {
25
+ headers: { origin: base },
26
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "AR", role: "user" },
27
+ });
28
+ await clearMailpit();
29
+ await page.locator("#toolbar-search").fill(email);
30
+ await page.getByRole("button", { name: "Search", exact: true }).click();
31
+ await page.getByRole("row", { name: new RegExp(email) }).getByRole("button").click();
32
+ await page.getByRole("menuitem", { name: "Manage" }).click();
33
+ const dialog = page.getByRole("dialog");
34
+ await dialog.getByRole("button", { name: "Security" }).click();
35
+ await dialog.getByRole("button", { name: "Send password reset email" }).click();
36
+ await expect(page.getByText("Password reset email sent")).toBeVisible();
37
+ await waitForLink(email); // a reset email actually arrived for this user
38
+ });
39
+
40
+ test("login shows a verification hint for unverified users", async ({ page, browser }) => {
41
+ const caps = await (await page.request.get("/api/account/capabilities")).json();
42
+ test.skip(caps.emailVerification !== true, "email verification disabled");
43
+ const email = `login-unv-${Date.now()}@example.com`;
44
+ await page.request.post("/api/auth/sign-up/email", { headers: { origin: base }, data: { email, password: "Podokit3e-Str0ng!pw", name: "LU" } });
45
+ const anon = await browser.newContext();
46
+ const p2 = await anon.newPage();
47
+ await p2.goto(`${base}/login`);
48
+ await p2.getByLabel("Email").fill(email);
49
+ await p2.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
50
+ await p2.getByRole("button", { name: "Sign in" }).click();
51
+ await expect(p2.getByRole("link", { name: /resend verification/i })).toBeVisible();
52
+ await anon.close();
53
+ });
54
+
55
+ test("overview omits the unverified-users card when the feature is off", async ({ page }) => {
56
+ await ready(page, "/admin");
57
+ const caps = await (await page.request.get("/api/account/capabilities")).json();
58
+ test.skip(caps.emailVerification === true, "email verification enabled");
59
+ await expect(page.getByText("Unverified users")).toHaveCount(0);
60
+ });
@@ -0,0 +1,42 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { ready } from "../helpers/hydration";
4
+ import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
5
+
6
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
7
+ const origin = { origin: base };
8
+
9
+ // The reset flow is for signed-out users — run without the seeded session.
10
+ test.use({ storageState: anonState });
11
+
12
+ test("forgot password: request, open the emailed link, set a new password, sign in @smoke", async ({ page, playwright }) => {
13
+ test.skip(!(await mailpitReachable()), "Mailpit not available");
14
+ const email = `ui-reset-${Date.now()}@example.com`;
15
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
16
+ const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "UIReset" } });
17
+ expect(signup.ok()).toBeTruthy();
18
+ const verificationOn = !(await signup.json())?.token;
19
+ await ctx.dispose();
20
+ // The happy path ends by signing in; when verification is required that would be
21
+ // blocked, so this UI walkthrough only applies with verification off.
22
+ test.skip(verificationOn, "email verification enabled");
23
+ await clearMailpit();
24
+
25
+ await ready(page, "/forgot-password");
26
+ await page.getByLabel("Email").fill(email);
27
+ await page.getByRole("button", { name: "Send reset link" }).click();
28
+ await expect(page.getByText(/reset link is on its way/i)).toBeVisible();
29
+
30
+ // Open the emailed link: the API validates it and redirects to the reset page.
31
+ await page.goto(await waitForLink(email));
32
+ await page.waitForLoadState("load");
33
+ await page.waitForTimeout(400);
34
+ await page.getByLabel("New password").fill("Podokit3e-N3wStr0ng!pw");
35
+ await page.getByRole("button", { name: "Update password" }).click();
36
+ await expect(page).toHaveURL(/\/login/);
37
+
38
+ await page.getByLabel("Email").fill(email);
39
+ await page.getByLabel("Password").fill("Podokit3e-N3wStr0ng!pw");
40
+ await page.getByRole("button", { name: "Sign in" }).click();
41
+ await expect(page).toHaveURL(/\/admin/);
42
+ });
@@ -17,5 +17,6 @@ test.describe("normal user (non-admin)", () => {
17
17
  await expect(page.getByRole("link", { name: "Sessions" })).toHaveCount(0);
18
18
  expect((await page.goto("/admin/users"))?.status()).toBe(403);
19
19
  expect((await page.goto("/admin/sessions"))?.status()).toBe(403);
20
+ expect((await page.goto("/admin/settings"))?.status()).toBe(403);
20
21
  });
21
22
  });
@@ -0,0 +1,57 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+
4
+ // admin storageState (project default) — the admin is the resource owner.
5
+ const b64url = (b: Buffer): string => b.toString("base64url");
6
+ const REDIRECT = "http://localhost:9999/callback";
7
+
8
+ // Verifies the provider is correctly configured end to end on the front channel:
9
+ // signed discovery + JWKS, dynamic client registration, and an authorize request
10
+ // (with PKCE) that validates the client and hands off to the consent page. The
11
+ // back-channel token exchange is driven by an OIDC client SDK / real RP — see
12
+ // docs/modules.md for the manual round-trip checklist.
13
+ test("OIDC provider: discovery, JWKS, client registration, authorize handoff @smoke", async ({ page, baseURL }) => {
14
+ const headers = { origin: baseURL ?? "" };
15
+ await page.request.put("/api/account/settings", { data: { oidcProvider: true }, headers });
16
+ await expect
17
+ .poll(async () => (await page.request.get("/api/auth/.well-known/openid-configuration")).status(), { timeout: 8000 })
18
+ .toBe(200);
19
+
20
+ try {
21
+ const disc = await (await page.request.get("/api/auth/.well-known/openid-configuration")).json();
22
+ for (const field of ["issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint", "jwks_uri"]) {
23
+ expect(typeof disc[field]).toBe("string");
24
+ }
25
+ // JWKS exposes at least one signing key (the OAuth provider needs it for id_tokens).
26
+ const jwks = await (await page.request.get(disc.jwks_uri)).json();
27
+ expect(Array.isArray(jwks.keys) && jwks.keys.length).toBeTruthy();
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();
37
+ expect(reg.client_secret).toBeTruthy();
38
+
39
+ // Authorize validates the client + PKCE and hands off to the consent page with
40
+ // the signed authorization request.
41
+ const verifier = b64url(randomBytes(40));
42
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
43
+ const authorizeUrl =
44
+ `/api/auth/oauth2/authorize?client_id=${reg.client_id}&redirect_uri=${encodeURIComponent(REDIRECT)}` +
45
+ `&response_type=code&scope=${encodeURIComponent("openid profile email")}&state=st` +
46
+ `&code_challenge=${challenge}&code_challenge_method=S256`;
47
+ const authz = await (await page.request.get(authorizeUrl, { headers })).json();
48
+ expect(String(authz.url)).toContain("/oauth2/consent");
49
+ expect(String(authz.url)).toContain("client_id=");
50
+
51
+ // The consent page renders (reached in a real browser navigation).
52
+ await page.goto(authz.url);
53
+ await expect(page.getByRole("button", { name: "Allow" })).toBeVisible();
54
+ } finally {
55
+ await page.request.put("/api/account/settings", { data: { oidcProvider: false }, headers });
56
+ }
57
+ });
@@ -0,0 +1,52 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // admin storageState (project default) — organizations is an admin-only view
5
+ test("admin can create an organization @smoke", async ({ page }) => {
6
+ await ready(page, "/admin");
7
+ const nav = page.getByRole("link", { name: "Organizations" });
8
+ test.skip((await nav.count()) === 0, "organizations not enabled");
9
+ await nav.click();
10
+ await expect(page).toHaveURL(/\/admin\/organizations/);
11
+ await page.getByRole("button", { name: "New organization" }).click();
12
+ const name = `Acme ${Date.now()}`;
13
+ await page.getByLabel("Name", { exact: true }).fill(name);
14
+ await page.getByRole("dialog").getByRole("button", { name: "Create", exact: true }).click();
15
+ await expect(page.getByText("Organization created").first()).toBeVisible();
16
+ await expect(page.getByRole("cell", { name })).toBeVisible();
17
+ // clean up so the shared admin account keeps no leftover org
18
+ await page.getByRole("row", { name: new RegExp(name) }).getByRole("button", { name: "Delete" }).click();
19
+ await expect(page.getByText("Organization deleted").first()).toBeVisible();
20
+ });
21
+
22
+ test("create an org with a non-ascii name, a parent, and a manager @smoke", async ({ page }) => {
23
+ await ready(page, "/admin/organizations");
24
+ const newBtn = page.getByRole("button", { name: "New organization" });
25
+ test.skip((await newBtn.count()) === 0, "organizations not enabled");
26
+
27
+ // Parent org first (Korean name → slug falls back, so it still saves).
28
+ const parent = `본사 ${Date.now()}`;
29
+ await newBtn.click();
30
+ await page.getByLabel("Name", { exact: true }).fill(parent);
31
+ await page.getByRole("dialog").getByRole("button", { name: "Create", exact: true }).click();
32
+ await expect(page.getByText("Organization created").first()).toBeVisible();
33
+ await expect(page.getByRole("cell", { name: parent })).toBeVisible();
34
+
35
+ // Child org: pick the parent + a manager (an existing user).
36
+ const child = `영업 ${Date.now()}`;
37
+ await page.getByRole("button", { name: "New organization" }).click();
38
+ await page.getByLabel("Name", { exact: true }).fill(child);
39
+ await page.locator("#o-parent").click();
40
+ await page.getByRole("option", { name: parent }).click();
41
+ await page.getByRole("checkbox").first().click(); // first existing user as a manager
42
+ await page.getByRole("dialog").getByRole("button", { name: "Create", exact: true }).click();
43
+ await expect(page.getByText("Organization created").first()).toBeVisible();
44
+ // child row shows the parent org name
45
+ await expect(page.getByRole("row", { name: new RegExp(child) })).toContainText(parent);
46
+
47
+ // clean up both
48
+ for (const n of [child, parent]) {
49
+ await page.getByRole("row", { name: new RegExp(n) }).getByRole("button", { name: "Delete" }).click();
50
+ await expect(page.getByText("Organization deleted").first()).toBeVisible();
51
+ }
52
+ });
@@ -0,0 +1,36 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // Full passkey round trip on a throwaway account — never touches the shared admin.
5
+ test.use({ storageState: { cookies: [], origins: [] } });
6
+
7
+ test("register a passkey and sign in with it @smoke", async ({ page, baseURL }) => {
8
+ const origin = { origin: baseURL ?? "" };
9
+ // A virtual authenticator answers both the register (create) and sign-in (get)
10
+ // WebAuthn ceremonies without hardware.
11
+ const client = await page.context().newCDPSession(page);
12
+ await client.send("WebAuthn.enable");
13
+ await client.send("WebAuthn.addVirtualAuthenticator", {
14
+ options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
15
+ });
16
+
17
+ // Sign up a throwaway user (shares the page's cookie jar) and register a passkey.
18
+ const email = `pk-${Date.now()}@example.com`;
19
+ const pw = "Podokit3e-Str0ng!pw";
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();
22
+
23
+ await ready(page, "/admin/account");
24
+ await page.getByRole("button", { name: "Security" }).click();
25
+ const addBtn = page.getByRole("button", { name: "Add passkey" });
26
+ test.skip((await addBtn.count()) === 0, "passkeys not enabled");
27
+ await page.getByLabel("Name", { exact: true }).fill("My device");
28
+ await addBtn.click();
29
+ await expect(page.getByText("Passkey added")).toBeVisible();
30
+
31
+ // Sign out, then sign back in using only the passkey.
32
+ await page.context().request.post("/api/auth/sign-out", { headers: origin });
33
+ await ready(page, "/login");
34
+ await page.getByRole("button", { name: "Use a passkey" }).click();
35
+ await expect(page).toHaveURL(/\/admin(\/|$)/);
36
+ });
@@ -2,11 +2,18 @@ import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
4
  // admin storageState (project default) — sessions is an admin-only view
5
+ test("sessions table shows the expires column @smoke", async ({ page }) => {
6
+ await ready(page, "/admin/sessions");
7
+ await expect(page.getByRole("columnheader", { name: "Expires" })).toBeVisible();
8
+ });
9
+
5
10
  test("admin sees user sessions across users @smoke", async ({ page }) => {
6
11
  await ready(page, "/admin/sessions");
7
12
  await expect(page.getByRole("heading", { name: "User sessions" })).toBeVisible();
8
- // another user's session is listed here (the across-users admin view); scope to
9
- // the table, not the sidebar, and match the first row.
13
+ // Search by exact address — robust to pagination as sessions accumulate (the
14
+ // admin account alone can fill the first page). This is the across-users view.
15
+ await page.locator("#toolbar-search").fill("user@example.com");
16
+ await page.getByRole("button", { name: "Search", exact: true }).click();
10
17
  await expect(page.getByRole("main").getByText("user@example.com").first()).toBeVisible();
11
18
  // row actions are available
12
19
  await page.getByRole("row", { name: /user@example.com/ }).first().getByRole("button").click();
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // admin storageState (project default)
5
+ test("settings lists the auth features and their status @smoke", async ({ page }) => {
6
+ await ready(page, "/admin/settings");
7
+ await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
8
+ await expect(page.getByText("Email & password")).toBeVisible();
9
+ await expect(page.getByText("Two-factor authentication")).toBeVisible();
10
+ await expect(page.getByText("Breached-password check")).toBeVisible();
11
+ await expect(page.getByText("Magic link")).toBeVisible();
12
+ // each feature carries an explicit status
13
+ await expect(page.getByText("Enabled", { exact: true }).first()).toBeVisible();
14
+ });
15
+
16
+ test("settings is reachable from the sidebar @smoke", async ({ page }) => {
17
+ await ready(page, "/admin");
18
+ await page.getByRole("link", { name: "Settings" }).click();
19
+ await expect(page).toHaveURL(/\/admin\/settings/);
20
+ });
21
+
22
+ test("admin can toggle a feature flag and it applies live @smoke", async ({ page }) => {
23
+ await ready(page, "/admin/settings");
24
+ const toggle = page.getByRole("switch", { name: "Magic link" });
25
+ test.skip((await toggle.count()) === 0, "magic link toggle not present");
26
+ // Serial workers (workers:1) make toggle-then-restore safe for other specs.
27
+ await expect(toggle).toBeChecked();
28
+ await toggle.click();
29
+ await expect(page.getByText("Setting updated")).toBeVisible();
30
+ await expect(toggle).not.toBeChecked(); // capabilities refreshed live
31
+ // restore so the shared magic-link/2FA specs keep their expected state
32
+ await toggle.click();
33
+ await expect(toggle).toBeChecked();
34
+ });
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // The shared toolbar: filters + search both commit on the Search button; the
5
+ // search field itself is selectable (Email, Name, ...).
6
+ test("users toolbar searches only when Search is pressed @smoke", async ({ page }) => {
7
+ await ready(page, "/admin/users");
8
+ const main = page.getByRole("main");
9
+ await page.locator("#toolbar-search").fill("admin@example.com");
10
+ await page.getByRole("button", { name: "Search", exact: true }).click();
11
+ await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
12
+ await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
13
+ });
14
+
15
+ test("users toolbar applies filter + search together on Search", async ({ page }) => {
16
+ await ready(page, "/admin/users");
17
+ const main = page.getByRole("main");
18
+ await page.locator('button[data-slot="select-trigger"]').first().click(); // Role select
19
+ await page.getByRole("option", { name: "Admin", exact: true }).click();
20
+ // Selecting a filter stages it; nothing applies until Search is pressed.
21
+ await page.getByRole("button", { name: "Search", exact: true }).click();
22
+ await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
23
+ await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
24
+ });
25
+
26
+ test("users toolbar can pick the search field", async ({ page }) => {
27
+ await ready(page, "/admin/users");
28
+ const fieldSelect = page.locator('button[data-slot="select-trigger"]').last(); // after the filters
29
+ await fieldSelect.click();
30
+ await page.getByRole("option", { name: "Name", exact: true }).click();
31
+ await expect(fieldSelect).toContainText("Name");
32
+ });
@@ -1,21 +1,22 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
- const base = process.env.E2E_BASE_URL ?? "http://localhost:5173";
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
 
6
6
  // Seed a throwaway user via the API (admin cookies come from storageState) so
7
7
  // the mutating UI tests don't touch the shared admin/user seed accounts.
8
8
  async function seedUser(page: Page, email: string): Promise<void> {
9
9
  const res = await page.request.post("/api/auth/admin/create-user", {
10
10
  headers: { origin: base },
11
- data: { email, password: "password123", name: "Throwaway", role: "user" },
11
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "Throwaway", role: "user" },
12
12
  });
13
13
  expect(res.ok()).toBeTruthy();
14
14
  }
15
15
 
16
16
  // Open the Manage two-pane modal for a seeded user.
17
17
  async function openManage(page: Page, email: string): Promise<void> {
18
- await page.getByPlaceholder("Search by email…").fill(email);
18
+ await page.locator("#toolbar-search").fill(email);
19
+ await page.getByRole("button", { name: "Search", exact: true }).click();
19
20
  await page.getByRole("row", { name: new RegExp(email) }).getByRole("button").click();
20
21
  await page.getByRole("menuitem", { name: "Manage" }).click();
21
22
  }
@@ -27,10 +28,14 @@ test("admin can create a user via the dialog @smoke", async ({ page }) => {
27
28
  const dialog = page.getByRole("dialog");
28
29
  await dialog.getByLabel("Name").fill("Created");
29
30
  await dialog.getByLabel("Email").fill(email);
30
- await dialog.getByLabel("Password", { exact: true }).fill("password123");
31
- await dialog.getByLabel("Confirm password").fill("password123");
31
+ await dialog.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
32
+ await dialog.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
32
33
  await dialog.getByRole("button", { name: "Create" }).click();
33
- await page.getByPlaceholder("Search by email…").fill(email);
34
+ // Wait for the create to settle (dialog closes + list reloads) before searching,
35
+ // otherwise the search input can be refilled by the post-create reload.
36
+ await expect(page.getByRole("dialog")).toBeHidden();
37
+ await page.locator("#toolbar-search").fill(email);
38
+ await page.getByRole("button", { name: "Search", exact: true }).click();
34
39
  await expect(page.getByRole("cell", { name: email })).toBeVisible();
35
40
  });
36
41
 
@@ -40,7 +45,7 @@ test("create rejects mismatched passwords", async ({ page }) => {
40
45
  const dialog = page.getByRole("dialog");
41
46
  await dialog.getByLabel("Name").fill("Mismatch");
42
47
  await dialog.getByLabel("Email").fill(`ui-mismatch-${Date.now()}@example.com`);
43
- await dialog.getByLabel("Password", { exact: true }).fill("password123");
48
+ await dialog.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
44
49
  await dialog.getByLabel("Confirm password").fill("different999");
45
50
  await dialog.getByRole("button", { name: "Create" }).click();
46
51
  await expect(dialog.getByText("Passwords do not match")).toBeVisible();
@@ -64,11 +69,11 @@ test("manage: set a password (and reject mismatch)", async ({ page }) => {
64
69
  await openManage(page, email);
65
70
  const dialog = page.getByRole("dialog");
66
71
  await dialog.getByRole("button", { name: "Security" }).click();
67
- await dialog.getByLabel("New password", { exact: true }).fill("newpass1234");
72
+ await dialog.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
68
73
  await dialog.getByLabel("Confirm new password").fill("different999");
69
74
  await dialog.getByRole("button", { name: "Set password" }).click();
70
75
  await expect(dialog.getByText("Passwords do not match")).toBeVisible();
71
- await dialog.getByLabel("Confirm new password").fill("newpass1234");
76
+ await dialog.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pw");
72
77
  await dialog.getByRole("button", { name: "Set password" }).click();
73
78
  await expect(page.getByText("Password updated")).toBeVisible();
74
79
  });
@@ -91,7 +96,7 @@ test("manage: revoke a user's session", async ({ page, playwright }) => {
91
96
  await seedUser(page, email);
92
97
  // create a session for the user in an isolated context (don't touch the admin cookie)
93
98
  const uctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
94
- expect((await uctx.post("/api/auth/sign-in/email", { data: { email, password: "password123" } })).ok()).toBeTruthy();
99
+ expect((await uctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok()).toBeTruthy();
95
100
  await uctx.dispose();
96
101
  await openManage(page, email);
97
102
  const dialog = page.getByRole("dialog");
@@ -110,7 +115,8 @@ test("manage: delete a user from the danger zone", async ({ page }) => {
110
115
  await dialog.getByRole("button", { name: "Delete" }).click(); // arm
111
116
  await dialog.getByRole("button", { name: "Delete" }).click(); // confirm
112
117
  await expect(page.getByText("User deleted")).toBeVisible();
113
- await page.getByPlaceholder("Search by email…").fill(email);
118
+ await page.locator("#toolbar-search").fill(email);
119
+ await page.getByRole("button", { name: "Search", exact: true }).click();
114
120
  await expect(page.getByRole("cell", { name: email })).toHaveCount(0);
115
121
  });
116
122
 
@@ -119,7 +125,7 @@ test("users list shows pagination beyond one page", async ({ page }) => {
119
125
  for (let i = 0; i < 6; i += 1) {
120
126
  await page.request.post("/api/auth/admin/create-user", {
121
127
  headers: { origin: base },
122
- data: { email: `pg-${Date.now()}-${i}@example.com`, password: "password123", name: `Pg${i}`, role: "user" },
128
+ data: { email: `pg-${Date.now()}-${i}@example.com`, password: "Podokit3e-Str0ng!pw", name: `Pg${i}`, role: "user" },
123
129
  });
124
130
  }
125
131
  await page.reload();
@@ -127,3 +133,13 @@ test("users list shows pagination beyond one page", async ({ page }) => {
127
133
  await expect(nav).toBeVisible();
128
134
  await expect(nav.getByRole("button", { name: "Page 2" })).toBeVisible();
129
135
  });
136
+
137
+ test("manage modal closes from its footer", async ({ page }) => {
138
+ await ready(page, "/admin/users");
139
+ const email = `ui-close-${Date.now()}@example.com`;
140
+ await seedUser(page, email);
141
+ await openManage(page, email);
142
+ // footer Close (a visible button, distinct from the header X) dismisses the modal
143
+ await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click();
144
+ await expect(page.getByRole("dialog")).toBeHidden();
145
+ });