create-ej-app 26.0.0 → 28.0.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 (357) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +79 -29
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -1
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -39
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +1 -0
  140. package/template/saas-starter/.env.example +15 -15
  141. package/template/saas-starter/AGENTS.md +302 -0
  142. package/template/saas-starter/CLAUDE.md +1 -0
  143. package/template/saas-starter/README.md +124 -2
  144. package/template/saas-starter/bunfig.toml +3 -0
  145. package/template/saas-starter/eslint.config.mjs +6 -5
  146. package/template/saas-starter/gitignore +6 -1
  147. package/template/saas-starter/next.config.ts +19 -1
  148. package/template/saas-starter/package.json +50 -44
  149. package/template/saas-starter/public/logo-dark.svg +4 -0
  150. package/template/saas-starter/public/logo.svg +4 -0
  151. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  152. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  153. package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
  154. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
  155. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
  156. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  157. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  158. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
  159. package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
  160. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  161. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  162. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  163. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
  164. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  165. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  166. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  167. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  168. package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
  169. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  170. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  171. package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
  172. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  173. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  174. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
  175. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
  176. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
  177. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
  178. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
  179. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
  180. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
  181. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  182. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  188. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
  189. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
  190. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
  191. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  192. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
  193. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
  194. package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
  195. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
  196. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
  197. package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
  198. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  199. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  200. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  201. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  202. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  203. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
  204. package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
  205. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
  206. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
  207. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
  208. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  209. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  210. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  211. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  212. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  213. package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
  214. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  215. package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
  216. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
  217. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
  218. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  219. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  220. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  221. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  222. package/template/saas-starter/src/app/error.tsx +37 -0
  223. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  224. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  225. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  226. package/template/saas-starter/src/app/global-error.tsx +38 -0
  227. package/template/saas-starter/src/app/globals.css +29 -0
  228. package/template/saas-starter/src/app/layout.tsx +43 -19
  229. package/template/saas-starter/src/app/not-found.tsx +25 -3
  230. package/template/saas-starter/src/app/robots.ts +14 -10
  231. package/template/saas-starter/src/app/sitemap.ts +2 -1
  232. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  233. package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
  234. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  235. package/template/saas-starter/src/components/data-table/index.tsx +65 -64
  236. package/template/saas-starter/src/components/data-table/types.ts +5 -4
  237. package/template/saas-starter/src/components/error.tsx +45 -0
  238. package/template/saas-starter/src/components/file-upload.tsx +65 -26
  239. package/template/saas-starter/src/components/loader.tsx +18 -2
  240. package/template/saas-starter/src/components/logo.tsx +49 -0
  241. package/template/saas-starter/src/components/navigation-search.tsx +2 -2
  242. package/template/saas-starter/src/components/page-heading.tsx +6 -0
  243. package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
  244. package/template/saas-starter/src/components/route-error.tsx +55 -0
  245. package/template/saas-starter/src/components/screen-size.tsx +36 -0
  246. package/template/saas-starter/src/components/site-footer.tsx +20 -4
  247. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  248. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  249. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  250. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  251. package/template/saas-starter/src/components/theme-provider.tsx +48 -0
  252. package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
  253. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  254. package/template/saas-starter/src/components/ui/badge.tsx +6 -1
  255. package/template/saas-starter/src/components/ui/button.tsx +2 -0
  256. package/template/saas-starter/src/components/ui/command.tsx +1 -1
  257. package/template/saas-starter/src/components/user-menu.tsx +147 -24
  258. package/template/saas-starter/src/db/common.ts +33 -1
  259. package/template/saas-starter/src/db/enums.ts +24 -0
  260. package/template/saas-starter/src/db/index.ts +14 -5
  261. package/template/saas-starter/src/db/relations.ts +40 -3
  262. package/template/saas-starter/src/db/schema.ts +186 -35
  263. package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
  264. package/template/saas-starter/src/emails/invitation.tsx +34 -21
  265. package/template/saas-starter/src/emails/magic-link.tsx +15 -13
  266. package/template/saas-starter/src/emails/test.tsx +11 -9
  267. package/template/saas-starter/src/emails/welcome.tsx +12 -9
  268. package/template/saas-starter/src/env.ts +1 -1
  269. package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
  270. package/template/saas-starter/src/instrumentation.ts +20 -2
  271. package/template/saas-starter/src/lib/actions.ts +34 -0
  272. package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
  273. package/template/saas-starter/src/lib/app-navigation.ts +45 -41
  274. package/template/saas-starter/src/lib/auth-client.ts +8 -3
  275. package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
  276. package/template/saas-starter/src/lib/auth.ts +122 -21
  277. package/template/saas-starter/src/lib/billing.ts +59 -24
  278. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  279. package/template/saas-starter/src/lib/constants.ts +78 -9
  280. package/template/saas-starter/src/lib/delay.ts +10 -0
  281. package/template/saas-starter/src/lib/email-service.ts +30 -1
  282. package/template/saas-starter/src/lib/errors.ts +74 -0
  283. package/template/saas-starter/src/lib/format-currency.ts +22 -24
  284. package/template/saas-starter/src/lib/format-date.ts +69 -6
  285. package/template/saas-starter/src/lib/image-url.ts +54 -0
  286. package/template/saas-starter/src/lib/nodemailer.ts +27 -3
  287. package/template/saas-starter/src/lib/organization-access.ts +56 -76
  288. package/template/saas-starter/src/lib/otel.ts +92 -2
  289. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  290. package/template/saas-starter/src/lib/plans.ts +40 -13
  291. package/template/saas-starter/src/lib/rbac.ts +44 -0
  292. package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
  293. package/template/saas-starter/src/lib/safe-action.ts +137 -17
  294. package/template/saas-starter/src/lib/search.ts +16 -0
  295. package/template/saas-starter/src/lib/seo.ts +7 -0
  296. package/template/saas-starter/src/lib/siteConfig.ts +17 -1
  297. package/template/saas-starter/src/lib/storage.ts +176 -46
  298. package/template/saas-starter/src/lib/stripe.ts +4 -1
  299. package/template/saas-starter/src/lib/toast-message.ts +87 -1
  300. package/template/saas-starter/src/lib/validations.ts +85 -2
  301. package/template/saas-starter/src/proxy.ts +48 -0
  302. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  303. package/template/saas-starter/src/scripts/seed.ts +82 -8
  304. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  305. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
  306. package/template/saas-starter/src/scripts/test-mail.ts +28 -20
  307. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  308. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  309. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  310. package/template/saas-starter/tests/helpers/action.ts +71 -0
  311. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  312. package/template/saas-starter/tests/helpers/index.ts +2 -0
  313. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  314. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  315. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  316. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  317. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  318. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  319. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  320. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  321. package/template/saas-starter/tests/setup/preload.ts +108 -0
  322. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  323. package/template/saas-starter/tsconfig.json +1 -0
  324. package/cli.js +0 -147
  325. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  326. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  327. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  328. package/template/nextjs/terraform/backend-service.tf +0 -13
  329. package/template/nextjs/terraform/cleanup.sh +0 -9
  330. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  331. package/template/nextjs/terraform/cloudrun.tf +0 -68
  332. package/template/nextjs/terraform/database.tf +0 -60
  333. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  334. package/template/nextjs/terraform/gitignore +0 -4
  335. package/template/nextjs/terraform/ip-address.tf +0 -11
  336. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  337. package/template/nextjs/terraform/network.tf +0 -21
  338. package/template/nextjs/terraform/pre.sh +0 -3
  339. package/template/nextjs/terraform/providers.tf +0 -18
  340. package/template/nextjs/terraform/secret.txt +0 -11
  341. package/template/nextjs/terraform/setup.sh +0 -25
  342. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  343. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  344. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  345. package/template/nextjs/terraform/terraform.tfvars +0 -4
  346. package/template/nextjs/terraform/url-map.tf +0 -23
  347. package/template/nextjs/terraform/variables.tf +0 -21
  348. package/template/saas-starter/next-env.d.ts +0 -6
  349. package/template/saas-starter/src/actions/uploads.ts +0 -28
  350. package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
  351. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
  352. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
  353. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
  354. package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
  355. package/template/saas-starter/src/app/proxy.ts +0 -28
  356. package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
  357. package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
@@ -1,6 +1,14 @@
1
+ /**
2
+ * @fileoverview Drizzle table definitions.
3
+ *
4
+ * Enums live in `./enums.ts` and reusable column helpers in `./common.ts`.
5
+ * Every foreign key leads an index and declares an explicit `onDelete`;
6
+ * `tests/db/fk-indexes.test.ts` enforces both.
7
+ */
1
8
  import { sql } from 'drizzle-orm'
2
9
  import {
3
10
  boolean,
11
+ check,
4
12
  index,
5
13
  integer,
6
14
  jsonb,
@@ -9,27 +17,35 @@ import {
9
17
  uniqueIndex,
10
18
  varchar
11
19
  } from 'drizzle-orm/pg-core'
12
- import { commonFieldDefs } from './common'
20
+ import { commonFieldDefs, nonNegativeCheck } from './common'
13
21
  import {
14
22
  organizationSubscriptionIntervalEnum,
15
23
  organizationSubscriptionPlanEnum,
16
- organizationSubscriptionStatusEnum
24
+ organizationSubscriptionStatusEnum,
25
+ supportTicketPriorityEnum,
26
+ supportTicketStatusEnum
17
27
  } from './enums'
18
28
 
19
29
  export * from './enums'
20
30
 
21
- export const usersTable = pgTable('users', {
22
- id: commonFieldDefs.id('user'),
23
- name: text('name').notNull(),
24
- email: text('email').notNull().unique(),
25
- emailVerified: boolean('email_verified').default(false).notNull(),
26
- image: text('image'),
27
- role: text('role'),
28
- banned: boolean('banned').default(false),
29
- banReason: text('ban_reason'),
30
- banExpires: commonFieldDefs.date('ban_expires'),
31
- ...commonFieldDefs.dates
32
- })
31
+ // ─── Auth (Better Auth) ─────────────────────────────────────────
32
+
33
+ export const usersTable = pgTable(
34
+ 'users',
35
+ {
36
+ id: commonFieldDefs.id('user'),
37
+ name: text('name').notNull(),
38
+ email: text('email').notNull().unique(),
39
+ emailVerified: boolean('email_verified').default(false).notNull(),
40
+ image: text('image'),
41
+ role: text('role'),
42
+ banned: boolean('banned').default(false),
43
+ banReason: text('ban_reason'),
44
+ banExpires: commonFieldDefs.date('ban_expires'),
45
+ ...commonFieldDefs.dates
46
+ },
47
+ table => [index().on(table.createdAt), index().on(table.role)]
48
+ )
33
49
 
34
50
  export const sessionsTable = pgTable(
35
51
  'sessions',
@@ -70,13 +86,19 @@ export const accountsTable = pgTable(
70
86
  table => [index().on(table.userId)]
71
87
  )
72
88
 
73
- export const verificationsTable = pgTable('verifications', {
74
- id: commonFieldDefs.id('verification'),
75
- identifier: text('identifier').notNull(),
76
- value: text('value').notNull(),
77
- expiresAt: commonFieldDefs.date('expires_at').notNull(),
78
- ...commonFieldDefs.dates
79
- })
89
+ export const verificationsTable = pgTable(
90
+ 'verifications',
91
+ {
92
+ id: commonFieldDefs.id('verification'),
93
+ identifier: text('identifier').notNull(),
94
+ value: text('value').notNull(),
95
+ expiresAt: commonFieldDefs.date('expires_at').notNull(),
96
+ ...commonFieldDefs.dates
97
+ },
98
+ table => [index().on(table.identifier)]
99
+ )
100
+
101
+ // ─── Organizations (Better Auth) ────────────────────────────────
80
102
 
81
103
  export const organizationsTable = pgTable('organizations', {
82
104
  id: commonFieldDefs.id('organization'),
@@ -100,7 +122,10 @@ export const membersTable = pgTable(
100
122
  role: text('role').notNull(),
101
123
  ...commonFieldDefs.dates
102
124
  },
103
- table => [index().on(table.organizationId), index().on(table.userId)]
125
+ table => [
126
+ uniqueIndex().on(table.organizationId, table.userId),
127
+ index().on(table.userId)
128
+ ]
104
129
  )
105
130
 
106
131
  export const invitationsTable = pgTable(
@@ -116,20 +141,21 @@ export const invitationsTable = pgTable(
116
141
  expiresAt: commonFieldDefs.date('expires_at'),
117
142
  inviterId: text('inviter_id')
118
143
  .notNull()
119
- .references(() => usersTable.id, {
120
- onDelete: 'cascade'
121
- }),
144
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
122
145
  ...commonFieldDefs.dates
123
146
  },
124
147
  table => [
125
148
  index().on(table.organizationId),
126
149
  index().on(table.email),
150
+ index().on(table.inviterId),
127
151
  uniqueIndex()
128
152
  .on(table.organizationId, table.email)
129
153
  .where(sql`${table.status} = 'pending'`)
130
154
  ]
131
155
  )
132
156
 
157
+ // ─── Billing ────────────────────────────────────────────────────
158
+
133
159
  export const organizationSubscriptionsTable = pgTable(
134
160
  'organization_subscriptions',
135
161
  {
@@ -137,7 +163,7 @@ export const organizationSubscriptionsTable = pgTable(
137
163
  organizationId: text('organization_id')
138
164
  .notNull()
139
165
  .unique()
140
- .references(() => organizationsTable.id),
166
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
141
167
  plan: organizationSubscriptionPlanEnum('plan').notNull().default('free'),
142
168
  status: organizationSubscriptionStatusEnum('status')
143
169
  .notNull()
@@ -154,6 +180,7 @@ export const organizationSubscriptionsTable = pgTable(
154
180
  }
155
181
  )
156
182
 
183
+ /** One row per Stripe event, so a redelivered webhook is processed once. */
157
184
  export const stripeWebhooksEventsTable = pgTable('stripe_webhooks_events', {
158
185
  id: text('id').primaryKey(),
159
186
  type: text('type').notNull(),
@@ -161,31 +188,155 @@ export const stripeWebhooksEventsTable = pgTable('stripe_webhooks_events', {
161
188
  processed: boolean('processed').notNull().default(false),
162
189
  processedAt: commonFieldDefs.date('processed_at'),
163
190
  error: text('error'),
164
- payload: jsonb('payload') // store only on error
191
+ // Stored only when processing fails, for replay and debugging.
192
+ payload: jsonb('payload')
165
193
  })
166
194
 
195
+ // ─── Support ────────────────────────────────────────────────────
196
+
197
+ /** Single-row counter behind `TICKET-000001` numbers; see `scripts/setup-db.ts`. */
198
+ export const supportTicketCounterTable = pgTable(
199
+ 'support_ticket_counter',
200
+ {
201
+ id: integer('id').primaryKey().default(1),
202
+ counter: integer('counter').notNull().default(0)
203
+ },
204
+ table => [
205
+ nonNegativeCheck('support_ticket_counter_counter_non_negative', [
206
+ table.counter
207
+ ])
208
+ ]
209
+ )
210
+
211
+ /**
212
+ * A ticket with an `organizationId` belongs to that organization's managers.
213
+ * A ticket without one is an account ticket, visible only to its author.
214
+ * Platform admins see both.
215
+ */
216
+ export const supportTicketsTable = pgTable(
217
+ 'support_tickets',
218
+ {
219
+ id: commonFieldDefs.id('support_ticket'),
220
+ // The database fills this in from a trigger; see `scripts/setup-db.ts`.
221
+ ticketNumber: varchar('ticket_number')
222
+ .notNull()
223
+ .unique()
224
+ .$defaultFn(() => ''),
225
+ organizationId: varchar('organization_id').references(
226
+ () => organizationsTable.id,
227
+ { onDelete: 'set null' }
228
+ ),
229
+ userId: varchar('user_id').references(() => usersTable.id, {
230
+ onDelete: 'set null'
231
+ }),
232
+ subject: varchar('subject').notNull(),
233
+ status: supportTicketStatusEnum('status').notNull().default('open'),
234
+ priority: supportTicketPriorityEnum('priority'),
235
+ resolvedAt: commonFieldDefs.date('resolved_at'),
236
+ ...commonFieldDefs.dates
237
+ },
238
+ table => [
239
+ index().on(table.userId, table.createdAt),
240
+ index().on(table.organizationId, table.createdAt),
241
+ index().on(table.status),
242
+ index().on(table.priority)
243
+ ]
244
+ )
245
+
246
+ export const supportTicketMessagesTable = pgTable(
247
+ 'support_ticket_messages',
248
+ {
249
+ id: commonFieldDefs.id('support_ticket_message'),
250
+ ticketId: varchar('ticket_id')
251
+ .notNull()
252
+ .references(() => supportTicketsTable.id, { onDelete: 'cascade' }),
253
+ userId: varchar('user_id').references(() => usersTable.id, {
254
+ onDelete: 'set null'
255
+ }),
256
+ message: text('message').notNull(),
257
+ /** True when the support team wrote it, even if the author is also a customer. */
258
+ isAdmin: boolean('is_admin').notNull().default(false),
259
+ ...commonFieldDefs.dates
260
+ },
261
+ table => [index().on(table.ticketId), index().on(table.userId)]
262
+ )
263
+
264
+ export const feedbacksTable = pgTable(
265
+ 'feedbacks',
266
+ {
267
+ id: commonFieldDefs.id('feedback'),
268
+ userId: varchar('user_id')
269
+ .notNull()
270
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
271
+ organizationId: varchar('organization_id')
272
+ .notNull()
273
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
274
+ rating: integer('rating').notNull(),
275
+ comment: text('comment'),
276
+ ...commonFieldDefs.dates
277
+ },
278
+ table => [
279
+ index().on(table.userId),
280
+ index().on(table.organizationId, table.createdAt),
281
+ index().on(table.createdAt),
282
+ check('feedbacks_rating_range', sql`${table.rating} BETWEEN 1 AND 5`)
283
+ ]
284
+ )
285
+
286
+ export const contactLeadsTable = pgTable(
287
+ 'contact_leads',
288
+ {
289
+ id: commonFieldDefs.id('contact_lead'),
290
+ name: varchar('name').notNull(),
291
+ email: varchar('email').notNull(),
292
+ phone: varchar('phone'),
293
+ subject: varchar('subject').notNull(),
294
+ message: text('message').notNull(),
295
+ ...commonFieldDefs.dates
296
+ },
297
+ table => [index().on(table.createdAt)]
298
+ )
299
+
300
+ // ─── Uploads ────────────────────────────────────────────────────
301
+
167
302
  export const fileUploadsTable = pgTable(
168
303
  'file_uploads',
169
304
  {
170
- id: varchar('id').primaryKey(),
171
- // Object path e.g. uploads/organizations/organizationId/filename.jpg
305
+ id: commonFieldDefs.id('file_upload'),
306
+ // Object key, such as uploads/organizations/<organizationId>/<file>.jpg
172
307
  key: text('key').notNull().unique(),
173
308
  originalName: varchar('original_name').notNull(),
174
309
  mimeType: varchar('mime_type').notNull(),
175
- // In Bytes
310
+ // In bytes
176
311
  size: integer('size').notNull(),
177
312
  isTemp: boolean('is_temp').notNull().default(true),
178
313
  uploadedBy: varchar('uploaded_by').references(() => usersTable.id, {
179
314
  onDelete: 'set null'
180
315
  }),
181
- // Nullable: scopes upload to a organization for cleanup purposes
182
- organizationId: varchar('organization_id').references(
183
- () => organizationsTable.id
184
- ),
316
+ // `restrict` forces organization deletion to remove the R2 objects first;
317
+ // see `deleteOrganizationUploads`.
318
+ organizationId: varchar('organization_id')
319
+ .notNull()
320
+ .references(() => organizationsTable.id, { onDelete: 'restrict' }),
185
321
  ...commonFieldDefs.dates
186
322
  },
187
323
  table => [
188
324
  index().on(table.uploadedBy),
189
- index().on(table.isTemp, table.createdAt)
325
+ index().on(table.organizationId),
326
+ index().on(table.isTemp, table.createdAt),
327
+ nonNegativeCheck('file_uploads_size_non_negative', [table.size])
190
328
  ]
191
329
  )
330
+
331
+ // ─── Seed ───────────────────────────────────────────────────────
332
+
333
+ /**
334
+ * Singleton marker for a successful `seedDatabase` run. While
335
+ * {@link SEED_RUN_MARKER_ID} exists, the seed script does nothing.
336
+ */
337
+ export const SEED_RUN_MARKER_ID = 'seed_run_default'
338
+
339
+ export const seedRunsTable = pgTable('seed_runs', {
340
+ id: varchar('id').primaryKey(),
341
+ ...commonFieldDefs.dates
342
+ })
@@ -1,8 +1,14 @@
1
+ /**
2
+ * @fileoverview Shared email shell: a brand rule and name at the top, the
3
+ * content, and a copyright footer. Every template wraps its body in it.
4
+ *
5
+ * @module emails/components/email-layout
6
+ */
7
+
1
8
  import {
2
9
  Body,
3
10
  Container,
4
11
  Head,
5
- Hr,
6
12
  Html,
7
13
  pixelBasedPreset,
8
14
  Preview,
@@ -11,7 +17,11 @@ import {
11
17
  Text
12
18
  } from 'react-email'
13
19
 
20
+ /** Brand color. Email clients strip CSS variables, so it is a literal here. */
21
+ const BRAND = '#171717'
22
+
14
23
  interface EmailLayoutProps {
24
+ /** Text email clients show in the inbox before the email is opened. */
15
25
  previewText: string
16
26
  companyName: string
17
27
  children: React.ReactNode
@@ -28,31 +38,37 @@ export default function EmailLayout({
28
38
  <Preview>{previewText}</Preview>
29
39
  <Tailwind
30
40
  config={{
31
- presets: [pixelBasedPreset]
41
+ presets: [pixelBasedPreset],
42
+ theme: { extend: { colors: { brand: BRAND } } }
32
43
  }}
33
44
  >
34
45
  <Body className="bg-gray-100 font-sans">
35
46
  <Container className="mx-auto my-8 max-w-xl rounded-lg bg-white shadow-sm">
36
- {/* Header */}
37
- <Section className="rounded-t-lg bg-black px-8 py-6">
38
- <Text className="m-0 text-center text-2xl font-bold text-white">
47
+ <Section
48
+ className="rounded-t-lg bg-white px-8 py-5"
49
+ style={{
50
+ borderTop: `4px solid ${BRAND}`,
51
+ borderBottom: '1px solid #E5E7EB'
52
+ }}
53
+ >
54
+ <Text className="m-0 text-lg font-bold tracking-tight text-gray-900">
39
55
  {companyName}
40
56
  </Text>
41
57
  </Section>
42
58
 
43
- {/* Content */}
44
59
  <Section className="px-8 py-6">{children}</Section>
45
60
 
46
- {/* Footer */}
47
- <Section className="rounded-b-lg bg-gray-50 px-8 py-6">
48
- <Hr className="my-4 border-gray-200" />
61
+ <Section
62
+ className="rounded-b-lg bg-gray-50 px-8 py-6"
63
+ style={{ borderTop: '1px solid #E5E7EB' }}
64
+ >
49
65
  <Text className="m-0 text-center text-sm text-gray-500">
50
66
  &copy; {new Date().getUTCFullYear()} {companyName}. All rights
51
67
  reserved.
52
68
  </Text>
53
69
  <Text className="m-0 mt-2 text-center text-xs text-gray-400">
54
- This is an automated email. Please do not reply directly to this
55
- message.
70
+ This is an automated email. Replies to this address are not
71
+ read.
56
72
  </Text>
57
73
  </Section>
58
74
  </Container>
@@ -1,56 +1,69 @@
1
1
  import { Button, Heading, Text } from 'react-email'
2
- import EmailLayout from './components/email-layout'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { INVITATION_EXPIRES_IN_DAYS } from '~/lib/constants'
4
+ import { roleArticle } from '~/lib/rbac'
5
+ import { siteConfig } from '~/lib/siteConfig'
3
6
 
4
7
  export interface InvitationEmailProps {
5
- inviterName: string
8
+ inviteUrl: string
6
9
  organizationName: string
7
- inviteLink: string
10
+ inviterName: string
11
+ role: string
8
12
  companyName: string
9
13
  }
10
14
 
15
+ /** Invitation to join an organization, sent by the Better Auth organization plugin. */
11
16
  export default function InvitationEmail({
12
- inviterName,
17
+ inviteUrl,
13
18
  organizationName,
14
- inviteLink,
19
+ inviterName,
20
+ role,
15
21
  companyName
16
22
  }: InvitationEmailProps) {
17
- const previewText = `${inviterName} invited you to join ${organizationName}`
18
-
19
23
  return (
20
- <EmailLayout previewText={previewText} companyName={companyName}>
24
+ <EmailLayout
25
+ previewText={`${inviterName} invited you to join ${organizationName}`}
26
+ companyName={companyName}
27
+ >
21
28
  <Heading className="mb-4 text-2xl font-bold text-gray-900">
22
- You've been invited to join {organizationName}
29
+ You&apos;re Invited to Join {organizationName}
23
30
  </Heading>
24
31
 
25
32
  <Text className="mb-4 text-base text-gray-700">
26
- <strong>{inviterName}</strong> has invited you to collaborate in{' '}
27
- <strong>{organizationName}</strong> on {companyName}.
33
+ {inviterName} invited you to join <strong>{organizationName}</strong> on{' '}
34
+ {companyName} as {roleArticle(role)}.
35
+ </Text>
36
+
37
+ <Text className="mb-4 text-base text-gray-700">
38
+ Select the button below to accept. You&apos;ll sign in with this email
39
+ address first.
28
40
  </Text>
29
41
 
30
42
  <Button
31
- href={inviteLink}
32
- className="mb-4 rounded-lg bg-black px-6 py-3 text-center text-base font-semibold text-white no-underline"
43
+ href={inviteUrl}
44
+ className="bg-brand mb-4 rounded-lg px-6 py-3 text-center text-base font-semibold text-white no-underline"
33
45
  >
34
46
  Accept Invitation
35
47
  </Button>
36
48
 
37
49
  <Text className="mb-4 text-sm text-gray-600">
38
- Or copy and paste this link into your browser:
50
+ Or copy this link into your browser:
39
51
  </Text>
40
52
 
41
- <Text className="mb-4 text-sm break-all text-blue-600">{inviteLink}</Text>
53
+ <Text className="text-brand mb-4 text-sm break-all">{inviteUrl}</Text>
42
54
 
43
55
  <Text className="text-sm text-gray-500">
44
- If you weren't expecting this invitation, you can safely ignore this
45
- email.
56
+ This invitation expires in {INVITATION_EXPIRES_IN_DAYS} days. If you
57
+ weren&apos;t expecting it, you can ignore this email.
46
58
  </Text>
47
59
  </EmailLayout>
48
60
  )
49
61
  }
50
62
 
51
63
  InvitationEmail.PreviewProps = {
52
- inviterName: 'John Doe',
53
- organizationName: 'Acme Inc',
54
- inviteLink: 'https://example.com/accept-invitation/abc123',
55
- companyName: 'Example Site'
64
+ inviteUrl: 'https://example.com/accept-invitation/your_invitation_id_here',
65
+ organizationName: 'Acme Inc.',
66
+ inviterName: 'Alex Morgan',
67
+ role: 'member',
68
+ companyName: siteConfig.name
56
69
  } satisfies InvitationEmailProps
@@ -1,49 +1,51 @@
1
1
  import { Button, Heading, Text } from 'react-email'
2
- import EmailLayout from './components/email-layout'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { siteConfig } from '~/lib/siteConfig'
3
4
 
4
5
  export interface MagicLinkEmailProps {
5
6
  magicLink: string
6
7
  companyName: string
7
8
  }
8
9
 
10
+ /** Passwordless sign-in link. The link works once and expires in 5 minutes. */
9
11
  export default function MagicLinkEmail({
10
12
  magicLink,
11
13
  companyName
12
14
  }: MagicLinkEmailProps) {
13
- const previewText = `Your magic link to sign in to ${companyName}`
14
-
15
15
  return (
16
- <EmailLayout previewText={previewText} companyName={companyName}>
16
+ <EmailLayout
17
+ previewText={`Your link to sign in to ${companyName}`}
18
+ companyName={companyName}
19
+ >
17
20
  <Heading className="mb-4 text-2xl font-bold text-gray-900">
18
- Sign in to {companyName}
21
+ Sign In to {companyName}
19
22
  </Heading>
20
23
 
21
24
  <Text className="mb-4 text-base text-gray-700">
22
- Click the button below to securely sign in to your account. This link
23
- will expire in 5 minutes.
25
+ Select the button below to sign in. The link expires in 5 minutes.
24
26
  </Text>
25
27
 
26
28
  <Button
27
29
  href={magicLink}
28
- className="mb-4 rounded-lg bg-black px-6 py-3 text-center text-base font-semibold text-white no-underline"
30
+ className="bg-brand mb-4 rounded-lg px-6 py-3 text-center text-base font-semibold text-white no-underline"
29
31
  >
30
32
  Sign In
31
33
  </Button>
32
34
 
33
35
  <Text className="mb-4 text-sm text-gray-600">
34
- Or copy and paste this link into your browser:
36
+ Or copy this link into your browser:
35
37
  </Text>
36
38
 
37
- <Text className="mb-4 text-sm break-all text-blue-600">{magicLink}</Text>
39
+ <Text className="text-brand mb-4 text-sm break-all">{magicLink}</Text>
38
40
 
39
41
  <Text className="text-sm text-gray-500">
40
- If you didn't request this email, you can safely ignore it.
42
+ If you didn&apos;t ask to sign in, you can ignore this email.
41
43
  </Text>
42
44
  </EmailLayout>
43
45
  )
44
46
  }
45
47
 
46
48
  MagicLinkEmail.PreviewProps = {
47
- magicLink: 'https://example.com/magic-link?token=abc123',
48
- companyName: 'Example Site'
49
+ magicLink: 'https://example.com/magic-link?token=your_magic_link_token_here',
50
+ companyName: siteConfig.name
49
51
  } satisfies MagicLinkEmailProps
@@ -1,25 +1,27 @@
1
1
  import { Button, Heading, Text } from 'react-email'
2
- import EmailLayout from './components/email-layout'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { siteConfig } from '~/lib/siteConfig'
3
4
 
4
5
  interface TestEmailProps {
5
6
  name: string
6
7
  }
7
8
 
9
+ /** Sent by `bun run email:test` to check SMTP settings. */
8
10
  export default function TestEmail({ name }: TestEmailProps) {
9
11
  return (
10
- <EmailLayout previewText="This is a test email" companyName="Test">
12
+ <EmailLayout
13
+ previewText="Test email from your app"
14
+ companyName={siteConfig.name}
15
+ >
11
16
  <Heading className="mb-4 text-2xl font-bold text-gray-900">
12
17
  Test Email for {name}
13
18
  </Heading>
14
- <Text className="mb-4 text-base text-gray-700">
15
- This is a test email template to verify email functionality.
16
- </Text>
17
19
  <Text className="mb-6 text-base text-gray-700">
18
- If you're seeing this, the email system is working correctly.
20
+ Your SMTP settings work: this email reached your inbox.
19
21
  </Text>
20
22
  <Button
21
23
  href="https://example.com"
22
- className="rounded-md bg-black px-5 py-3 text-center text-sm font-medium text-white"
24
+ className="bg-brand rounded-md px-5 py-3 text-center text-sm font-medium text-white"
23
25
  >
24
26
  Test Button
25
27
  </Button>
@@ -28,5 +30,5 @@ export default function TestEmail({ name }: TestEmailProps) {
28
30
  }
29
31
 
30
32
  TestEmail.PreviewProps = {
31
- name: 'John Doe'
32
- }
33
+ name: 'Alex Morgan'
34
+ } satisfies TestEmailProps
@@ -1,34 +1,37 @@
1
1
  import { Heading, Text } from 'react-email'
2
- import EmailLayout from './components/email-layout'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { siteConfig } from '~/lib/siteConfig'
3
4
 
4
5
  export interface WelcomeEmailProps {
5
6
  userName: string
6
7
  companyName: string
7
8
  }
8
9
 
10
+ /** Sent once, right after an account is created. */
9
11
  export default function WelcomeEmail({
10
12
  userName,
11
13
  companyName
12
14
  }: WelcomeEmailProps) {
13
- const previewText = `Welcome to ${companyName}! Your account has been created successfully.`
14
-
15
15
  return (
16
- <EmailLayout previewText={previewText} companyName={companyName}>
16
+ <EmailLayout
17
+ previewText={`Welcome to ${companyName}. Your account is ready.`}
18
+ companyName={companyName}
19
+ >
17
20
  <Heading className="mb-4 text-2xl font-bold text-gray-900">
18
- Welcome to {companyName}! 🎉
21
+ Welcome to {companyName}
19
22
  </Heading>
20
23
 
21
24
  <Text className="mb-4 text-base text-gray-700">Hi {userName},</Text>
22
25
 
23
26
  <Text className="mb-4 text-base text-gray-700">
24
- Thank you for creating an account with us. We're thrilled to have you as
25
- part of our community!
27
+ Your account is ready. Sign in any time with the email address this
28
+ message was sent to.
26
29
  </Text>
27
30
  </EmailLayout>
28
31
  )
29
32
  }
30
33
 
31
34
  WelcomeEmail.PreviewProps = {
32
- userName: 'John Doe',
33
- companyName: 'Example Site'
35
+ userName: 'Alex Morgan',
36
+ companyName: siteConfig.name
34
37
  } satisfies WelcomeEmailProps
@@ -15,7 +15,7 @@ export const env = createEnv({
15
15
  EMAIL_SERVER_USER: z.string(),
16
16
  EMAIL_SERVER_PASSWORD: z.string(),
17
17
  EMAIL_SERVER_HOST: z.string(),
18
- EMAIL_SERVER_PORT: z.string(),
18
+ EMAIL_SERVER_PORT: z.coerce.number().int().min(1).max(65535),
19
19
  EMAIL_FROM: z.email(),
20
20
  STRIPE_SECRET_KEY: z.string(),
21
21
  STRIPE_WEBHOOK_SECRET: z.string(),