create-m5kdev 0.25.3 → 0.26.2

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