create-m5kdev 0.32.8 → 0.34.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 (152) hide show
  1. package/dist/src/catalog.cjs +76 -11
  2. package/dist/src/catalog.cjs.map +1 -1
  3. package/dist/src/catalog.d.cts +13 -1
  4. package/dist/src/create.cjs +6 -2
  5. package/dist/src/create.cjs.map +1 -1
  6. package/dist/src/doctor.cjs +13 -7
  7. package/dist/src/doctor.cjs.map +1 -1
  8. package/dist/src/fs.cjs +10 -5
  9. package/dist/src/fs.cjs.map +1 -1
  10. package/dist/src/prompts.cjs +32 -0
  11. package/dist/src/prompts.cjs.map +1 -1
  12. package/dist/src/prompts.d.cts +7 -1
  13. package/dist/src/template.cjs +23 -4
  14. package/dist/src/template.cjs.map +1 -1
  15. package/dist/src/template.d.cts +17 -3
  16. package/dist/src/types.d.cts +11 -4
  17. package/package.json +4 -1
  18. package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +1 -1
  19. package/templates/minimal-app/.cursor/rules/module-grants-guide.mdc +3 -3
  20. package/templates/minimal-app/.cursor/rules/module-module-guide.mdc +3 -2
  21. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +1 -1
  22. package/templates/minimal-app/.dockerignore.tpl +31 -0
  23. package/templates/minimal-app/.gitignore.tpl +1 -0
  24. package/templates/minimal-app/AGENTS.md.tpl +13 -10
  25. package/templates/minimal-app/CLAUDE.md.tpl +132 -131
  26. package/templates/minimal-app/README.md.tpl +50 -19
  27. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -23
  28. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +51 -39
  29. package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -38
  30. package/templates/minimal-app/apps/e2e/servers.ts.tpl +136 -112
  31. package/templates/minimal-app/apps/e2e/tests/conversation.spec.ts.tpl +22 -0
  32. package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -47
  33. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -73
  34. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -517
  35. package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -94
  36. package/templates/minimal-app/apps/e2e/tests/files.spec.ts.tpl +35 -0
  37. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -289
  38. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -516
  39. package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -107
  40. package/templates/minimal-app/apps/e2e/tests/workflow.spec.ts.tpl +26 -0
  41. package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -10
  42. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  43. package/templates/minimal-app/apps/email/src/emails/trialEndingEmail.tsx.tpl +40 -0
  44. package/templates/minimal-app/apps/email/src/index.ts.tpl +7 -0
  45. package/templates/minimal-app/apps/email/src/resources.ts.tpl +72 -65
  46. package/templates/minimal-app/apps/expo/app.json.tpl +36 -36
  47. package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -8
  48. package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -16
  49. package/templates/minimal-app/apps/expo/package.json.tpl +59 -59
  50. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -31
  51. package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -151
  52. package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -53
  53. package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -8
  54. package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -71
  55. package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -19
  56. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -85
  57. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -40
  58. package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -75
  59. package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -193
  60. package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -34
  61. package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -37
  62. package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -10
  63. package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -22
  64. package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -9
  65. package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -11
  66. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -20
  67. package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -11
  68. package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -1
  69. package/templates/minimal-app/apps/landing/.env.production.example.tpl +7 -0
  70. package/templates/minimal-app/apps/landing/Dockerfile.tpl +39 -0
  71. package/templates/minimal-app/apps/landing/fly.toml.tpl +20 -0
  72. package/templates/minimal-app/apps/landing/index.html.tpl +35 -0
  73. package/templates/minimal-app/apps/landing/package.json.tpl +34 -0
  74. package/templates/minimal-app/apps/landing/scripts/fly-deploy.mjs.tpl +75 -0
  75. package/templates/minimal-app/apps/landing/scripts/fly-secrets.mjs.tpl +31 -0
  76. package/templates/minimal-app/apps/landing/src/App.tsx.tpl +12 -0
  77. package/templates/minimal-app/apps/landing/src/LandingPage.tsx.tpl +37 -0
  78. package/templates/minimal-app/apps/landing/src/index.css.tpl +24 -0
  79. package/templates/minimal-app/apps/landing/src/main.tsx.tpl +16 -0
  80. package/templates/minimal-app/apps/landing/src/vite-env.d.ts.tpl +11 -0
  81. package/templates/minimal-app/apps/landing/tsconfig.json.tpl +12 -0
  82. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +25 -0
  83. package/templates/minimal-app/apps/server/AGENTS.md.tpl +54 -46
  84. package/templates/minimal-app/apps/server/{drizzle/seed.e2e.ts.tpl → db.e2e.ts.tpl} +57 -40
  85. package/templates/minimal-app/apps/server/{drizzle/seed.ts.tpl → db.ts.tpl} +27 -20
  86. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +3 -0
  87. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +27 -0
  88. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +56 -166
  89. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +1796 -1910
  90. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +1818 -0
  91. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +2137 -0
  92. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +15 -1
  93. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +3 -33
  94. package/templates/minimal-app/apps/server/package.json.tpl +57 -56
  95. package/templates/minimal-app/apps/server/src/app.ts.tpl +149 -141
  96. package/templates/minimal-app/apps/server/src/index.ts.tpl +12 -65
  97. package/templates/minimal-app/apps/server/src/modules/conversation/conversation.mastra.ts.tpl +26 -0
  98. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.module.ts.tpl +47 -0
  99. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.service.ts.tpl +43 -0
  100. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.trpc.ts.tpl +16 -0
  101. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +28 -28
  102. package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +28 -30
  103. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
  104. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -67
  105. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +116 -115
  106. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -37
  107. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -279
  108. package/templates/minimal-app/apps/server/src/schema.ts.tpl +31 -20
  109. package/templates/minimal-app/apps/server/src/types.ts.tpl +12 -1
  110. package/templates/minimal-app/apps/server/tsconfig.json.tpl +1 -1
  111. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +23 -9
  112. package/templates/minimal-app/apps/shared/.env.example.tpl +5 -0
  113. package/templates/minimal-app/apps/shared/.env.production.example.tpl +23 -0
  114. package/templates/minimal-app/apps/shared/.env.tpl +5 -0
  115. package/templates/minimal-app/apps/shared/Dockerfile.tpl +48 -0
  116. package/templates/minimal-app/apps/shared/fly.toml.tpl +24 -0
  117. package/templates/minimal-app/apps/shared/package.json.tpl +25 -24
  118. package/templates/minimal-app/apps/shared/scripts/fly-deploy.mjs.tpl +75 -0
  119. package/templates/minimal-app/apps/shared/scripts/fly-secrets.mjs.tpl +31 -0
  120. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -28
  121. package/templates/minimal-app/apps/shared/src/modules/conversation/conversation.constants.ts.tpl +1 -0
  122. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
  123. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
  124. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -25
  125. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -53
  126. package/templates/minimal-app/apps/webapp/public/push-sw.js.tpl +34 -34
  127. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +46 -44
  128. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +96 -65
  129. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +114 -93
  130. package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -15
  131. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
  132. package/templates/minimal-app/apps/webapp/src/modules/conversation/ConversationRoute.tsx.tpl +36 -0
  133. package/templates/minimal-app/apps/webapp/src/modules/files/FilesRoute.tsx.tpl +88 -0
  134. package/templates/minimal-app/apps/webapp/src/modules/files/hooks/useFilesRoute.ts.tpl +50 -0
  135. package/templates/minimal-app/apps/webapp/src/modules/workflows/WorkflowsRoute.tsx.tpl +97 -0
  136. package/templates/minimal-app/apps/webapp/src/modules/workflows/hooks/useWorkflowsRoute.ts.tpl +64 -0
  137. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
  138. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -10
  139. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
  140. package/templates/minimal-app/apps/webapp/translations/en/app.json.tpl +7 -7
  141. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +193 -135
  142. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
  143. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
  144. package/templates/minimal-app/biome.json.tpl +17 -12
  145. package/templates/minimal-app/package.json.tpl +9 -5
  146. package/templates/minimal-app/pnpm-workspace.yaml.tpl +67 -58
  147. package/templates/minimal-app/template.manifest.json +92 -2
  148. package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +0 -26
  149. package/templates/minimal-app/apps/server/drizzle/guard.ts.tpl +0 -36
  150. package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +0 -40
  151. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +0 -16
  152. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +0 -12
@@ -1,516 +1,516 @@
1
- import { type APIRequestContext, expect, type Page, test } from "@playwright/test";
2
- import {
3
- authFetch,
4
- clearEmails,
5
- createProvisionedClaimUser,
6
- emailUrl,
7
- expectLoginRejected,
8
- getUserState,
9
- latestEmail,
10
- login,
11
- logout,
12
- profiles,
13
- resetTokenFrom,
14
- seedOrganizations,
15
- signUp,
16
- verifyLatestEmail,
17
- } from "./helpers";
18
-
19
- const profile = "standard" as const;
20
- const enterpriseOrgId = "auth-e2e-enterprise-standard";
21
-
22
- async function createVerifiedAccount(
23
- page: Page,
24
- request: APIRequestContext,
25
- email: string,
26
- password: string
27
- ) {
28
- await signUp(page, email, password);
29
- await verifyLatestEmail(request, profile, email);
30
- }
31
-
32
- test.beforeEach(async ({ request }) => {
33
- await clearEmails(request, profile);
34
- });
35
-
36
- test("signup, verification email, login, protected app, and logout", async ({ page, request }) => {
37
- const email = `signup.${Date.now()}@auth-e2e.local`;
38
- const password = "password1234";
39
-
40
- // posts live on the protected index route
41
- await page.goto("/");
42
- await expect(page).toHaveURL(/\/login/);
43
-
44
- await signUp(page, email, password);
45
- await expect(page.getByText(/verification email sent/i)).toBeVisible();
46
-
47
- const verification = await latestEmail(request, profile, {
48
- to: email,
49
- templateId: "verification",
50
- });
51
- await request.get(emailUrl(verification));
52
-
53
- await login(page, email, password);
54
- await expect(page.getByTestId("posts-route")).toBeVisible();
55
- await expect(page.getByText(/small blog that protects auth changes/i).first()).toBeVisible();
56
-
57
- await logout(page);
58
- });
59
-
60
- test("password reset changes credentials through stored email", async ({ page, request }) => {
61
- const email = `reset.${Date.now()}@auth-e2e.local`;
62
- const oldPassword = "password1234";
63
- const newPassword = "password5678";
64
-
65
- await createVerifiedAccount(page, request, email, oldPassword);
66
-
67
- await page.goto("/forgot-password");
68
- await page.locator('[name="forgot-password-email"]').fill(email);
69
- const resetRequestPromise = page.waitForResponse(
70
- (response) =>
71
- response.url().includes("/api/auth/request-password-reset") &&
72
- response.request().method() === "POST"
73
- );
74
- await page.getByRole("button", { name: /reset password/i }).click();
75
- const resetRequest = await resetRequestPromise;
76
- expect(
77
- resetRequest.ok(),
78
- `${resetRequest.status()} ${resetRequest.statusText()}: ${await resetRequest.text()}`
79
- ).toBe(true);
80
-
81
- const resetEmail = await latestEmail(request, profile, {
82
- to: email,
83
- templateId: "password-reset",
84
- });
85
- const token = resetTokenFrom(emailUrl(resetEmail));
86
-
87
- await page.goto(`/reset-password?token=${encodeURIComponent(token)}`);
88
- await page.locator('[name="reset-password-new"]').fill(newPassword);
89
- await page.locator('[name="reset-password-confirm"]').fill(newPassword);
90
- await page.getByRole("button", { name: /reset password/i }).click();
91
- await expect(page.getByText(/password reset successfully/i)).toBeVisible();
92
-
93
- await expectLoginRejected(page, email, oldPassword);
94
- await login(page, email, newPassword);
95
- });
96
-
97
- test("invalid reset and verification tokens are rejected", async ({ page, request }) => {
98
- await page.goto("/reset-password?token=invalid-reset-token");
99
- await page.locator('[name="reset-password-new"]').fill("password5678");
100
- await page.locator('[name="reset-password-confirm"]').fill("password5678");
101
- const resetResponsePromise = page.waitForResponse(
102
- (response) =>
103
- response.url().includes("/api/auth/reset-password") && response.request().method() === "POST"
104
- );
105
- await page.getByRole("button", { name: /reset password/i }).click();
106
- const resetResponse = await resetResponsePromise;
107
- expect(
108
- resetResponse.ok(),
109
- `${resetResponse.status()} ${resetResponse.statusText()}: ${await resetResponse.text()}`
110
- ).toBe(false);
111
-
112
- const verificationUrl = new URL(`${profiles.standard.serverUrl}/api/auth/verify-email`);
113
- verificationUrl.searchParams.set("token", "invalid-verification-token");
114
- verificationUrl.searchParams.set("callbackURL", `${profiles.standard.webUrl}/login`);
115
- const verificationResponse = await request.get(verificationUrl.toString(), { maxRedirects: 0 });
116
- expect(verificationResponse.ok()).toBe(false);
117
- });
118
-
119
- test("duplicate signup with an existing email is rejected", async ({ page, request }) => {
120
- const email = `duplicate.${Date.now()}@auth-e2e.local`;
121
- const password = "password1234";
122
-
123
- await createVerifiedAccount(page, request, email, password);
124
-
125
- await page.goto("/signup");
126
- await page.locator('[name="signup-email"]').fill(email);
127
- await page.locator('[name="signup-password"]').fill(password);
128
- const responsePromise = page.waitForResponse(
129
- (response) =>
130
- response.url().includes("/api/auth/sign-up/email") && response.request().method() === "POST"
131
- );
132
- await page.getByRole("button", { name: /sign up/i }).click();
133
- const response = await responsePromise;
134
- expect(
135
- response.ok(),
136
- `${response.status()} ${response.statusText()}: ${await response.text()}`
137
- ).toBe(false);
138
- });
139
-
140
- test("organization invite email lets a new user join without creating a separate org", async ({
141
- page,
142
- request,
143
- }) => {
144
- const invitee = `member.${Date.now()}@auth-e2e.local`;
145
-
146
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
147
- await page.goto("/organization/members");
148
- await page.locator('input[type="email"]').fill(invitee);
149
- await page.getByRole("button", { name: /invite/i }).click();
150
-
151
- const invite = await latestEmail(request, profile, {
152
- to: invitee,
153
- templateId: "organization-invite",
154
- });
155
-
156
- await logout(page);
157
- await page.goto(emailUrl(invite));
158
- await expect(page).toHaveURL(/\/signup/);
159
- await page.locator('[name="signup-password"]').fill("password1234");
160
- await page.getByRole("button", { name: /sign up/i }).click();
161
- await expect(page.getByTestId("session-email")).toContainText(invitee);
162
-
163
- const state = await getUserState(request, profile, invitee);
164
- expect(state.user.emailVerified).toBe(true);
165
- expect(state.organizations.map((organization) => organization.id)).toEqual([enterpriseOrgId]);
166
- expect(state.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
167
- });
168
-
169
- test("organization invite lets an existing user join an additional org", async ({
170
- page,
171
- request,
172
- }) => {
173
- const email = `existing-member.${Date.now()}@auth-e2e.local`;
174
- const password = "password1234";
175
-
176
- await createVerifiedAccount(page, request, email, password);
177
- const beforeInvite = await getUserState(request, profile, email);
178
- expect(beforeInvite.organizations).toHaveLength(1);
179
- expect(beforeInvite.organizations[0]?.id).not.toBe(enterpriseOrgId);
180
-
181
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
182
- await page.goto("/organization/members");
183
- await page.locator('input[type="email"]').fill(email);
184
- await page.getByRole("button", { name: /invite/i }).click();
185
-
186
- const invite = await latestEmail(request, profile, {
187
- to: email,
188
- templateId: "organization-invite",
189
- });
190
-
191
- await logout(page);
192
- await login(page, email, password);
193
- await page.goto(emailUrl(invite));
194
-
195
- await expect
196
- .poll(async () => {
197
- const state = await getUserState(request, profile, email);
198
- return state.organizations.length;
199
- })
200
- .toBe(2);
201
-
202
- const afterInvite = await getUserState(request, profile, email);
203
- expect(afterInvite.organizations.map((organization) => organization.id)).toContain(
204
- enterpriseOrgId
205
- );
206
- expect(afterInvite.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
207
- });
208
-
209
- test("admin creates, bans, unbans, and authenticates a user", async ({ page, request }) => {
210
- const email = `admin-created.${Date.now()}@auth-e2e.local`;
211
- const password = "password1234";
212
-
213
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
214
- await page.goto("/admin/users");
215
- await page.getByRole("button", { name: /create user/i }).click();
216
-
217
- const dialog = page.getByRole("dialog");
218
- await dialog.getByPlaceholder(/enter user's name/i).fill("Admin Created User");
219
- await dialog.getByPlaceholder(/enter user's email/i).fill(email);
220
- await dialog.getByPlaceholder(/enter password/i).fill(password);
221
- await dialog.getByRole("button", { name: /^create user$/i }).click();
222
-
223
- const createdRow = page.getByRole("row").filter({ hasText: email });
224
- await expect(createdRow).toBeVisible();
225
- await createdRow.getByLabel("User actions").click();
226
- await page.getByRole("menuitem", { name: /^ban$/i }).click();
227
-
228
- const banDialog = page.getByRole("dialog");
229
- await banDialog.getByLabel(/ban reason/i).fill("E2E status update");
230
- await banDialog.getByRole("button", { name: /^ban user$/i }).click();
231
- await expect(createdRow.getByText(/banned/i)).toBeVisible();
232
-
233
- await createdRow.getByLabel("User actions").click();
234
- await page.getByRole("menuitem", { name: /^unban$/i }).click();
235
- await expect(createdRow.getByText(/active/i)).toBeVisible();
236
-
237
- const createdState = await getUserState(request, profile, email);
238
- expect(createdState.user.role).toBe("user");
239
- const setRoleResponse = await authFetch(page, profile, "/api/auth/admin/set-role", {
240
- method: "POST",
241
- body: {
242
- userId: createdState.user.id,
243
- role: "admin",
244
- },
245
- });
246
- expect(
247
- setRoleResponse.ok,
248
- `${setRoleResponse.status} ${setRoleResponse.statusText}: ${setRoleResponse.text}`
249
- ).toBe(true);
250
-
251
- await expect
252
- .poll(async () => {
253
- const state = await getUserState(request, profile, email);
254
- return state.user.role;
255
- })
256
- .toBe("admin");
257
-
258
- await logout(page);
259
- const verificationResponse = await request.post(
260
- `${profiles.standard.serverUrl}/api/auth/send-verification-email`,
261
- {
262
- data: {
263
- email,
264
- callbackURL: `${profiles.standard.webUrl}/login`,
265
- },
266
- }
267
- );
268
- expect(verificationResponse.ok()).toBe(true);
269
- const verification = await latestEmail(request, profile, {
270
- to: email,
271
- templateId: "verification",
272
- });
273
- await request.get(emailUrl(verification));
274
- await login(page, email, password);
275
- const adminListResponse = await authFetch(page, profile, "/api/auth/admin/list-users");
276
- expect(
277
- adminListResponse.ok,
278
- `${adminListResponse.status} ${adminListResponse.statusText}: ${adminListResponse.text}`
279
- ).toBe(true);
280
- });
281
-
282
- test("admin manages organization members from organization admin", async ({ page, request }) => {
283
- const email = `admin-org-member.${Date.now()}@auth-e2e.local`;
284
- const password = "password1234";
285
-
286
- await createVerifiedAccount(page, request, email, password);
287
- const beforeMembership = await getUserState(request, profile, email);
288
- expect(beforeMembership.organizations.map((organization) => organization.id)).not.toContain(
289
- enterpriseOrgId
290
- );
291
-
292
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
293
- await page.goto("/admin/organizations");
294
- await page.locator('input[name="search"]').fill("Auth E2E Enterprise standard");
295
-
296
- const organizationRow = page.getByRole("row").filter({
297
- hasText: "Auth E2E Enterprise standard",
298
- });
299
- await expect(organizationRow).toBeVisible();
300
- await organizationRow.getByLabel(/manage members/i).click();
301
-
302
- const dialog = page.getByRole("dialog").filter({ hasText: "Manage Members" });
303
- await expect(dialog.getByText("Auth E2E Enterprise standard")).toBeVisible();
304
- await dialog.getByText("Select user", { exact: true }).click();
305
- await page.getByPlaceholder("Search users...").fill(email);
306
- await page.getByRole("option", { name: new RegExp(email) }).click();
307
- await dialog.getByRole("button", { name: /add member/i }).click();
308
-
309
- const memberRow = dialog.getByRole("row").filter({ hasText: email });
310
- await expect(memberRow).toBeVisible();
311
- await expect
312
- .poll(async () => {
313
- const state = await getUserState(request, profile, email);
314
- return (
315
- state.organizations.find((organization) => organization.id === enterpriseOrgId)?.role ??
316
- null
317
- );
318
- })
319
- .toBe("member");
320
-
321
- await memberRow.getByRole("button", { name: `Member Role for ${email}` }).click();
322
- await page.getByRole("option", { name: /^Admin$/ }).click();
323
- await expect
324
- .poll(async () => {
325
- const state = await getUserState(request, profile, email);
326
- return (
327
- state.organizations.find((organization) => organization.id === enterpriseOrgId)?.role ??
328
- null
329
- );
330
- })
331
- .toBe("admin");
332
-
333
- await memberRow.getByRole("button", { name: `Remove ${email}` }).click();
334
- await expect(memberRow).toBeHidden();
335
- await expect
336
- .poll(async () => {
337
- const state = await getUserState(request, profile, email);
338
- return state.organizations.some((organization) => organization.id === enterpriseOrgId);
339
- })
340
- .toBe(false);
341
- });
342
-
343
- test("admin organization pagination stays on the selected page", async ({ page, request }) => {
344
- const prefix = `Pagination Org ${Date.now()}`;
345
- await seedOrganizations(request, profile, { prefix, count: 12 });
346
-
347
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
348
- await page.goto("/admin/organizations");
349
- await page.locator('input[name="search"]').fill(prefix);
350
-
351
- await expect(page.getByText("1 to 10 of 12 results")).toBeVisible();
352
- await page.getByRole("button", { name: /next/i }).click();
353
-
354
- await expect(page).toHaveURL(/om_p=2/);
355
- await expect(page.getByText("11 to 12 of 12 results")).toBeVisible();
356
- });
357
-
358
- test("non-admin users cannot call admin auth APIs", async ({ page, request }) => {
359
- const email = `non-admin.${Date.now()}@auth-e2e.local`;
360
- const password = "password1234";
361
-
362
- await createVerifiedAccount(page, request, email, password);
363
- await login(page, email, password);
364
-
365
- const response = await authFetch(page, profile, "/api/auth/admin/list-users");
366
- expect(response.ok, `${response.status} ${response.statusText}: ${response.text}`).toBe(false);
367
- expect([401, 403]).toContain(response.status);
368
- });
369
-
370
- test("session management endpoints list and revoke sessions", async ({ page, request }) => {
371
- const email = `sessions.${Date.now()}@auth-e2e.local`;
372
- const password = "password1234";
373
-
374
- await createVerifiedAccount(page, request, email, password);
375
- await login(page, email, password);
376
-
377
- const listResponse = await authFetch(page, profile, "/api/auth/list-sessions");
378
- expect(
379
- listResponse.ok,
380
- `${listResponse.status} ${listResponse.statusText}: ${listResponse.text}`
381
- ).toBe(true);
382
- expect(Array.isArray(listResponse.json)).toBe(true);
383
- expect((listResponse.json as unknown[]).length).toBeGreaterThanOrEqual(1);
384
-
385
- const revokeResponse = await authFetch(page, profile, "/api/auth/revoke-other-sessions", {
386
- method: "POST",
387
- body: {},
388
- });
389
- expect(
390
- revokeResponse.ok,
391
- `${revokeResponse.status} ${revokeResponse.statusText}: ${revokeResponse.text}`
392
- ).toBe(true);
393
- });
394
-
395
- test("delete account request stores a verification email", async ({ page, request }) => {
396
- const email = `delete.${Date.now()}@auth-e2e.local`;
397
- const password = "password1234";
398
-
399
- await createVerifiedAccount(page, request, email, password);
400
- await login(page, email, password);
401
-
402
- const deleteResponse = await authFetch(page, profile, "/api/auth/delete-user", {
403
- method: "POST",
404
- body: {
405
- callbackURL: `${profiles.standard.webUrl}/login`,
406
- },
407
- });
408
- expect(
409
- deleteResponse.ok,
410
- `${deleteResponse.status} ${deleteResponse.statusText}: ${deleteResponse.text}`
411
- ).toBe(true);
412
-
413
- const deletionEmail = await latestEmail(request, profile, {
414
- to: email,
415
- templateId: "account-deletion",
416
- });
417
- expect(deletionEmail.subject).toContain("Delete your account");
418
- expect(deletionEmail.html).toContain("Confirm account deletion");
419
- expect(emailUrl(deletionEmail)).toContain("/api/auth/delete-user/callback");
420
- });
421
-
422
- test("admin can create and switch to a child organization from an enterprise org", async ({
423
- page,
424
- request,
425
- }) => {
426
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
427
- await page.goto("/organization/manage");
428
- const childOrgName = `Child Org ${Date.now()}`;
429
- await page.getByRole("button", { name: /new organization/i }).click();
430
- const dialog = page.getByRole("dialog");
431
- await dialog.getByPlaceholder(/acme subsidiary/i).fill(childOrgName);
432
- await dialog.getByRole("button", { name: /create organization/i }).click();
433
- await expect(page.getByText(childOrgName)).toBeVisible();
434
-
435
- const row = page.getByRole("row").filter({ hasText: childOrgName });
436
- await row.getByRole("button", { name: /switch/i }).click();
437
-
438
- await expect
439
- .poll(async () => {
440
- const state = await getUserState(request, profile, profiles.standard.adminEmail);
441
- return state.latestSession?.activeOrganizationId ?? null;
442
- })
443
- .not.toBe(enterpriseOrgId);
444
- });
445
-
446
- test("account claim magic link sets real credentials", async ({ page, request }) => {
447
- const provisionedEmail = `claim.${Date.now()}@provisioned.auth-e2e.local`;
448
- const claimedEmail = `claimed.${Date.now()}@auth-e2e.local`;
449
- const password = "password1234";
450
-
451
- await createProvisionedClaimUser(request, profile, provisionedEmail);
452
-
453
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
454
- await page.goto("/admin/users");
455
- await page.locator('input[name="search"]').fill(provisionedEmail);
456
-
457
- const provisionedRow = page.getByRole("row").filter({ hasText: provisionedEmail });
458
- await expect(provisionedRow).toBeVisible();
459
- await provisionedRow.getByLabel("User actions").click();
460
- await page.getByRole("menuitem", { name: /generate magic login link/i }).click();
461
-
462
- const dialog = page.getByRole("dialog");
463
- await dialog.getByLabel(/claim email/i).fill(claimedEmail);
464
- await dialog.getByRole("button", { name: /^generate link$/i }).click();
465
- const generatedLink = dialog.getByLabel(/generated link/i);
466
- await expect(generatedLink).not.toHaveValue("");
467
- const magicLink = await generatedLink.inputValue();
468
-
469
- await logout(page);
470
- await page.goto(magicLink);
471
- await expect(page.getByText(/claim your account/i)).toBeVisible();
472
- await page.getByLabel(/set password/i).fill(password);
473
- const setPasswordResponse = page.waitForResponse(
474
- (response) =>
475
- response.url().includes("/api/auth/set-password") && response.request().method() === "POST"
476
- );
477
- await page.getByRole("button", { name: /set password and claim/i }).click();
478
- const response = await setPasswordResponse;
479
- expect(
480
- response.ok(),
481
- `${response.status()} ${response.statusText()}: ${await response.text()}`
482
- ).toBe(true);
483
-
484
- await logout(page);
485
- await login(page, claimedEmail, password);
486
-
487
- const claimedState = await getUserState(request, profile, claimedEmail);
488
- expect(claimedState.user.emailVerified).toBe(true);
489
- expect(claimedState.credentialAccount).toBe(true);
490
- expect(claimedState.pendingAccountClaims).toBe(0);
491
- expect(claimedState.acceptedAccountClaims).toBeGreaterThanOrEqual(1);
492
- expect(claimedState.organizations).toHaveLength(1);
493
- });
494
-
495
- test("account claim magic link rejects an email that already belongs to a user", async ({
496
- page,
497
- request,
498
- }) => {
499
- const provisionedEmail = `claim-conflict.${Date.now()}@provisioned.auth-e2e.local`;
500
- await createProvisionedClaimUser(request, profile, provisionedEmail);
501
-
502
- await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
503
- await page.goto("/admin/users");
504
- await page.locator('input[name="search"]').fill(provisionedEmail);
505
-
506
- const provisionedRow = page.getByRole("row").filter({ hasText: provisionedEmail });
507
- await expect(provisionedRow).toBeVisible();
508
- await provisionedRow.getByLabel("User actions").click();
509
- await page.getByRole("menuitem", { name: /generate magic login link/i }).click();
510
-
511
- const dialog = page.getByRole("dialog");
512
- await dialog.getByLabel(/claim email/i).fill(profiles.standard.adminEmail);
513
- await dialog.getByRole("button", { name: /^generate link$/i }).click();
514
- await expect(page.getByText(/failed to generate magic link/i)).toBeVisible();
515
- await expect(dialog.getByLabel(/generated link/i)).toHaveValue("");
516
- });
1
+ import { type APIRequestContext, expect, type Page, test } from "@playwright/test";
2
+ import {
3
+ authFetch,
4
+ clearEmails,
5
+ createProvisionedClaimUser,
6
+ emailUrl,
7
+ expectLoginRejected,
8
+ getUserState,
9
+ latestEmail,
10
+ login,
11
+ logout,
12
+ profiles,
13
+ resetTokenFrom,
14
+ seedOrganizations,
15
+ signUp,
16
+ verifyLatestEmail,
17
+ } from "./helpers";
18
+
19
+ const profile = "standard" as const;
20
+ const enterpriseOrgId = "auth-e2e-enterprise-standard";
21
+
22
+ async function createVerifiedAccount(
23
+ page: Page,
24
+ request: APIRequestContext,
25
+ email: string,
26
+ password: string
27
+ ) {
28
+ await signUp(page, email, password);
29
+ await verifyLatestEmail(request, profile, email);
30
+ }
31
+
32
+ test.beforeEach(async ({ request }) => {
33
+ await clearEmails(request, profile);
34
+ });
35
+
36
+ test("signup, verification email, login, protected app, and logout", async ({ page, request }) => {
37
+ const email = `signup.${Date.now()}@auth-e2e.local`;
38
+ const password = "password1234";
39
+
40
+ // posts live on the protected index route
41
+ await page.goto("/");
42
+ await expect(page).toHaveURL(/\/login/);
43
+
44
+ await signUp(page, email, password);
45
+ await expect(page.getByText(/verification email sent/i)).toBeVisible();
46
+
47
+ const verification = await latestEmail(request, profile, {
48
+ to: email,
49
+ templateId: "verification",
50
+ });
51
+ await request.get(emailUrl(verification));
52
+
53
+ await login(page, email, password);
54
+ await expect(page.getByTestId("posts-route")).toBeVisible();
55
+ await expect(page.getByText(/small blog that protects auth changes/i).first()).toBeVisible();
56
+
57
+ await logout(page);
58
+ });
59
+
60
+ test("password reset changes credentials through stored email", async ({ page, request }) => {
61
+ const email = `reset.${Date.now()}@auth-e2e.local`;
62
+ const oldPassword = "password1234";
63
+ const newPassword = "password5678";
64
+
65
+ await createVerifiedAccount(page, request, email, oldPassword);
66
+
67
+ await page.goto("/forgot-password");
68
+ await page.locator('[name="forgot-password-email"]').fill(email);
69
+ const resetRequestPromise = page.waitForResponse(
70
+ (response) =>
71
+ response.url().includes("/api/auth/request-password-reset") &&
72
+ response.request().method() === "POST"
73
+ );
74
+ await page.getByRole("button", { name: /reset password/i }).click();
75
+ const resetRequest = await resetRequestPromise;
76
+ expect(
77
+ resetRequest.ok(),
78
+ `${resetRequest.status()} ${resetRequest.statusText()}: ${await resetRequest.text()}`
79
+ ).toBe(true);
80
+
81
+ const resetEmail = await latestEmail(request, profile, {
82
+ to: email,
83
+ templateId: "password-reset",
84
+ });
85
+ const token = resetTokenFrom(emailUrl(resetEmail));
86
+
87
+ await page.goto(`/reset-password?token=${encodeURIComponent(token)}`);
88
+ await page.locator('[name="reset-password-new"]').fill(newPassword);
89
+ await page.locator('[name="reset-password-confirm"]').fill(newPassword);
90
+ await page.getByRole("button", { name: /reset password/i }).click();
91
+ await expect(page.getByText(/password reset successfully/i)).toBeVisible();
92
+
93
+ await expectLoginRejected(page, email, oldPassword);
94
+ await login(page, email, newPassword);
95
+ });
96
+
97
+ test("invalid reset and verification tokens are rejected", async ({ page, request }) => {
98
+ await page.goto("/reset-password?token=invalid-reset-token");
99
+ await page.locator('[name="reset-password-new"]').fill("password5678");
100
+ await page.locator('[name="reset-password-confirm"]').fill("password5678");
101
+ const resetResponsePromise = page.waitForResponse(
102
+ (response) =>
103
+ response.url().includes("/api/auth/reset-password") && response.request().method() === "POST"
104
+ );
105
+ await page.getByRole("button", { name: /reset password/i }).click();
106
+ const resetResponse = await resetResponsePromise;
107
+ expect(
108
+ resetResponse.ok(),
109
+ `${resetResponse.status()} ${resetResponse.statusText()}: ${await resetResponse.text()}`
110
+ ).toBe(false);
111
+
112
+ const verificationUrl = new URL(`${profiles.standard.serverUrl}/api/auth/verify-email`);
113
+ verificationUrl.searchParams.set("token", "invalid-verification-token");
114
+ verificationUrl.searchParams.set("callbackURL", `${profiles.standard.webUrl}/login`);
115
+ const verificationResponse = await request.get(verificationUrl.toString(), { maxRedirects: 0 });
116
+ expect(verificationResponse.ok()).toBe(false);
117
+ });
118
+
119
+ test("duplicate signup with an existing email is rejected", async ({ page, request }) => {
120
+ const email = `duplicate.${Date.now()}@auth-e2e.local`;
121
+ const password = "password1234";
122
+
123
+ await createVerifiedAccount(page, request, email, password);
124
+
125
+ await page.goto("/signup");
126
+ await page.locator('[name="signup-email"]').fill(email);
127
+ await page.locator('[name="signup-password"]').fill(password);
128
+ const responsePromise = page.waitForResponse(
129
+ (response) =>
130
+ response.url().includes("/api/auth/sign-up/email") && response.request().method() === "POST"
131
+ );
132
+ await page.getByRole("button", { name: /sign up/i }).click();
133
+ const response = await responsePromise;
134
+ expect(
135
+ response.ok(),
136
+ `${response.status()} ${response.statusText()}: ${await response.text()}`
137
+ ).toBe(false);
138
+ });
139
+
140
+ test("organization invite email lets a new user join without creating a separate org", async ({
141
+ page,
142
+ request,
143
+ }) => {
144
+ const invitee = `member.${Date.now()}@auth-e2e.local`;
145
+
146
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
147
+ await page.goto("/organization/members");
148
+ await page.locator('input[type="email"]').fill(invitee);
149
+ await page.getByRole("button", { name: /invite/i }).click();
150
+
151
+ const invite = await latestEmail(request, profile, {
152
+ to: invitee,
153
+ templateId: "organization-invite",
154
+ });
155
+
156
+ await logout(page);
157
+ await page.goto(emailUrl(invite));
158
+ await expect(page).toHaveURL(/\/signup/);
159
+ await page.locator('[name="signup-password"]').fill("password1234");
160
+ await page.getByRole("button", { name: /sign up/i }).click();
161
+ await expect(page.getByTestId("session-email")).toContainText(invitee);
162
+
163
+ const state = await getUserState(request, profile, invitee);
164
+ expect(state.user.emailVerified).toBe(true);
165
+ expect(state.organizations.map((organization) => organization.id)).toEqual([enterpriseOrgId]);
166
+ expect(state.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
167
+ });
168
+
169
+ test("organization invite lets an existing user join an additional org", async ({
170
+ page,
171
+ request,
172
+ }) => {
173
+ const email = `existing-member.${Date.now()}@auth-e2e.local`;
174
+ const password = "password1234";
175
+
176
+ await createVerifiedAccount(page, request, email, password);
177
+ const beforeInvite = await getUserState(request, profile, email);
178
+ expect(beforeInvite.organizations).toHaveLength(1);
179
+ expect(beforeInvite.organizations[0]?.id).not.toBe(enterpriseOrgId);
180
+
181
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
182
+ await page.goto("/organization/members");
183
+ await page.locator('input[type="email"]').fill(email);
184
+ await page.getByRole("button", { name: /invite/i }).click();
185
+
186
+ const invite = await latestEmail(request, profile, {
187
+ to: email,
188
+ templateId: "organization-invite",
189
+ });
190
+
191
+ await logout(page);
192
+ await login(page, email, password);
193
+ await page.goto(emailUrl(invite));
194
+
195
+ await expect
196
+ .poll(async () => {
197
+ const state = await getUserState(request, profile, email);
198
+ return state.organizations.length;
199
+ })
200
+ .toBe(2);
201
+
202
+ const afterInvite = await getUserState(request, profile, email);
203
+ expect(afterInvite.organizations.map((organization) => organization.id)).toContain(
204
+ enterpriseOrgId
205
+ );
206
+ expect(afterInvite.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
207
+ });
208
+
209
+ test("admin creates, bans, unbans, and authenticates a user", async ({ page, request }) => {
210
+ const email = `admin-created.${Date.now()}@auth-e2e.local`;
211
+ const password = "password1234";
212
+
213
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
214
+ await page.goto("/admin/users");
215
+ await page.getByRole("button", { name: /create user/i }).click();
216
+
217
+ const dialog = page.getByRole("dialog");
218
+ await dialog.getByPlaceholder(/enter user's name/i).fill("Admin Created User");
219
+ await dialog.getByPlaceholder(/enter user's email/i).fill(email);
220
+ await dialog.getByPlaceholder(/enter password/i).fill(password);
221
+ await dialog.getByRole("button", { name: /^create user$/i }).click();
222
+
223
+ const createdRow = page.getByRole("row").filter({ hasText: email });
224
+ await expect(createdRow).toBeVisible();
225
+ await createdRow.getByLabel("User actions").click();
226
+ await page.getByRole("menuitem", { name: /^ban$/i }).click();
227
+
228
+ const banDialog = page.getByRole("dialog");
229
+ await banDialog.getByLabel(/ban reason/i).fill("E2E status update");
230
+ await banDialog.getByRole("button", { name: /^ban user$/i }).click();
231
+ await expect(createdRow.getByText(/banned/i)).toBeVisible();
232
+
233
+ await createdRow.getByLabel("User actions").click();
234
+ await page.getByRole("menuitem", { name: /^unban$/i }).click();
235
+ await expect(createdRow.getByText(/active/i)).toBeVisible();
236
+
237
+ const createdState = await getUserState(request, profile, email);
238
+ expect(createdState.user.role).toBe("user");
239
+ const setRoleResponse = await authFetch(page, profile, "/api/auth/admin/set-role", {
240
+ method: "POST",
241
+ body: {
242
+ userId: createdState.user.id,
243
+ role: "admin",
244
+ },
245
+ });
246
+ expect(
247
+ setRoleResponse.ok,
248
+ `${setRoleResponse.status} ${setRoleResponse.statusText}: ${setRoleResponse.text}`
249
+ ).toBe(true);
250
+
251
+ await expect
252
+ .poll(async () => {
253
+ const state = await getUserState(request, profile, email);
254
+ return state.user.role;
255
+ })
256
+ .toBe("admin");
257
+
258
+ await logout(page);
259
+ const verificationResponse = await request.post(
260
+ `${profiles.standard.serverUrl}/api/auth/send-verification-email`,
261
+ {
262
+ data: {
263
+ email,
264
+ callbackURL: `${profiles.standard.webUrl}/login`,
265
+ },
266
+ }
267
+ );
268
+ expect(verificationResponse.ok()).toBe(true);
269
+ const verification = await latestEmail(request, profile, {
270
+ to: email,
271
+ templateId: "verification",
272
+ });
273
+ await request.get(emailUrl(verification));
274
+ await login(page, email, password);
275
+ const adminListResponse = await authFetch(page, profile, "/api/auth/admin/list-users");
276
+ expect(
277
+ adminListResponse.ok,
278
+ `${adminListResponse.status} ${adminListResponse.statusText}: ${adminListResponse.text}`
279
+ ).toBe(true);
280
+ });
281
+
282
+ test("admin manages organization members from organization admin", async ({ page, request }) => {
283
+ const email = `admin-org-member.${Date.now()}@auth-e2e.local`;
284
+ const password = "password1234";
285
+
286
+ await createVerifiedAccount(page, request, email, password);
287
+ const beforeMembership = await getUserState(request, profile, email);
288
+ expect(beforeMembership.organizations.map((organization) => organization.id)).not.toContain(
289
+ enterpriseOrgId
290
+ );
291
+
292
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
293
+ await page.goto("/admin/organizations");
294
+ await page.locator('input[name="search"]').fill("Auth E2E Enterprise standard");
295
+
296
+ const organizationRow = page.getByRole("row").filter({
297
+ hasText: "Auth E2E Enterprise standard",
298
+ });
299
+ await expect(organizationRow).toBeVisible();
300
+ await organizationRow.getByLabel(/manage members/i).click();
301
+
302
+ const dialog = page.getByRole("dialog").filter({ hasText: "Manage Members" });
303
+ await expect(dialog.getByText("Auth E2E Enterprise standard")).toBeVisible();
304
+ await dialog.getByText("Select user", { exact: true }).click();
305
+ await page.getByPlaceholder("Search users...").fill(email);
306
+ await page.getByRole("option", { name: new RegExp(email) }).click();
307
+ await dialog.getByRole("button", { name: /add member/i }).click();
308
+
309
+ const memberRow = dialog.getByRole("row").filter({ hasText: email });
310
+ await expect(memberRow).toBeVisible();
311
+ await expect
312
+ .poll(async () => {
313
+ const state = await getUserState(request, profile, email);
314
+ return (
315
+ state.organizations.find((organization) => organization.id === enterpriseOrgId)?.role ??
316
+ null
317
+ );
318
+ })
319
+ .toBe("member");
320
+
321
+ await memberRow.getByRole("button", { name: `Member Role for ${email}` }).click();
322
+ await page.getByRole("option", { name: /^Admin$/ }).click();
323
+ await expect
324
+ .poll(async () => {
325
+ const state = await getUserState(request, profile, email);
326
+ return (
327
+ state.organizations.find((organization) => organization.id === enterpriseOrgId)?.role ??
328
+ null
329
+ );
330
+ })
331
+ .toBe("admin");
332
+
333
+ await memberRow.getByRole("button", { name: `Remove ${email}` }).click();
334
+ await expect(memberRow).toBeHidden();
335
+ await expect
336
+ .poll(async () => {
337
+ const state = await getUserState(request, profile, email);
338
+ return state.organizations.some((organization) => organization.id === enterpriseOrgId);
339
+ })
340
+ .toBe(false);
341
+ });
342
+
343
+ test("admin organization pagination stays on the selected page", async ({ page, request }) => {
344
+ const prefix = `Pagination Org ${Date.now()}`;
345
+ await seedOrganizations(request, profile, { prefix, count: 12 });
346
+
347
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
348
+ await page.goto("/admin/organizations");
349
+ await page.locator('input[name="search"]').fill(prefix);
350
+
351
+ await expect(page.getByText("1 to 10 of 12 results")).toBeVisible();
352
+ await page.getByRole("button", { name: /next/i }).click();
353
+
354
+ await expect(page).toHaveURL(/om_p=2/);
355
+ await expect(page.getByText("11 to 12 of 12 results")).toBeVisible();
356
+ });
357
+
358
+ test("non-admin users cannot call admin auth APIs", async ({ page, request }) => {
359
+ const email = `non-admin.${Date.now()}@auth-e2e.local`;
360
+ const password = "password1234";
361
+
362
+ await createVerifiedAccount(page, request, email, password);
363
+ await login(page, email, password);
364
+
365
+ const response = await authFetch(page, profile, "/api/auth/admin/list-users");
366
+ expect(response.ok, `${response.status} ${response.statusText}: ${response.text}`).toBe(false);
367
+ expect([401, 403]).toContain(response.status);
368
+ });
369
+
370
+ test("session management endpoints list and revoke sessions", async ({ page, request }) => {
371
+ const email = `sessions.${Date.now()}@auth-e2e.local`;
372
+ const password = "password1234";
373
+
374
+ await createVerifiedAccount(page, request, email, password);
375
+ await login(page, email, password);
376
+
377
+ const listResponse = await authFetch(page, profile, "/api/auth/list-sessions");
378
+ expect(
379
+ listResponse.ok,
380
+ `${listResponse.status} ${listResponse.statusText}: ${listResponse.text}`
381
+ ).toBe(true);
382
+ expect(Array.isArray(listResponse.json)).toBe(true);
383
+ expect((listResponse.json as unknown[]).length).toBeGreaterThanOrEqual(1);
384
+
385
+ const revokeResponse = await authFetch(page, profile, "/api/auth/revoke-other-sessions", {
386
+ method: "POST",
387
+ body: {},
388
+ });
389
+ expect(
390
+ revokeResponse.ok,
391
+ `${revokeResponse.status} ${revokeResponse.statusText}: ${revokeResponse.text}`
392
+ ).toBe(true);
393
+ });
394
+
395
+ test("delete account request stores a verification email", async ({ page, request }) => {
396
+ const email = `delete.${Date.now()}@auth-e2e.local`;
397
+ const password = "password1234";
398
+
399
+ await createVerifiedAccount(page, request, email, password);
400
+ await login(page, email, password);
401
+
402
+ const deleteResponse = await authFetch(page, profile, "/api/auth/delete-user", {
403
+ method: "POST",
404
+ body: {
405
+ callbackURL: `${profiles.standard.webUrl}/login`,
406
+ },
407
+ });
408
+ expect(
409
+ deleteResponse.ok,
410
+ `${deleteResponse.status} ${deleteResponse.statusText}: ${deleteResponse.text}`
411
+ ).toBe(true);
412
+
413
+ const deletionEmail = await latestEmail(request, profile, {
414
+ to: email,
415
+ templateId: "account-deletion",
416
+ });
417
+ expect(deletionEmail.subject).toContain("Delete your account");
418
+ expect(deletionEmail.html).toContain("Confirm account deletion");
419
+ expect(emailUrl(deletionEmail)).toContain("/api/auth/delete-user/callback");
420
+ });
421
+
422
+ test("admin can create and switch to a child organization from an enterprise org", async ({
423
+ page,
424
+ request,
425
+ }) => {
426
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
427
+ await page.goto("/organization/manage");
428
+ const childOrgName = `Child Org ${Date.now()}`;
429
+ await page.getByRole("button", { name: /new organization/i }).click();
430
+ const dialog = page.getByRole("dialog");
431
+ await dialog.getByPlaceholder(/acme subsidiary/i).fill(childOrgName);
432
+ await dialog.getByRole("button", { name: /create organization/i }).click();
433
+ await expect(page.getByText(childOrgName)).toBeVisible();
434
+
435
+ const row = page.getByRole("row").filter({ hasText: childOrgName });
436
+ await row.getByRole("button", { name: /switch/i }).click();
437
+
438
+ await expect
439
+ .poll(async () => {
440
+ const state = await getUserState(request, profile, profiles.standard.adminEmail);
441
+ return state.latestSession?.activeOrganizationId ?? null;
442
+ })
443
+ .not.toBe(enterpriseOrgId);
444
+ });
445
+
446
+ test("account claim magic link sets real credentials", async ({ page, request }) => {
447
+ const provisionedEmail = `claim.${Date.now()}@provisioned.auth-e2e.local`;
448
+ const claimedEmail = `claimed.${Date.now()}@auth-e2e.local`;
449
+ const password = "password1234";
450
+
451
+ await createProvisionedClaimUser(request, profile, provisionedEmail);
452
+
453
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
454
+ await page.goto("/admin/users");
455
+ await page.locator('input[name="search"]').fill(provisionedEmail);
456
+
457
+ const provisionedRow = page.getByRole("row").filter({ hasText: provisionedEmail });
458
+ await expect(provisionedRow).toBeVisible();
459
+ await provisionedRow.getByLabel("User actions").click();
460
+ await page.getByRole("menuitem", { name: /generate magic login link/i }).click();
461
+
462
+ const dialog = page.getByRole("dialog");
463
+ await dialog.getByLabel(/claim email/i).fill(claimedEmail);
464
+ await dialog.getByRole("button", { name: /^generate link$/i }).click();
465
+ const generatedLink = dialog.getByLabel(/generated link/i);
466
+ await expect(generatedLink).not.toHaveValue("");
467
+ const magicLink = await generatedLink.inputValue();
468
+
469
+ await logout(page);
470
+ await page.goto(magicLink);
471
+ await expect(page.getByText(/claim your account/i)).toBeVisible();
472
+ await page.getByLabel(/set password/i).fill(password);
473
+ const setPasswordResponse = page.waitForResponse(
474
+ (response) =>
475
+ response.url().includes("/api/auth/set-password") && response.request().method() === "POST"
476
+ );
477
+ await page.getByRole("button", { name: /set password and claim/i }).click();
478
+ const response = await setPasswordResponse;
479
+ expect(
480
+ response.ok(),
481
+ `${response.status()} ${response.statusText()}: ${await response.text()}`
482
+ ).toBe(true);
483
+
484
+ await logout(page);
485
+ await login(page, claimedEmail, password);
486
+
487
+ const claimedState = await getUserState(request, profile, claimedEmail);
488
+ expect(claimedState.user.emailVerified).toBe(true);
489
+ expect(claimedState.credentialAccount).toBe(true);
490
+ expect(claimedState.pendingAccountClaims).toBe(0);
491
+ expect(claimedState.acceptedAccountClaims).toBeGreaterThanOrEqual(1);
492
+ expect(claimedState.organizations).toHaveLength(1);
493
+ });
494
+
495
+ test("account claim magic link rejects an email that already belongs to a user", async ({
496
+ page,
497
+ request,
498
+ }) => {
499
+ const provisionedEmail = `claim-conflict.${Date.now()}@provisioned.auth-e2e.local`;
500
+ await createProvisionedClaimUser(request, profile, provisionedEmail);
501
+
502
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
503
+ await page.goto("/admin/users");
504
+ await page.locator('input[name="search"]').fill(provisionedEmail);
505
+
506
+ const provisionedRow = page.getByRole("row").filter({ hasText: provisionedEmail });
507
+ await expect(provisionedRow).toBeVisible();
508
+ await provisionedRow.getByLabel("User actions").click();
509
+ await page.getByRole("menuitem", { name: /generate magic login link/i }).click();
510
+
511
+ const dialog = page.getByRole("dialog");
512
+ await dialog.getByLabel(/claim email/i).fill(profiles.standard.adminEmail);
513
+ await dialog.getByRole("button", { name: /^generate link$/i }).click();
514
+ await expect(page.getByText(/failed to generate magic link/i)).toBeVisible();
515
+ await expect(dialog.getByLabel(/generated link/i)).toHaveValue("");
516
+ });