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
|
@@ -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
|
+
}
|
|
@@ -20,6 +20,9 @@ apps/server/src/modules/<module>/
|
|
|
20
20
|
├── <module>.repository.ts
|
|
21
21
|
├── <module>.service.ts
|
|
22
22
|
├── <module>.trpc.ts
|
|
23
|
+
// m5k:mcp:start
|
|
24
|
+
├── <module>.mcp.ts
|
|
25
|
+
// m5k:mcp:end
|
|
23
26
|
└── <module>.module.ts
|
|
24
27
|
```
|
|
25
28
|
|
|
@@ -28,6 +31,9 @@ apps/server/src/modules/<module>/
|
|
|
28
31
|
- Repositories own persistence and query construction.
|
|
29
32
|
- Services own business rules, orchestration, and context-aware defaults.
|
|
30
33
|
- tRPC files own transport only and must delegate to services.
|
|
34
|
+
// m5k:mcp:start
|
|
35
|
+
- MCP call maps live in `<module>.mcp.ts` and are contributed from `mcp()` / `mcpUser()`. Delegate to Procedures or unguarded service methods; do not scrape Service properties.
|
|
36
|
+
// m5k:mcp:end
|
|
31
37
|
- Register Core Modules and app modules in `apps/server/src/app.ts` via `createBackendApp(config, [modules])`. Do not use `backendApp.use`. Optional Backend Modules (`@m5kdev/module-*`) are not scaffolded; add them only when the product needs Clay, Docx, Pdf, Social, or Video.
|
|
32
38
|
// m5k:ai:start
|
|
33
39
|
- `AIModule` is registered in `app.ts` with an app-owned Mastra Agent (`assistant`). Conversation HTTP lives on `/ai`. Set `OPENROUTER_API_KEY` for live replies. Do not add a Starter service wrapper around `AIService` chat.
|
|
@@ -50,6 +56,10 @@ apps/server/src/modules/<module>/
|
|
|
50
56
|
- `index.ts` calls `builtBackendApp.start()`, which listens and handles SIGINT/SIGTERM. Extra shutdown work (telemetry) is `onShutdown` on `createBackendApp`.
|
|
51
57
|
- After changing Drizzle tables, run `pnpm --filter ./apps/server drizzle:generate` then `drizzle:migrate` — do not hand-edit SQL migrations in this repo.
|
|
52
58
|
|
|
59
|
+
// m5k:mcp:start
|
|
60
|
+
- `McpModule` is registered in `app.ts`. MCP clients authenticate with Better Auth MCP OAuth (not API keys). Compose `auth.oauth.db` plus `mcpAllowlistEntries`. Contribute Organization-scoped calls from `posts.mcp.ts`. Omit the module to disable MCP.
|
|
61
|
+
// m5k:mcp:end
|
|
62
|
+
|
|
53
63
|
// m5k:notifications:start
|
|
54
64
|
- `NotificationModule` is registered in `app.ts` when this feature is selected. Push-related server env vars are documented in `apps/shared/.env.example`.
|
|
55
65
|
// m5k:notifications:end
|