create-m5kdev 0.35.0 → 0.37.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.
- package/package.json +3 -3
- package/templates/minimal-app/.dockerignore.tpl +6 -0
- package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +3 -0
- package/templates/minimal-app/apps/e2e/servers.ts.tpl +4 -0
- package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
- package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
- package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
- package/templates/minimal-app/apps/e2e/tests/mcp-helpers.ts.tpl +406 -0
- package/templates/minimal-app/apps/e2e/tests/mcp.spec.ts.tpl +108 -0
- package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
- package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
- package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
- package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
- package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
- package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
- package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
- package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
- package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +10 -0
- package/templates/minimal-app/apps/server/db.e2e.ts.tpl +6 -40
- package/templates/minimal-app/apps/server/db.ts.tpl +6 -42
- package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
- package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
- package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
- package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
- package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
- package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
- package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +159 -0
- package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +2 -0
- package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +87 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
- package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
- package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
- package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
- package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3603 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3620 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3620 -0
- package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +44 -9
- package/templates/minimal-app/apps/server/src/app.ts.tpl +12 -2
- package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
- package/templates/minimal-app/apps/server/src/modules/posts/posts.mcp.ts.tpl +17 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +11 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +14 -5
- package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
- package/templates/minimal-app/apps/server/src/schema.ts.tpl +16 -2
- package/templates/minimal-app/apps/shared/package.json.tpl +1 -0
- package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
- package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
- package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
- package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
- package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
- package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
- package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
- package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +14 -13
- package/templates/minimal-app/template.manifest.json +9 -0
- package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
- package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
- package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +0 -27
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-m5kdev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.2",
|
|
4
4
|
"license": "GPL-3.0-only",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
7
7
|
},
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "https://github.com/michalkow/m5kdev.git"
|
|
10
|
+
"url": "git+https://github.com/michalkow/m5kdev.git"
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://github.com/michalkow/m5kdev#readme",
|
|
13
13
|
"bugs": "https://github.com/michalkow/m5kdev/issues",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"semver": "7.8.5",
|
|
26
26
|
"tslib": "2.8.1",
|
|
27
27
|
"yaml": "2.9.0",
|
|
28
|
-
"zod": "4.
|
|
28
|
+
"zod": "4.4.3"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/jscodeshift": "17.3.0",
|
|
@@ -58,9 +58,13 @@ function serverEnv(profile: E2EProfile, expo: boolean): Record<string, string> {
|
|
|
58
58
|
REDIS_URL: `redis://127.0.0.1:6379/${expo ? PORTS[profile].expoRedisDb : PORTS[profile].redisDb}`,
|
|
59
59
|
VITE_ENABLE_WAITLIST: profile === "waitlist" ? "true" : "false",
|
|
60
60
|
AUTH_E2E_PROFILE: profile,
|
|
61
|
+
M5K_MCP_E2E_CIMD: "1",
|
|
61
62
|
BETTER_AUTH_SECRET: "auth-e2e-local-secret-auth-e2e-local-secret",
|
|
62
63
|
SYSTEM_NOTIFICATION_EMAIL: "ops@auth-e2e.local",
|
|
63
64
|
NODE_ENV: "development",
|
|
65
|
+
// hermetic: shared/.env Turso credentials must not leak into drizzle-kit
|
|
66
|
+
TURSO_DATABASE_URL: "",
|
|
67
|
+
TURSO_AUTH_TOKEN: "",
|
|
64
68
|
// hermetic: a real key in the invoking shell would flip email mode to
|
|
65
69
|
// "send" and the stored-email assertions would find nothing
|
|
66
70
|
RESEND_API_KEY: "",
|
|
@@ -1,40 +1,8 @@
|
|
|
1
|
-
import type { BackendTRPCRouter } from "@m5kdev/backend/types";
|
|
2
|
-
import { transformer } from "@m5kdev/commons/utils/trpc";
|
|
3
1
|
import type { Page } from "@playwright/test";
|
|
4
2
|
import { expect } from "@playwright/test";
|
|
5
|
-
import {
|
|
6
|
-
import { type Profile, profiles } from "./helpers";
|
|
3
|
+
import { createTrpcClient } from "./helpers";
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
async function cookieHeader(page: Page, profile: Profile) {
|
|
11
|
-
const cookies = await page.context().cookies(profiles[profile].serverUrl);
|
|
12
|
-
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function createExpoTrpcClient(page: Page, profile: Profile) {
|
|
16
|
-
const cookie = await cookieHeader(page, profile);
|
|
17
|
-
|
|
18
|
-
return createTRPCClient<BackendTRPCRouter>({
|
|
19
|
-
links: [
|
|
20
|
-
httpBatchLink({
|
|
21
|
-
url: `${profiles[profile].serverUrl}/trpc`,
|
|
22
|
-
fetch(url, options) {
|
|
23
|
-
const headers = new Headers(options?.headers);
|
|
24
|
-
if (cookie) {
|
|
25
|
-
headers.set("cookie", cookie);
|
|
26
|
-
}
|
|
27
|
-
return fetch(url, {
|
|
28
|
-
...options,
|
|
29
|
-
body: options?.body ? (options.body as FetchInit["body"]) : undefined,
|
|
30
|
-
headers,
|
|
31
|
-
});
|
|
32
|
-
},
|
|
33
|
-
transformer,
|
|
34
|
-
}),
|
|
35
|
-
],
|
|
36
|
-
});
|
|
37
|
-
}
|
|
5
|
+
export const createExpoTrpcClient = createTrpcClient;
|
|
38
6
|
|
|
39
7
|
export async function expoSignUp(page: Page, email: string, password: string) {
|
|
40
8
|
await page.goto("/signup");
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
clearEmails,
|
|
12
12
|
createProvisionedClaimUser,
|
|
13
13
|
emailUrl,
|
|
14
|
+
findMemberByEmail,
|
|
14
15
|
getUserState,
|
|
15
16
|
latestEmail,
|
|
16
17
|
profiles,
|
|
@@ -37,15 +38,26 @@ async function expectAuthOk(response: Awaited<ReturnType<typeof authFetch>>) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
async function inviteOrganizationMember(page: Page, email: string, role: "member" | "admin") {
|
|
41
|
+
const trpc = await createExpoTrpcClient(page, profile);
|
|
42
|
+
return trpc.auth.inviteOrganizationMember.mutate({ email, role });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function listOrganizationMembers(page: Page) {
|
|
46
|
+
const trpc = await createExpoTrpcClient(page, profile);
|
|
47
|
+
return trpc.auth.listOrganizationMembers.query();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function expectBetterAuthInviteForbidden(page: Page, email: string) {
|
|
40
51
|
const response = await authFetch(page, profile, "/api/auth/organization/invite-member", {
|
|
41
52
|
method: "POST",
|
|
42
53
|
body: {
|
|
43
54
|
organizationId: enterpriseOrgId,
|
|
44
55
|
email,
|
|
45
|
-
role,
|
|
56
|
+
role: "member",
|
|
46
57
|
},
|
|
47
58
|
});
|
|
48
|
-
|
|
59
|
+
expect(response.ok, `${response.status} ${response.statusText}: ${response.text}`).toBe(false);
|
|
60
|
+
expect(response.status).toBe(403);
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
test.beforeEach(async ({ request }) => {
|
|
@@ -151,12 +163,21 @@ test("organization invite email lets a new user join without creating a separate
|
|
|
151
163
|
const invitee = `expo.member.${Date.now()}@auth-e2e.local`;
|
|
152
164
|
|
|
153
165
|
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
154
|
-
await inviteOrganizationMember(page, invitee, "member");
|
|
166
|
+
const invited = await inviteOrganizationMember(page, invitee, "member");
|
|
167
|
+
expect(invited.member.userId).toBeNull();
|
|
168
|
+
expect(invited.invitation.memberId).toBe(invited.member.id);
|
|
169
|
+
expect(invited.invitation.status).toBe("pending");
|
|
170
|
+
|
|
171
|
+
const listed = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
172
|
+
expect(listed?.id).toBe(invited.member.id);
|
|
173
|
+
expect(listed?.userId).toBeNull();
|
|
174
|
+
expect(listed?.invitationId).toBe(invited.invitation.id);
|
|
155
175
|
|
|
156
176
|
const invite = await latestEmail(request, profile, {
|
|
157
177
|
to: invitee,
|
|
158
178
|
templateId: "organization-invite",
|
|
159
179
|
});
|
|
180
|
+
expect(emailUrl(invite)).toContain(`/organization/accept-invitation?id=${invited.invitation.id}`);
|
|
160
181
|
|
|
161
182
|
await expoLogoutNative(page);
|
|
162
183
|
await page.goto(emailUrl(invite));
|
|
@@ -168,7 +189,9 @@ test("organization invite email lets a new user join without creating a separate
|
|
|
168
189
|
const state = await getUserState(request, profile, invitee);
|
|
169
190
|
expect(state.user.emailVerified).toBe(true);
|
|
170
191
|
expect(state.organizations.map((organization) => organization.id)).toEqual([enterpriseOrgId]);
|
|
192
|
+
expect(state.organizations[0]?.memberId).toBe(invited.member.id);
|
|
171
193
|
expect(state.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
|
|
194
|
+
expect(state.latestSession?.activeOrganizationMemberId).toBe(invited.member.id);
|
|
172
195
|
});
|
|
173
196
|
|
|
174
197
|
test("organization invite lets an existing user join an additional org", async ({
|
|
@@ -184,12 +207,14 @@ test("organization invite lets an existing user join an additional org", async (
|
|
|
184
207
|
expect(beforeInvite.organizations[0]?.id).not.toBe(enterpriseOrgId);
|
|
185
208
|
|
|
186
209
|
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
187
|
-
await inviteOrganizationMember(page, email, "member");
|
|
210
|
+
const invited = await inviteOrganizationMember(page, email, "member");
|
|
211
|
+
expect(invited.member.userId).toBeNull();
|
|
188
212
|
|
|
189
213
|
const invite = await latestEmail(request, profile, {
|
|
190
214
|
to: email,
|
|
191
215
|
templateId: "organization-invite",
|
|
192
216
|
});
|
|
217
|
+
expect(emailUrl(invite)).toContain("/organization/accept-invitation?id=");
|
|
193
218
|
|
|
194
219
|
await expoLogoutNative(page);
|
|
195
220
|
await expoLoginNative(page, email, password);
|
|
@@ -206,7 +231,156 @@ test("organization invite lets an existing user join an additional org", async (
|
|
|
206
231
|
expect(afterInvite.organizations.map((organization) => organization.id)).toContain(
|
|
207
232
|
enterpriseOrgId
|
|
208
233
|
);
|
|
234
|
+
expect(
|
|
235
|
+
afterInvite.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId
|
|
236
|
+
).toBe(invited.member.id);
|
|
209
237
|
expect(afterInvite.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
|
|
238
|
+
expect(afterInvite.latestSession?.activeOrganizationMemberId).toBe(invited.member.id);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("invited membership role change applies before accept", async ({ page, request }) => {
|
|
242
|
+
const invitee = `expo.invite-role.${Date.now()}@auth-e2e.local`;
|
|
243
|
+
|
|
244
|
+
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
245
|
+
const invited = await inviteOrganizationMember(page, invitee, "member");
|
|
246
|
+
expect(invited.member.role).toBe("member");
|
|
247
|
+
expect(invited.member.userId).toBeNull();
|
|
248
|
+
|
|
249
|
+
const trpc = await createExpoTrpcClient(page, profile);
|
|
250
|
+
const updated = await trpc.auth.updateMemberRole.mutate({
|
|
251
|
+
memberId: invited.member.id,
|
|
252
|
+
role: "admin",
|
|
253
|
+
});
|
|
254
|
+
expect(updated).toEqual({ id: invited.member.id, role: "admin" });
|
|
255
|
+
|
|
256
|
+
const afterRole = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
257
|
+
expect(afterRole?.id).toBe(invited.member.id);
|
|
258
|
+
expect(afterRole?.role).toBe("admin");
|
|
259
|
+
expect(afterRole?.userId).toBeNull();
|
|
260
|
+
expect(afterRole?.invitationId).toBe(invited.invitation.id);
|
|
261
|
+
|
|
262
|
+
const invite = await latestEmail(request, profile, {
|
|
263
|
+
to: invitee,
|
|
264
|
+
templateId: "organization-invite",
|
|
265
|
+
});
|
|
266
|
+
await expoLogoutNative(page);
|
|
267
|
+
await page.goto(emailUrl(invite));
|
|
268
|
+
await expect(page).toHaveURL(/\/signup/);
|
|
269
|
+
await page.getByTestId("signup-password").fill("password1234");
|
|
270
|
+
await page.getByTestId("signup-submit").click();
|
|
271
|
+
await expect(page.getByTestId("session-email")).toContainText(invitee);
|
|
272
|
+
|
|
273
|
+
const state = await getUserState(request, profile, invitee);
|
|
274
|
+
expect(state.organizations).toEqual([
|
|
275
|
+
expect.objectContaining({
|
|
276
|
+
id: enterpriseOrgId,
|
|
277
|
+
memberId: invited.member.id,
|
|
278
|
+
role: "admin",
|
|
279
|
+
}),
|
|
280
|
+
]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("invited membership can be resent, role-updated, canceled, and revived", async ({ page }) => {
|
|
284
|
+
const invitee = `expo.invite-manage.${Date.now()}@auth-e2e.local`;
|
|
285
|
+
|
|
286
|
+
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
287
|
+
const invited = await inviteOrganizationMember(page, invitee, "member");
|
|
288
|
+
const resent = await inviteOrganizationMember(page, invitee, "member");
|
|
289
|
+
expect(resent.member.id).toBe(invited.member.id);
|
|
290
|
+
expect(resent.invitation.id).toBe(invited.invitation.id);
|
|
291
|
+
|
|
292
|
+
const trpc = await createExpoTrpcClient(page, profile);
|
|
293
|
+
await trpc.auth.updateMemberRole.mutate({ memberId: invited.member.id, role: "admin" });
|
|
294
|
+
const afterRole = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
295
|
+
expect(afterRole?.role).toBe("admin");
|
|
296
|
+
expect(afterRole?.userId).toBeNull();
|
|
297
|
+
|
|
298
|
+
await trpc.auth.cancelOrganizationInvitation.mutate({
|
|
299
|
+
invitationId: invited.invitation.id,
|
|
300
|
+
});
|
|
301
|
+
expect(findMemberByEmail(await listOrganizationMembers(page), invitee)).toBeUndefined();
|
|
302
|
+
|
|
303
|
+
const revived = await inviteOrganizationMember(page, invitee, "member");
|
|
304
|
+
expect(revived.member.id).toBe(invited.member.id);
|
|
305
|
+
expect(revived.member.userId).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("live member invite is rejected; remove and leave drop the seat", async ({
|
|
309
|
+
page,
|
|
310
|
+
request,
|
|
311
|
+
}) => {
|
|
312
|
+
const email = `expo.live-member.${Date.now()}@auth-e2e.local`;
|
|
313
|
+
const password = "password1234";
|
|
314
|
+
|
|
315
|
+
await createVerifiedExpoAccount(page, request, email, password);
|
|
316
|
+
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
317
|
+
const invited = await inviteOrganizationMember(page, email, "member");
|
|
318
|
+
|
|
319
|
+
const invite = await latestEmail(request, profile, {
|
|
320
|
+
to: email,
|
|
321
|
+
templateId: "organization-invite",
|
|
322
|
+
});
|
|
323
|
+
await expoLogoutNative(page);
|
|
324
|
+
await expoLoginNative(page, email, password);
|
|
325
|
+
await page.goto(emailUrl(invite));
|
|
326
|
+
await expect
|
|
327
|
+
.poll(async () => {
|
|
328
|
+
const state = await getUserState(request, profile, email);
|
|
329
|
+
return (
|
|
330
|
+
state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
|
|
331
|
+
null
|
|
332
|
+
);
|
|
333
|
+
})
|
|
334
|
+
.toBe(invited.member.id);
|
|
335
|
+
|
|
336
|
+
await expoLogoutNative(page);
|
|
337
|
+
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
338
|
+
const adminTrpc = await createExpoTrpcClient(page, profile);
|
|
339
|
+
await expect(
|
|
340
|
+
adminTrpc.auth.inviteOrganizationMember.mutate({ email, role: "member" })
|
|
341
|
+
).rejects.toThrow(/live membership already exists/i);
|
|
342
|
+
|
|
343
|
+
await adminTrpc.auth.removeOrganizationMember.mutate({ memberId: invited.member.id });
|
|
344
|
+
await expect
|
|
345
|
+
.poll(async () => {
|
|
346
|
+
const state = await getUserState(request, profile, email);
|
|
347
|
+
return state.organizations.some((organization) => organization.id === enterpriseOrgId);
|
|
348
|
+
})
|
|
349
|
+
.toBe(false);
|
|
350
|
+
|
|
351
|
+
const revived = await inviteOrganizationMember(page, email, "member");
|
|
352
|
+
expect(revived.member.id).toBe(invited.member.id);
|
|
353
|
+
|
|
354
|
+
const reinvite = await latestEmail(request, profile, {
|
|
355
|
+
to: email,
|
|
356
|
+
templateId: "organization-invite",
|
|
357
|
+
});
|
|
358
|
+
await expoLogoutNative(page);
|
|
359
|
+
await expoLoginNative(page, email, password);
|
|
360
|
+
await page.goto(emailUrl(reinvite));
|
|
361
|
+
await expect
|
|
362
|
+
.poll(async () => {
|
|
363
|
+
const state = await getUserState(request, profile, email);
|
|
364
|
+
return (
|
|
365
|
+
state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
|
|
366
|
+
null
|
|
367
|
+
);
|
|
368
|
+
})
|
|
369
|
+
.toBe(invited.member.id);
|
|
370
|
+
|
|
371
|
+
const memberTrpc = await createExpoTrpcClient(page, profile);
|
|
372
|
+
await memberTrpc.auth.leaveOrganization.mutate();
|
|
373
|
+
await expect
|
|
374
|
+
.poll(async () => {
|
|
375
|
+
const state = await getUserState(request, profile, email);
|
|
376
|
+
return state.organizations.some((organization) => organization.id === enterpriseOrgId);
|
|
377
|
+
})
|
|
378
|
+
.toBe(false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("Better Auth organization invite HTTP is not a public invite path", async ({ page }) => {
|
|
382
|
+
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
383
|
+
await expectBetterAuthInviteForbidden(page, `expo.ba-invite.${Date.now()}@auth-e2e.local`);
|
|
210
384
|
});
|
|
211
385
|
|
|
212
386
|
test("admin creates, bans, unbans, and authenticates a user", async ({ page, request }) => {
|
|
@@ -316,7 +490,7 @@ test("admin manages organization members from organization admin", async ({ page
|
|
|
316
490
|
const membersAfterAdd = await trpc.auth.listAdminOrganizationMembers.query({
|
|
317
491
|
organizationId: enterpriseOrgId,
|
|
318
492
|
});
|
|
319
|
-
const member = membersAfterAdd.members.find((row) => row.user
|
|
493
|
+
const member = membersAfterAdd.members.find((row) => row.user?.email === email);
|
|
320
494
|
expect(member).toBeTruthy();
|
|
321
495
|
await expect
|
|
322
496
|
.poll(async () => {
|
|
@@ -355,6 +529,52 @@ test("admin manages organization members from organization admin", async ({ page
|
|
|
355
529
|
.toBe(false);
|
|
356
530
|
});
|
|
357
531
|
|
|
532
|
+
test("admin transfers organization ownership to an active member", async ({ page, request }) => {
|
|
533
|
+
const email = `expo.org-owner.${Date.now()}@auth-e2e.local`;
|
|
534
|
+
const password = "password1234";
|
|
535
|
+
const childOrgName = `Expo Transfer Org ${Date.now()}`;
|
|
536
|
+
|
|
537
|
+
await createVerifiedExpoAccount(page, request, email, password);
|
|
538
|
+
await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
|
|
539
|
+
const trpc = await createExpoTrpcClient(page, profile);
|
|
540
|
+
const childOrg = await trpc.auth.createOrganization.mutate({ name: childOrgName });
|
|
541
|
+
|
|
542
|
+
const users = await trpc.auth.searchAdminUsers.query({ q: email, limit: 10 });
|
|
543
|
+
const user = users.rows.find((row) => row.email === email);
|
|
544
|
+
expect(user).toBeTruthy();
|
|
545
|
+
|
|
546
|
+
await trpc.auth.addAdminOrganizationMember.mutate({
|
|
547
|
+
organizationId: childOrg.id,
|
|
548
|
+
userId: user?.id ?? "",
|
|
549
|
+
role: "member",
|
|
550
|
+
});
|
|
551
|
+
const membersAfterAdd = await trpc.auth.listAdminOrganizationMembers.query({
|
|
552
|
+
organizationId: childOrg.id,
|
|
553
|
+
});
|
|
554
|
+
const member = membersAfterAdd.members.find((row) => row.user?.email === email);
|
|
555
|
+
expect(member).toBeTruthy();
|
|
556
|
+
|
|
557
|
+
const transferred = await trpc.auth.transferOrganizationOwner.mutate({
|
|
558
|
+
organizationId: childOrg.id,
|
|
559
|
+
memberId: member?.id ?? "",
|
|
560
|
+
});
|
|
561
|
+
expect(transferred.owner).toEqual({ id: member?.id, role: "owner" });
|
|
562
|
+
expect(transferred.previousOwner.role).toBe("admin");
|
|
563
|
+
|
|
564
|
+
await expect
|
|
565
|
+
.poll(async () => {
|
|
566
|
+
const state = await getUserState(request, profile, email);
|
|
567
|
+
return state.organizations.find((organization) => organization.id === childOrg.id)?.role;
|
|
568
|
+
})
|
|
569
|
+
.toBe("owner");
|
|
570
|
+
await expect
|
|
571
|
+
.poll(async () => {
|
|
572
|
+
const state = await getUserState(request, profile, profiles[profile].adminEmail);
|
|
573
|
+
return state.organizations.find((organization) => organization.id === childOrg.id)?.role;
|
|
574
|
+
})
|
|
575
|
+
.toBe("admin");
|
|
576
|
+
});
|
|
577
|
+
|
|
358
578
|
test("admin organization pagination stays on the selected page", async ({ page, request }) => {
|
|
359
579
|
const prefix = `Expo Pagination Org ${Date.now()}`;
|
|
360
580
|
await seedOrganizations(request, profile, { prefix, count: 12 });
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { BackendTRPCRouter } from "@m5kdev/backend/types";
|
|
2
|
+
import { transformer } from "@m5kdev/commons/utils/trpc";
|
|
1
3
|
import type { APIRequestContext, Page, TestInfo } from "@playwright/test";
|
|
2
4
|
import { expect } from "@playwright/test";
|
|
5
|
+
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
|
3
6
|
|
|
4
7
|
export type Profile = "standard" | "waitlist" | "expoStandard" | "expoWaitlist";
|
|
5
8
|
|
|
@@ -29,6 +32,7 @@ export type UserHarnessState = {
|
|
|
29
32
|
type: string | null;
|
|
30
33
|
parentId: string | null;
|
|
31
34
|
role: string;
|
|
35
|
+
memberId: string;
|
|
32
36
|
}>;
|
|
33
37
|
credentialAccount: boolean;
|
|
34
38
|
accountClaims: Array<{
|
|
@@ -48,7 +52,6 @@ export type UserHarnessState = {
|
|
|
48
52
|
activeOrganizationMemberId: string | null;
|
|
49
53
|
activeOrganizationRole: string | null;
|
|
50
54
|
activeOrganizationType: string | null;
|
|
51
|
-
activeTeamId: string | null;
|
|
52
55
|
impersonatedBy: string | null;
|
|
53
56
|
createdAt: string;
|
|
54
57
|
} | null;
|
|
@@ -287,3 +290,46 @@ export function resetTokenFrom(url: string) {
|
|
|
287
290
|
if (!token) throw new Error(`Could not read reset token from ${url}`);
|
|
288
291
|
return token;
|
|
289
292
|
}
|
|
293
|
+
|
|
294
|
+
export function memberEmail(member: {
|
|
295
|
+
email?: string | null;
|
|
296
|
+
user?: { email?: string | null } | null;
|
|
297
|
+
}): string {
|
|
298
|
+
return (member.email ?? member.user?.email ?? "").trim().toLowerCase();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function findMemberByEmail<
|
|
302
|
+
T extends {
|
|
303
|
+
email?: string | null;
|
|
304
|
+
user?: { email?: string | null } | null;
|
|
305
|
+
},
|
|
306
|
+
>(members: readonly T[], email: string): T | undefined {
|
|
307
|
+
const needle = email.trim().toLowerCase();
|
|
308
|
+
return members.find((member) => memberEmail(member) === needle);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function createTrpcClient(page: Page, profile: Profile) {
|
|
312
|
+
const cookies = await page.context().cookies(profiles[profile].serverUrl);
|
|
313
|
+
const cookie = cookies.map((entry) => `${entry.name}=${entry.value}`).join("; ");
|
|
314
|
+
type FetchInit = NonNullable<Parameters<typeof fetch>[1]>;
|
|
315
|
+
|
|
316
|
+
return createTRPCClient<BackendTRPCRouter>({
|
|
317
|
+
links: [
|
|
318
|
+
httpBatchLink({
|
|
319
|
+
url: `${profiles[profile].serverUrl}/trpc`,
|
|
320
|
+
fetch(url, options) {
|
|
321
|
+
const headers = new Headers(options?.headers);
|
|
322
|
+
if (cookie) {
|
|
323
|
+
headers.set("cookie", cookie);
|
|
324
|
+
}
|
|
325
|
+
return fetch(url, {
|
|
326
|
+
...options,
|
|
327
|
+
body: options?.body ? (options.body as FetchInit["body"]) : undefined,
|
|
328
|
+
headers,
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
transformer,
|
|
332
|
+
}),
|
|
333
|
+
],
|
|
334
|
+
});
|
|
335
|
+
}
|