create-m5kdev 0.34.0 → 0.36.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.
- package/package.json +1 -1
- package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +1 -0
- package/templates/minimal-app/.cursor/rules/server-event-emit.mdc +41 -0
- package/templates/minimal-app/AGENTS.md.tpl +1 -0
- package/templates/minimal-app/CLAUDE.md.tpl +1 -0
- package/templates/minimal-app/README.md.tpl +2 -1
- package/templates/minimal-app/apps/e2e/servers.ts.tpl +3 -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/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 +1 -0
- package/templates/minimal-app/apps/server/db.e2e.ts.tpl +5 -40
- package/templates/minimal-app/apps/server/db.ts.tpl +5 -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/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/_journal.json.tpl +23 -9
- package/templates/minimal-app/apps/server/src/app.ts.tpl +4 -1
- package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.module.ts.tpl +7 -3
- package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.service.ts.tpl +12 -4
- 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.module.ts.tpl +7 -4
- package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +2 -2
- 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 +3 -2
- package/templates/minimal-app/apps/shared/package.json.tpl +2 -0
- package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
- package/templates/minimal-app/apps/shared/src/modules/demo-workflow/demo-workflow.constants.ts.tpl +1 -0
- 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/App.tsx.tpl +8 -5
- 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/StarterServerEventProvider.tsx.tpl +38 -0
- 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/package.json.tpl +5 -4
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +12 -11
- package/templates/minimal-app/template.manifest.json +1 -0
- package/templates/minimal-app/apps/landing/scripts/fly-deploy.mjs.tpl +0 -75
- package/templates/minimal-app/apps/landing/scripts/fly-secrets.mjs.tpl +0 -31
- 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/templates/minimal-app/apps/shared/scripts/fly-deploy.mjs.tpl +0 -75
- package/templates/minimal-app/apps/shared/scripts/fly-secrets.mjs.tpl +0 -31
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
authFetch,
|
|
4
4
|
clearEmails,
|
|
5
5
|
createProvisionedClaimUser,
|
|
6
|
+
createTrpcClient,
|
|
6
7
|
emailUrl,
|
|
7
8
|
expectLoginRejected,
|
|
9
|
+
findMemberByEmail,
|
|
8
10
|
getUserState,
|
|
9
11
|
latestEmail,
|
|
10
12
|
login,
|
|
@@ -29,6 +31,43 @@ async function createVerifiedAccount(
|
|
|
29
31
|
await verifyLatestEmail(request, profile, email);
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
async function inviteFromMembersPage(page: Page, email: string) {
|
|
35
|
+
await page.goto("/organization/members");
|
|
36
|
+
await expect(page.getByRole("button", { name: /^invite$/i })).toBeEnabled();
|
|
37
|
+
await page.locator('input[type="email"]').fill(email);
|
|
38
|
+
const inviteResponsePromise = page.waitForResponse(
|
|
39
|
+
(response) =>
|
|
40
|
+
response.request().method() === "POST" && response.url().includes("inviteOrganizationMember")
|
|
41
|
+
);
|
|
42
|
+
await page.getByRole("button", { name: /^invite$/i }).click();
|
|
43
|
+
const inviteResponse = await inviteResponsePromise;
|
|
44
|
+
expect(
|
|
45
|
+
inviteResponse.ok(),
|
|
46
|
+
`${inviteResponse.status()} ${inviteResponse.statusText()}: ${await inviteResponse.text()}`
|
|
47
|
+
).toBe(true);
|
|
48
|
+
const invitedRow = page.getByRole("row").filter({ hasText: email });
|
|
49
|
+
await expect(invitedRow.getByText(/^invited$/i)).toBeVisible();
|
|
50
|
+
return invitedRow;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function listOrganizationMembers(page: Page) {
|
|
54
|
+
const trpc = await createTrpcClient(page, profile);
|
|
55
|
+
return trpc.auth.listOrganizationMembers.query();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function expectBetterAuthInviteForbidden(page: Page, email: string) {
|
|
59
|
+
const response = await authFetch(page, profile, "/api/auth/organization/invite-member", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: {
|
|
62
|
+
organizationId: enterpriseOrgId,
|
|
63
|
+
email,
|
|
64
|
+
role: "member",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
expect(response.ok, `${response.status} ${response.statusText}: ${response.text}`).toBe(false);
|
|
68
|
+
expect(response.status).toBe(403);
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
test.beforeEach(async ({ request }) => {
|
|
33
72
|
await clearEmails(request, profile);
|
|
34
73
|
});
|
|
@@ -52,7 +91,7 @@ test("signup, verification email, login, protected app, and logout", async ({ pa
|
|
|
52
91
|
|
|
53
92
|
await login(page, email, password);
|
|
54
93
|
await expect(page.getByTestId("posts-route")).toBeVisible();
|
|
55
|
-
await expect(page.
|
|
94
|
+
await expect(page.getByRole("button", { name: /create post/i })).toBeVisible();
|
|
56
95
|
|
|
57
96
|
await logout(page);
|
|
58
97
|
});
|
|
@@ -144,14 +183,19 @@ test("organization invite email lets a new user join without creating a separate
|
|
|
144
183
|
const invitee = `member.${Date.now()}@auth-e2e.local`;
|
|
145
184
|
|
|
146
185
|
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
147
|
-
await page
|
|
148
|
-
|
|
149
|
-
await page
|
|
186
|
+
await inviteFromMembersPage(page, invitee);
|
|
187
|
+
|
|
188
|
+
const invited = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
189
|
+
expect(invited?.userId).toBeNull();
|
|
190
|
+
expect(invited?.invitationId).toBeTruthy();
|
|
191
|
+
const invitedMemberId = invited?.id;
|
|
192
|
+
expect(invitedMemberId).toBeTruthy();
|
|
150
193
|
|
|
151
194
|
const invite = await latestEmail(request, profile, {
|
|
152
195
|
to: invitee,
|
|
153
196
|
templateId: "organization-invite",
|
|
154
197
|
});
|
|
198
|
+
expect(emailUrl(invite)).toContain("/organization/accept-invitation?id=");
|
|
155
199
|
|
|
156
200
|
await logout(page);
|
|
157
201
|
await page.goto(emailUrl(invite));
|
|
@@ -163,7 +207,9 @@ test("organization invite email lets a new user join without creating a separate
|
|
|
163
207
|
const state = await getUserState(request, profile, invitee);
|
|
164
208
|
expect(state.user.emailVerified).toBe(true);
|
|
165
209
|
expect(state.organizations.map((organization) => organization.id)).toEqual([enterpriseOrgId]);
|
|
210
|
+
expect(state.organizations[0]?.memberId).toBe(invitedMemberId);
|
|
166
211
|
expect(state.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
|
|
212
|
+
expect(state.latestSession?.activeOrganizationMemberId).toBe(invitedMemberId);
|
|
167
213
|
});
|
|
168
214
|
|
|
169
215
|
test("organization invite lets an existing user join an additional org", async ({
|
|
@@ -179,14 +225,16 @@ test("organization invite lets an existing user join an additional org", async (
|
|
|
179
225
|
expect(beforeInvite.organizations[0]?.id).not.toBe(enterpriseOrgId);
|
|
180
226
|
|
|
181
227
|
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
182
|
-
await page
|
|
183
|
-
await page
|
|
184
|
-
|
|
228
|
+
await inviteFromMembersPage(page, email);
|
|
229
|
+
const invited = findMemberByEmail(await listOrganizationMembers(page), email);
|
|
230
|
+
expect(invited?.userId).toBeNull();
|
|
231
|
+
const invitedMemberId = invited?.id;
|
|
185
232
|
|
|
186
233
|
const invite = await latestEmail(request, profile, {
|
|
187
234
|
to: email,
|
|
188
235
|
templateId: "organization-invite",
|
|
189
236
|
});
|
|
237
|
+
expect(emailUrl(invite)).toContain("/organization/accept-invitation?id=");
|
|
190
238
|
|
|
191
239
|
await logout(page);
|
|
192
240
|
await login(page, email, password);
|
|
@@ -203,7 +251,184 @@ test("organization invite lets an existing user join an additional org", async (
|
|
|
203
251
|
expect(afterInvite.organizations.map((organization) => organization.id)).toContain(
|
|
204
252
|
enterpriseOrgId
|
|
205
253
|
);
|
|
254
|
+
expect(
|
|
255
|
+
afterInvite.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId
|
|
256
|
+
).toBe(invitedMemberId);
|
|
206
257
|
expect(afterInvite.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
|
|
258
|
+
expect(afterInvite.latestSession?.activeOrganizationMemberId).toBe(invitedMemberId);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("organization members page changes an invited member role before they accept", async ({
|
|
262
|
+
page,
|
|
263
|
+
request,
|
|
264
|
+
}) => {
|
|
265
|
+
const invitee = `invite-role.${Date.now()}@auth-e2e.local`;
|
|
266
|
+
|
|
267
|
+
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
268
|
+
await inviteFromMembersPage(page, invitee);
|
|
269
|
+
const invited = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
270
|
+
expect(invited?.role).toBe("member");
|
|
271
|
+
expect(invited?.userId).toBeNull();
|
|
272
|
+
const invitedMemberId = invited?.id;
|
|
273
|
+
expect(invitedMemberId).toBeTruthy();
|
|
274
|
+
|
|
275
|
+
const invitedRow = page.getByRole("row").filter({ hasText: invitee });
|
|
276
|
+
await invitedRow.getByRole("button", { name: `Role for ${invitee}` }).click();
|
|
277
|
+
const roleResponsePromise = page.waitForResponse(
|
|
278
|
+
(response) =>
|
|
279
|
+
response.request().method() === "POST" && response.url().includes("updateMemberRole")
|
|
280
|
+
);
|
|
281
|
+
await page.getByRole("option", { name: /^Admin$/ }).click();
|
|
282
|
+
const roleResponse = await roleResponsePromise;
|
|
283
|
+
expect(
|
|
284
|
+
roleResponse.ok(),
|
|
285
|
+
`${roleResponse.status()} ${roleResponse.statusText()}: ${await roleResponse.text()}`
|
|
286
|
+
).toBe(true);
|
|
287
|
+
|
|
288
|
+
const afterRole = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
289
|
+
expect(afterRole?.id).toBe(invitedMemberId);
|
|
290
|
+
expect(afterRole?.role).toBe("admin");
|
|
291
|
+
expect(afterRole?.userId).toBeNull();
|
|
292
|
+
expect(afterRole?.invitationId).toBeTruthy();
|
|
293
|
+
|
|
294
|
+
const invite = await latestEmail(request, profile, {
|
|
295
|
+
to: invitee,
|
|
296
|
+
templateId: "organization-invite",
|
|
297
|
+
});
|
|
298
|
+
await logout(page);
|
|
299
|
+
await page.goto(emailUrl(invite));
|
|
300
|
+
await expect(page).toHaveURL(/\/signup/);
|
|
301
|
+
await page.locator('[name="signup-password"]').fill("password1234");
|
|
302
|
+
await page.getByRole("button", { name: /sign up/i }).click();
|
|
303
|
+
await expect(page.getByTestId("session-email")).toContainText(invitee);
|
|
304
|
+
|
|
305
|
+
const state = await getUserState(request, profile, invitee);
|
|
306
|
+
expect(state.organizations).toEqual([
|
|
307
|
+
expect.objectContaining({
|
|
308
|
+
id: enterpriseOrgId,
|
|
309
|
+
memberId: invitedMemberId,
|
|
310
|
+
role: "admin",
|
|
311
|
+
}),
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("organization members page resends, updates, cancels, and revives an invited seat", async ({
|
|
316
|
+
page,
|
|
317
|
+
}) => {
|
|
318
|
+
const invitee = `invite-manage.${Date.now()}@auth-e2e.local`;
|
|
319
|
+
|
|
320
|
+
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
321
|
+
await inviteFromMembersPage(page, invitee);
|
|
322
|
+
const firstSeat = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
323
|
+
const memberId = firstSeat?.id;
|
|
324
|
+
expect(memberId).toBeTruthy();
|
|
325
|
+
|
|
326
|
+
await inviteFromMembersPage(page, invitee);
|
|
327
|
+
const resent = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
328
|
+
expect(resent?.id).toBe(memberId);
|
|
329
|
+
expect(resent?.userId).toBeNull();
|
|
330
|
+
|
|
331
|
+
const invitedRow = page.getByRole("row").filter({ hasText: invitee });
|
|
332
|
+
await invitedRow.getByRole("button", { name: `Role for ${invitee}` }).click();
|
|
333
|
+
await page.getByRole("option", { name: /^Admin$/ }).click();
|
|
334
|
+
await expect(page.getByText(/member role updated/i)).toBeVisible();
|
|
335
|
+
await expect
|
|
336
|
+
.poll(async () => findMemberByEmail(await listOrganizationMembers(page), invitee)?.role)
|
|
337
|
+
.toBe("admin");
|
|
338
|
+
|
|
339
|
+
await invitedRow.getByRole("button", { name: /cancel invitation/i }).click();
|
|
340
|
+
await expect(page.getByText(/invitation canceled/i)).toBeVisible();
|
|
341
|
+
await expect(invitedRow).toBeHidden();
|
|
342
|
+
expect(findMemberByEmail(await listOrganizationMembers(page), invitee)).toBeUndefined();
|
|
343
|
+
|
|
344
|
+
await inviteFromMembersPage(page, invitee);
|
|
345
|
+
const revived = findMemberByEmail(await listOrganizationMembers(page), invitee);
|
|
346
|
+
expect(revived?.id).toBe(memberId);
|
|
347
|
+
expect(revived?.userId).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("organization members page refuses a live member invite, then remove and leave drop the seat", async ({
|
|
351
|
+
page,
|
|
352
|
+
request,
|
|
353
|
+
}) => {
|
|
354
|
+
const email = `live-member.${Date.now()}@auth-e2e.local`;
|
|
355
|
+
const password = "password1234";
|
|
356
|
+
|
|
357
|
+
await createVerifiedAccount(page, request, email, password);
|
|
358
|
+
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
359
|
+
await inviteFromMembersPage(page, email);
|
|
360
|
+
const invitedMemberId = findMemberByEmail(await listOrganizationMembers(page), email)?.id;
|
|
361
|
+
|
|
362
|
+
const invite = await latestEmail(request, profile, {
|
|
363
|
+
to: email,
|
|
364
|
+
templateId: "organization-invite",
|
|
365
|
+
});
|
|
366
|
+
await logout(page);
|
|
367
|
+
await login(page, email, password);
|
|
368
|
+
await page.goto(emailUrl(invite));
|
|
369
|
+
await expect
|
|
370
|
+
.poll(async () => {
|
|
371
|
+
const state = await getUserState(request, profile, email);
|
|
372
|
+
return (
|
|
373
|
+
state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
|
|
374
|
+
null
|
|
375
|
+
);
|
|
376
|
+
})
|
|
377
|
+
.toBe(invitedMemberId);
|
|
378
|
+
|
|
379
|
+
await logout(page);
|
|
380
|
+
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
381
|
+
await page.goto("/organization/members");
|
|
382
|
+
await page.locator('input[type="email"]').fill(email);
|
|
383
|
+
await page.getByRole("button", { name: /^invite$/i }).click();
|
|
384
|
+
await expect(page.getByText(/live membership already exists/i)).toBeVisible();
|
|
385
|
+
|
|
386
|
+
const memberRow = page.getByRole("row").filter({ hasText: email });
|
|
387
|
+
await expect(memberRow.getByText(/^active$/i)).toBeVisible();
|
|
388
|
+
await memberRow.getByRole("button", { name: /remove member/i }).click();
|
|
389
|
+
await expect(page.getByText(/member removed/i)).toBeVisible();
|
|
390
|
+
await expect(memberRow).toBeHidden();
|
|
391
|
+
await expect
|
|
392
|
+
.poll(async () => {
|
|
393
|
+
const state = await getUserState(request, profile, email);
|
|
394
|
+
return state.organizations.some((organization) => organization.id === enterpriseOrgId);
|
|
395
|
+
})
|
|
396
|
+
.toBe(false);
|
|
397
|
+
|
|
398
|
+
await inviteFromMembersPage(page, email);
|
|
399
|
+
const revived = findMemberByEmail(await listOrganizationMembers(page), email);
|
|
400
|
+
expect(revived?.id).toBe(invitedMemberId);
|
|
401
|
+
|
|
402
|
+
const reinvite = await latestEmail(request, profile, {
|
|
403
|
+
to: email,
|
|
404
|
+
templateId: "organization-invite",
|
|
405
|
+
});
|
|
406
|
+
await logout(page);
|
|
407
|
+
await login(page, email, password);
|
|
408
|
+
await page.goto(emailUrl(reinvite));
|
|
409
|
+
await expect
|
|
410
|
+
.poll(async () => {
|
|
411
|
+
const state = await getUserState(request, profile, email);
|
|
412
|
+
return (
|
|
413
|
+
state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
|
|
414
|
+
null
|
|
415
|
+
);
|
|
416
|
+
})
|
|
417
|
+
.toBe(invitedMemberId);
|
|
418
|
+
|
|
419
|
+
const memberTrpc = await createTrpcClient(page, profile);
|
|
420
|
+
await memberTrpc.auth.leaveOrganization.mutate();
|
|
421
|
+
await expect
|
|
422
|
+
.poll(async () => {
|
|
423
|
+
const state = await getUserState(request, profile, email);
|
|
424
|
+
return state.organizations.some((organization) => organization.id === enterpriseOrgId);
|
|
425
|
+
})
|
|
426
|
+
.toBe(false);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("Better Auth organization invite HTTP is not a public invite path", async ({ page }) => {
|
|
430
|
+
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
431
|
+
await expectBetterAuthInviteForbidden(page, `ba-invite.${Date.now()}@auth-e2e.local`);
|
|
207
432
|
});
|
|
208
433
|
|
|
209
434
|
test("admin creates, bans, unbans, and authenticates a user", async ({ page, request }) => {
|
|
@@ -318,7 +543,7 @@ test("admin manages organization members from organization admin", async ({ page
|
|
|
318
543
|
})
|
|
319
544
|
.toBe("member");
|
|
320
545
|
|
|
321
|
-
await memberRow.getByRole("button", { name: `
|
|
546
|
+
await memberRow.getByRole("button", { name: `Role for ${email}` }).click();
|
|
322
547
|
await page.getByRole("option", { name: /^Admin$/ }).click();
|
|
323
548
|
await expect
|
|
324
549
|
.poll(async () => {
|
|
@@ -340,6 +565,63 @@ test("admin manages organization members from organization admin", async ({ page
|
|
|
340
565
|
.toBe(false);
|
|
341
566
|
});
|
|
342
567
|
|
|
568
|
+
test("admin transfers organization ownership to an active member", async ({ page, request }) => {
|
|
569
|
+
const email = `org-owner.${Date.now()}@auth-e2e.local`;
|
|
570
|
+
const password = "password1234";
|
|
571
|
+
const childOrgName = `Transfer Org ${Date.now()}`;
|
|
572
|
+
|
|
573
|
+
await createVerifiedAccount(page, request, email, password);
|
|
574
|
+
await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
|
|
575
|
+
await page.goto("/organization/manage");
|
|
576
|
+
await page.getByRole("button", { name: /new organization/i }).click();
|
|
577
|
+
const createDialog = page.getByRole("dialog");
|
|
578
|
+
await createDialog.getByPlaceholder(/acme subsidiary/i).fill(childOrgName);
|
|
579
|
+
await createDialog.getByRole("button", { name: /create organization/i }).click();
|
|
580
|
+
await expect(page.getByText(childOrgName)).toBeVisible();
|
|
581
|
+
|
|
582
|
+
await page.goto("/admin/organizations");
|
|
583
|
+
await page.locator('input[name="search"]').fill(childOrgName);
|
|
584
|
+
const organizationRow = page.getByRole("row").filter({ hasText: childOrgName });
|
|
585
|
+
await expect(organizationRow).toBeVisible();
|
|
586
|
+
await organizationRow.getByLabel(/manage members/i).click();
|
|
587
|
+
|
|
588
|
+
const dialog = page.getByRole("dialog").filter({ hasText: "Manage Members" });
|
|
589
|
+
await expect(dialog.getByText(childOrgName)).toBeVisible();
|
|
590
|
+
await dialog.getByText("Select user", { exact: true }).click();
|
|
591
|
+
await page.getByPlaceholder("Search users...").fill(email);
|
|
592
|
+
await page.getByRole("option", { name: new RegExp(email) }).click();
|
|
593
|
+
await dialog.getByRole("button", { name: /add member/i }).click();
|
|
594
|
+
|
|
595
|
+
const memberRow = dialog.getByRole("row").filter({ hasText: email });
|
|
596
|
+
await expect(memberRow).toBeVisible();
|
|
597
|
+
await expect(memberRow.getByRole("button", { name: `Transfer owner to ${email}` })).toBeVisible();
|
|
598
|
+
|
|
599
|
+
const transferResponsePromise = page.waitForResponse(
|
|
600
|
+
(response) =>
|
|
601
|
+
response.request().method() === "POST" && response.url().includes("transferOrganizationOwner")
|
|
602
|
+
);
|
|
603
|
+
await memberRow.getByRole("button", { name: `Transfer owner to ${email}` }).click();
|
|
604
|
+
const transferResponse = await transferResponsePromise;
|
|
605
|
+
expect(
|
|
606
|
+
transferResponse.ok(),
|
|
607
|
+
`${transferResponse.status()} ${transferResponse.statusText()}: ${await transferResponse.text()}`
|
|
608
|
+
).toBe(true);
|
|
609
|
+
|
|
610
|
+
await expect
|
|
611
|
+
.poll(async () => {
|
|
612
|
+
const state = await getUserState(request, profile, email);
|
|
613
|
+
return state.organizations.find((organization) => organization.name === childOrgName)?.role;
|
|
614
|
+
})
|
|
615
|
+
.toBe("owner");
|
|
616
|
+
await expect
|
|
617
|
+
.poll(async () => {
|
|
618
|
+
const state = await getUserState(request, profile, profiles.standard.adminEmail);
|
|
619
|
+
return state.organizations.find((organization) => organization.name === childOrgName)?.role;
|
|
620
|
+
})
|
|
621
|
+
.toBe("admin");
|
|
622
|
+
await expect(memberRow.getByRole("button", { name: `Transfer owner to ${email}` })).toBeHidden();
|
|
623
|
+
});
|
|
624
|
+
|
|
343
625
|
test("admin organization pagination stays on the selected page", async ({ page, request }) => {
|
|
344
626
|
const prefix = `Pagination Org ${Date.now()}`;
|
|
345
627
|
await seedOrganizations(request, profile, { prefix, count: 12 });
|
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
"@expo/metro-runtime": "~56.0.13",
|
|
22
22
|
"@gorhom/bottom-sheet": "^5.2.14",
|
|
23
23
|
"@m5kdev/commons": "catalog:m5kdev",
|
|
24
|
+
"@m5kdev/expo": "catalog:m5kdev",
|
|
24
25
|
"@m5kdev/frontend": "catalog:m5kdev",
|
|
25
26
|
"@trpc/client": "catalog:m5kdev",
|
|
27
|
+
"@tanstack/react-query": "catalog:m5kdev",
|
|
26
28
|
"expo": "~56.0.5",
|
|
27
29
|
"expo-constants": "~56.0.16",
|
|
28
30
|
"expo-linking": "~56.0.12",
|
|
31
|
+
"expo-notifications": "~56.0.18",
|
|
29
32
|
"expo-router": "~56.2.7",
|
|
30
33
|
"expo-splash-screen": "~56.0.10",
|
|
31
34
|
"expo-status-bar": "~56.0.4",
|
|
@@ -47,6 +50,7 @@
|
|
|
47
50
|
"uniwind": "^1.5.0"
|
|
48
51
|
},
|
|
49
52
|
"devDependencies": {
|
|
53
|
+
"{{PACKAGE_SCOPE}}/server": "workspace:*",
|
|
50
54
|
"@biomejs/biome": "catalog:m5kdev",
|
|
51
55
|
"@m5kdev/backend": "catalog:m5kdev",
|
|
52
56
|
"@m5kdev/config": "catalog:m5kdev",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AppConfigProvider } from "@m5kdev/frontend/modules/app/components/AppConfigProvider";
|
|
2
|
+
import { AppTrpcQueryProvider } from "@m5kdev/frontend/modules/app/components/AppTrpcQueryProvider";
|
|
2
3
|
import { AuthProvider } from "@m5kdev/frontend/modules/auth/components/AuthProvider";
|
|
3
4
|
import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
|
|
4
5
|
import { Stack } from "expo-router";
|
|
@@ -22,7 +23,9 @@ export default function RootLayout(): JSX.Element {
|
|
|
22
23
|
}}
|
|
23
24
|
>
|
|
24
25
|
<AuthProvider loader={<LoadingScreen />}>
|
|
25
|
-
<
|
|
26
|
+
<AppTrpcQueryProvider>
|
|
27
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
28
|
+
</AppTrpcQueryProvider>
|
|
26
29
|
</AuthProvider>
|
|
27
30
|
</AppConfigProvider>
|
|
28
31
|
</HeroUINativeProvider>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useNativePush } from "@m5kdev/expo/hooks/useNativePush";
|
|
2
|
+
import { NotificationInbox } from "@m5kdev/expo/modules/notification/components/NotificationInbox";
|
|
3
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
4
|
+
import { Link, Redirect } from "expo-router";
|
|
5
|
+
import { Button, Card, Typography } from "heroui-native";
|
|
6
|
+
import { ScrollView, Text, View } from "react-native";
|
|
7
|
+
import { useTRPC } from "../lib/trpc";
|
|
8
|
+
|
|
9
|
+
export default function NotificationsScreen() {
|
|
10
|
+
const { data: session } = useSession();
|
|
11
|
+
const trpc = useTRPC();
|
|
12
|
+
const nativePush = useNativePush({
|
|
13
|
+
enabled: Boolean(session?.user?.id),
|
|
14
|
+
messages: {
|
|
15
|
+
unsupported: "Native push is not available on this platform.",
|
|
16
|
+
denied: "Notification permission was denied.",
|
|
17
|
+
noToken: "Could not read a device push token.",
|
|
18
|
+
failed: "Could not register this device.",
|
|
19
|
+
enabled: "This device is registered for push.",
|
|
20
|
+
},
|
|
21
|
+
registerDeviceMutation: trpc.notification.registerDevice.mutationOptions(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!session) {
|
|
25
|
+
return <Redirect href="/login" />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pushLabel =
|
|
29
|
+
nativePush.flowStatus === "done"
|
|
30
|
+
? "This device is registered for push."
|
|
31
|
+
: nativePush.permission === "denied"
|
|
32
|
+
? "Notification permission was denied."
|
|
33
|
+
: "Enable push for this device";
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<ScrollView className="flex-1 bg-background" contentContainerClassName="px-5 py-8">
|
|
37
|
+
<View className="gap-5">
|
|
38
|
+
<View className="gap-2">
|
|
39
|
+
<Text className="text-3xl font-semibold text-foreground">Notifications</Text>
|
|
40
|
+
<Typography.Paragraph className="text-default-600">
|
|
41
|
+
Inbox for this membership. Push registration is this User's device.
|
|
42
|
+
</Typography.Paragraph>
|
|
43
|
+
</View>
|
|
44
|
+
<Card className="gap-3 p-5">
|
|
45
|
+
<Text className="text-lg font-semibold text-foreground">This device</Text>
|
|
46
|
+
<Button
|
|
47
|
+
isDisabled={!nativePush.canSubscribe || nativePush.isWorking}
|
|
48
|
+
onPress={() => {
|
|
49
|
+
void nativePush.subscribe();
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{pushLabel}
|
|
53
|
+
</Button>
|
|
54
|
+
{nativePush.feedback ? (
|
|
55
|
+
<Typography.Paragraph className="text-default-600">
|
|
56
|
+
{nativePush.feedback}
|
|
57
|
+
</Typography.Paragraph>
|
|
58
|
+
) : null}
|
|
59
|
+
</Card>
|
|
60
|
+
<Card className="gap-3 p-5">
|
|
61
|
+
<Text className="text-lg font-semibold text-foreground">Inbox</Text>
|
|
62
|
+
<NotificationInbox />
|
|
63
|
+
</Card>
|
|
64
|
+
<Link href="/posts" asChild>
|
|
65
|
+
<Button variant="outline">Back to posts</Button>
|
|
66
|
+
</Link>
|
|
67
|
+
</View>
|
|
68
|
+
</ScrollView>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -46,22 +46,14 @@ export default function AcceptInvitationScreen() {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
.
|
|
51
|
-
.then(async (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
(data as { invitation?: { organizationId?: string } | null } | null)?.invitation
|
|
58
|
-
?.organizationId ?? null;
|
|
59
|
-
|
|
60
|
-
if (organizationId) {
|
|
61
|
-
const activeResult = await authClient.organization.setActive({ organizationId });
|
|
62
|
-
if (activeResult.error) {
|
|
63
|
-
throw new Error(activeResult.error.message ?? "Unable to activate organization");
|
|
64
|
-
}
|
|
49
|
+
trpcClient.auth.acceptOrganizationInvitation
|
|
50
|
+
.mutate({ id: invitationId })
|
|
51
|
+
.then(async (accepted) => {
|
|
52
|
+
const activeResult = await authClient.organization.setActive({
|
|
53
|
+
organizationId: accepted.organizationId,
|
|
54
|
+
});
|
|
55
|
+
if (activeResult.error) {
|
|
56
|
+
throw new Error(activeResult.error.message ?? "Unable to activate organization");
|
|
65
57
|
}
|
|
66
58
|
|
|
67
59
|
if (!cancelled) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
2
|
-
import { Link, Redirect } from "expo-router";
|
|
2
|
+
import { type Href, Link, Redirect } from "expo-router";
|
|
3
3
|
import { Button, Card, Typography } from "heroui-native";
|
|
4
4
|
import { ScrollView, Text, View } from "react-native";
|
|
5
5
|
|
|
@@ -29,6 +29,9 @@ export default function PostsScreen() {
|
|
|
29
29
|
@m5kdev/frontend.
|
|
30
30
|
</Typography.Paragraph>
|
|
31
31
|
</Card>
|
|
32
|
+
<Link href={"/notifications" as Href} asChild>
|
|
33
|
+
<Button variant="outline">Notifications</Button>
|
|
34
|
+
</Link>
|
|
32
35
|
<Link href="/logout" asChild>
|
|
33
36
|
<Button testID="logout-submit" variant="outline">
|
|
34
37
|
Logout
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { BackendTRPCRouter } from "@m5kdev/backend/types";
|
|
2
1
|
import { transformer } from "@m5kdev/commons/utils/trpc";
|
|
2
|
+
import { useAppTRPC, useAppTRPCClient } from "@m5kdev/frontend/modules/app/hooks/useAppTrpc";
|
|
3
|
+
import type { AppRouter } from "{{PACKAGE_SCOPE}}/server/types";
|
|
3
4
|
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
|
4
5
|
import { serverUrl } from "../config";
|
|
5
6
|
|
|
6
|
-
export const trpcClient = createTRPCClient<
|
|
7
|
+
export const trpcClient = createTRPCClient<AppRouter>({
|
|
7
8
|
links: [
|
|
8
9
|
httpBatchLink({
|
|
9
10
|
url: `${serverUrl}/trpc`,
|
|
@@ -18,3 +19,11 @@ export const trpcClient = createTRPCClient<BackendTRPCRouter>({
|
|
|
18
19
|
}),
|
|
19
20
|
],
|
|
20
21
|
});
|
|
22
|
+
|
|
23
|
+
export function useTRPC() {
|
|
24
|
+
return useAppTRPC<AppRouter>();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useTRPCClient() {
|
|
28
|
+
return useAppTRPCClient<AppRouter>();
|
|
29
|
+
}
|
|
@@ -45,6 +45,7 @@ apps/server/src/modules/<module>/
|
|
|
45
45
|
|
|
46
46
|
// m5k:workflows:start
|
|
47
47
|
- `WorkflowModule` is registered in `app.ts`. Start **Redis** locally (`REDIS_URL`) before `pnpm dev` on the server, or background jobs will not run.
|
|
48
|
+
- Server event: emit through Auth from a job `.handle` when the UI must refresh a change the trigger mutation did not return. See `.cursor/rules/server-event-emit.mdc`.
|
|
48
49
|
// m5k:workflows:end
|
|
49
50
|
- `index.ts` calls `builtBackendApp.start()`, which listens and handles SIGINT/SIGTERM. Extra shutdown work (telemetry) is `onShutdown` on `createBackendApp`.
|
|
50
51
|
- After changing Drizzle tables, run `pnpm --filter ./apps/server drizzle:generate` then `drizzle:migrate` — do not hand-edit SQL migrations in this repo.
|
|
@@ -96,10 +96,9 @@ async function ensureUser(input: {
|
|
|
96
96
|
async function ensureOrganization(input: {
|
|
97
97
|
orm: SeedOrm;
|
|
98
98
|
userId: string;
|
|
99
|
-
}): Promise<{ organizationId: string
|
|
99
|
+
}): Promise<{ organizationId: string }> {
|
|
100
100
|
const { orm, userId } = input;
|
|
101
101
|
const organizationId = `auth-e2e-enterprise-${profile}`;
|
|
102
|
-
const teamId = `auth-e2e-team-${profile}`;
|
|
103
102
|
|
|
104
103
|
const [existingOrganization] = await orm
|
|
105
104
|
.select()
|
|
@@ -133,39 +132,7 @@ async function ensureOrganization(input: {
|
|
|
133
132
|
});
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
|
|
137
|
-
.select()
|
|
138
|
-
.from(schema.teams)
|
|
139
|
-
.where(eq(schema.teams.id, teamId))
|
|
140
|
-
.limit(1);
|
|
141
|
-
|
|
142
|
-
if (!existingTeam) {
|
|
143
|
-
await orm.insert(schema.teams).values({
|
|
144
|
-
id: teamId,
|
|
145
|
-
name: "Editorial",
|
|
146
|
-
organizationId,
|
|
147
|
-
createdAt: new Date(),
|
|
148
|
-
updatedAt: new Date(),
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const [existingTeamMember] = await orm
|
|
153
|
-
.select()
|
|
154
|
-
.from(schema.teamMembers)
|
|
155
|
-
.where(eq(schema.teamMembers.userId, userId))
|
|
156
|
-
.limit(1);
|
|
157
|
-
|
|
158
|
-
if (!existingTeamMember) {
|
|
159
|
-
await orm.insert(schema.teamMembers).values({
|
|
160
|
-
id: uuidv4(),
|
|
161
|
-
teamId,
|
|
162
|
-
userId,
|
|
163
|
-
role: "owner",
|
|
164
|
-
createdAt: new Date(),
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return { organizationId, teamId };
|
|
135
|
+
return { organizationId };
|
|
169
136
|
}
|
|
170
137
|
|
|
171
138
|
async function seedPosts(input: {
|
|
@@ -220,7 +187,6 @@ async function seedExpiredWaitlistCode(orm: SeedOrm): Promise<void> {
|
|
|
220
187
|
id: uuidv4(),
|
|
221
188
|
email: `expired.${profile}@auth-e2e.local`,
|
|
222
189
|
name: "Expired Invite",
|
|
223
|
-
type: "WAITLIST",
|
|
224
190
|
status: "INVITED",
|
|
225
191
|
code: "expired-waitlist-code",
|
|
226
192
|
expiresAt: new Date(Date.now() - 60_000),
|
|
@@ -241,16 +207,15 @@ async function seedProvisionedClaimUser(orm: SeedOrm): Promise<typeof schema.use
|
|
|
241
207
|
|
|
242
208
|
const [existingClaim] = await orm
|
|
243
209
|
.select()
|
|
244
|
-
.from(schema.
|
|
245
|
-
.where(eq(schema.
|
|
210
|
+
.from(schema.accountClaims)
|
|
211
|
+
.where(eq(schema.accountClaims.claimUserId, user.id))
|
|
246
212
|
.limit(1);
|
|
247
213
|
if (existingClaim) {
|
|
248
214
|
return user;
|
|
249
215
|
}
|
|
250
216
|
|
|
251
|
-
await orm.insert(schema.
|
|
217
|
+
await orm.insert(schema.accountClaims).values({
|
|
252
218
|
id: `auth-e2e-claim-${profile}`,
|
|
253
|
-
type: "ACCOUNT_CLAIM",
|
|
254
219
|
status: "INVITED",
|
|
255
220
|
claimUserId: user.id,
|
|
256
221
|
code: uuidv4(),
|