create-m5kdev 0.35.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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/templates/minimal-app/apps/e2e/servers.ts.tpl +3 -0
  3. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
  4. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
  5. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
  6. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
  7. package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
  8. package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
  9. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
  10. package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
  11. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
  12. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
  13. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
  14. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
  15. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +5 -40
  16. package/templates/minimal-app/apps/server/db.ts.tpl +5 -42
  17. package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
  18. package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
  19. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
  20. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
  21. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
  22. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
  23. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
  24. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
  25. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
  26. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
  27. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
  28. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
  29. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +23 -9
  30. package/templates/minimal-app/apps/server/src/app.ts.tpl +4 -1
  31. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
  32. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +1 -1
  33. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
  34. package/templates/minimal-app/apps/server/src/schema.ts.tpl +3 -2
  35. package/templates/minimal-app/apps/shared/package.json.tpl +1 -0
  36. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
  37. package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
  38. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
  39. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
  40. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
  41. package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
  42. package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
  43. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
  44. package/templates/minimal-app/pnpm-workspace.yaml.tpl +12 -11
  45. package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
  46. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
  47. 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.getByText(/small blog that protects auth changes/i).first()).toBeVisible();
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.goto("/organization/members");
148
- await page.locator('input[type="email"]').fill(invitee);
149
- await page.getByRole("button", { name: /invite/i }).click();
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.goto("/organization/members");
183
- await page.locator('input[type="email"]').fill(email);
184
- await page.getByRole("button", { name: /invite/i }).click();
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: `Member Role for ${email}` }).click();
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 });
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "plugins": [
21
21
  "expo-router",
22
+ "expo-notifications",
22
23
  [
23
24
  "expo-splash-screen",
24
25
  {
@@ -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
- <Stack screenOptions={{ headerShown: false }} />
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
- authClient.organization
50
- .acceptInvitation({ invitationId })
51
- .then(async ({ data, error }) => {
52
- if (error) {
53
- throw new Error(error.message ?? "Unable to accept invitation");
54
- }
55
-
56
- const organizationId =
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<BackendTRPCRouter>({
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
+ }
@@ -18,8 +18,7 @@ export default defineConfig({
18
18
  dedupe: ["react", "react-dom", "react-router"],
19
19
  },
20
20
  envDir: "../shared",
21
- preview: {
22
- host: "0.0.0.0",
23
- port: 8080,
21
+ server: {
22
+ port: 5174,
24
23
  },
25
24
  });
@@ -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; teamId: 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
- const [existingTeam] = await orm
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.waitlist)
245
- .where(eq(schema.waitlist.claimUserId, user.id))
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.waitlist).values({
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(),