create-ej-app 25.0.0 → 27.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 (385) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +83 -28
  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 -0
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -40
  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 +8 -0
  140. package/template/saas-starter/.env.example +25 -0
  141. package/template/saas-starter/.vscode/extensions.json +11 -0
  142. package/template/saas-starter/.vscode/settings.json +3 -0
  143. package/template/saas-starter/AGENTS.md +302 -0
  144. package/template/saas-starter/CLAUDE.md +1 -0
  145. package/template/saas-starter/Dockerfile +15 -0
  146. package/template/saas-starter/README.md +125 -0
  147. package/template/saas-starter/bunfig.toml +3 -0
  148. package/template/saas-starter/components.json +25 -0
  149. package/template/saas-starter/drizzle.config.ts +10 -0
  150. package/template/saas-starter/eslint.config.mjs +24 -0
  151. package/template/saas-starter/gitignore +14 -0
  152. package/template/saas-starter/next.config.ts +32 -0
  153. package/template/saas-starter/package.json +97 -0
  154. package/template/saas-starter/postcss.config.mjs +7 -0
  155. package/template/saas-starter/prettier.config.mjs +11 -0
  156. package/template/saas-starter/public/logo-dark.svg +4 -0
  157. package/template/saas-starter/public/logo.svg +4 -0
  158. package/template/saas-starter/public/placeholder.svg +1 -0
  159. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  160. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  161. package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
  162. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  163. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  164. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  165. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  166. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  167. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
  168. package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
  169. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  170. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  171. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  172. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  173. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  174. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  175. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  176. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  177. package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
  178. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  179. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  180. package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
  181. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  182. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
  188. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
  189. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
  190. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  191. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
  192. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
  193. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
  194. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
  195. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  196. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  197. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
  198. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
  199. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
  200. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  201. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
  202. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
  203. package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
  204. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
  205. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
  206. package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
  207. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  208. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  209. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  210. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  211. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  212. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
  213. package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
  214. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
  215. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
  216. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
  217. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  218. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  219. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  220. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  221. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  222. package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
  223. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  224. package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
  225. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
  226. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
  227. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  228. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  229. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  230. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  231. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  232. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  233. package/template/saas-starter/src/app/error.tsx +37 -0
  234. package/template/saas-starter/src/app/favicon.ico +0 -0
  235. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  236. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  237. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  238. package/template/saas-starter/src/app/global-error.tsx +38 -0
  239. package/template/saas-starter/src/app/globals.css +269 -0
  240. package/template/saas-starter/src/app/layout.tsx +77 -0
  241. package/template/saas-starter/src/app/not-found.tsx +49 -0
  242. package/template/saas-starter/src/app/robots.ts +36 -0
  243. package/template/saas-starter/src/app/sitemap.ts +21 -0
  244. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  245. package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
  246. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  247. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  248. package/template/saas-starter/src/components/data-table/index.tsx +961 -0
  249. package/template/saas-starter/src/components/data-table/types.ts +76 -0
  250. package/template/saas-starter/src/components/error.tsx +71 -0
  251. package/template/saas-starter/src/components/file-upload.tsx +254 -0
  252. package/template/saas-starter/src/components/loader.tsx +41 -0
  253. package/template/saas-starter/src/components/logo.tsx +49 -0
  254. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  255. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  256. package/template/saas-starter/src/components/page-heading.tsx +38 -0
  257. package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
  258. package/template/saas-starter/src/components/route-error.tsx +55 -0
  259. package/template/saas-starter/src/components/screen-size.tsx +76 -0
  260. package/template/saas-starter/src/components/site-footer.tsx +39 -0
  261. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  262. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  263. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  264. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  265. package/template/saas-starter/src/components/theme-provider.tsx +115 -0
  266. package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
  267. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  268. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  269. package/template/saas-starter/src/components/ui/badge.tsx +54 -0
  270. package/template/saas-starter/src/components/ui/button.tsx +69 -0
  271. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  272. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  273. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  274. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  275. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  276. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  277. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  278. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  279. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  280. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  281. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  282. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  283. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  284. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  285. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  286. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  287. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  288. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  289. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  290. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  291. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  292. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  293. package/template/saas-starter/src/components/user-menu.tsx +213 -0
  294. package/template/saas-starter/src/db/common.ts +63 -0
  295. package/template/saas-starter/src/db/enums.ts +50 -0
  296. package/template/saas-starter/src/db/index.ts +60 -0
  297. package/template/saas-starter/src/db/relations.ts +112 -0
  298. package/template/saas-starter/src/db/schema.ts +342 -0
  299. package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
  300. package/template/saas-starter/src/emails/invitation.tsx +69 -0
  301. package/template/saas-starter/src/emails/magic-link.tsx +51 -0
  302. package/template/saas-starter/src/emails/test.tsx +34 -0
  303. package/template/saas-starter/src/emails/welcome.tsx +37 -0
  304. package/template/saas-starter/src/env.ts +66 -0
  305. package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
  306. package/template/saas-starter/src/instrumentation.ts +44 -0
  307. package/template/saas-starter/src/lib/actions.ts +55 -0
  308. package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
  309. package/template/saas-starter/src/lib/app-navigation.ts +63 -0
  310. package/template/saas-starter/src/lib/auth-client.ts +34 -0
  311. package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
  312. package/template/saas-starter/src/lib/auth.ts +312 -0
  313. package/template/saas-starter/src/lib/billing.ts +152 -0
  314. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  315. package/template/saas-starter/src/lib/cn.ts +4 -0
  316. package/template/saas-starter/src/lib/constants.ts +91 -0
  317. package/template/saas-starter/src/lib/delay.ts +12 -0
  318. package/template/saas-starter/src/lib/email-service.ts +118 -0
  319. package/template/saas-starter/src/lib/errors.ts +74 -0
  320. package/template/saas-starter/src/lib/format-currency.ts +25 -0
  321. package/template/saas-starter/src/lib/format-date.ts +75 -0
  322. package/template/saas-starter/src/lib/image-url.ts +54 -0
  323. package/template/saas-starter/src/lib/nodemailer.ts +67 -0
  324. package/template/saas-starter/src/lib/organization-access.ts +73 -0
  325. package/template/saas-starter/src/lib/otel.ts +117 -0
  326. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  327. package/template/saas-starter/src/lib/plans.ts +90 -0
  328. package/template/saas-starter/src/lib/rbac.ts +44 -0
  329. package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
  330. package/template/saas-starter/src/lib/safe-action.ts +187 -0
  331. package/template/saas-starter/src/lib/search.ts +16 -0
  332. package/template/saas-starter/src/lib/seo.ts +7 -0
  333. package/template/saas-starter/src/lib/siteConfig.ts +20 -0
  334. package/template/saas-starter/src/lib/storage.ts +311 -0
  335. package/template/saas-starter/src/lib/stripe.ts +9 -0
  336. package/template/saas-starter/src/lib/toast-message.ts +101 -0
  337. package/template/saas-starter/src/lib/validations.ts +95 -0
  338. package/template/saas-starter/src/proxy.ts +48 -0
  339. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  340. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  341. package/template/saas-starter/src/scripts/seed.ts +85 -0
  342. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  343. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
  344. package/template/saas-starter/src/scripts/test-mail.ts +43 -0
  345. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  346. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  347. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  348. package/template/saas-starter/tests/helpers/action.ts +71 -0
  349. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  350. package/template/saas-starter/tests/helpers/index.ts +2 -0
  351. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  352. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  353. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  354. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  355. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  356. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  357. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  358. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  359. package/template/saas-starter/tests/setup/preload.ts +108 -0
  360. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  361. package/template/saas-starter/tsconfig.json +52 -0
  362. package/cli.js +0 -143
  363. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  364. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  365. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  366. package/template/nextjs/terraform/backend-service.tf +0 -13
  367. package/template/nextjs/terraform/cleanup.sh +0 -9
  368. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  369. package/template/nextjs/terraform/cloudrun.tf +0 -68
  370. package/template/nextjs/terraform/database.tf +0 -60
  371. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  372. package/template/nextjs/terraform/gitignore +0 -4
  373. package/template/nextjs/terraform/ip-address.tf +0 -11
  374. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  375. package/template/nextjs/terraform/network.tf +0 -21
  376. package/template/nextjs/terraform/pre.sh +0 -3
  377. package/template/nextjs/terraform/providers.tf +0 -18
  378. package/template/nextjs/terraform/secret.txt +0 -11
  379. package/template/nextjs/terraform/setup.sh +0 -25
  380. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  381. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  382. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  383. package/template/nextjs/terraform/terraform.tfvars +0 -4
  384. package/template/nextjs/terraform/url-map.tf +0 -23
  385. package/template/nextjs/terraform/variables.tf +0 -21
@@ -1,30 +1,132 @@
1
+ import 'server-only'
2
+
1
3
  import { createSafeActionClient } from 'next-safe-action'
2
4
  import { getAuthSession } from './auth'
3
- import { recordWideEvent } from './otel'
5
+ import { AppError, AppErrorCode, isAppError } from './errors'
6
+ import { flattenForWideEvent, recordError, recordWideEvent } from './otel'
7
+
8
+ /** Shown for every unexpected error; details stay in telemetry. */
9
+ export const UNEXPECTED_ERROR_MESSAGE =
10
+ 'The request did not finish. Try again in a few minutes.'
11
+
12
+ type ActionServerArgs = {
13
+ parsedInput?: unknown
14
+ clientInput?: unknown
15
+ [key: string]: unknown
16
+ }
4
17
 
5
- export const actionClient = createSafeActionClient().use(
6
- async ({ next, ctx }) => {
7
- const authSession = await getAuthSession()
18
+ /**
19
+ * Wrap a safe-action server function with wide-event telemetry.
20
+ *
21
+ * Records `action.name`, flattened `parsedInput` (or `clientInput`), and on
22
+ * success `outcome: 'success'` plus `duration_ms`. Failures keep error fields
23
+ * from `handleServerError`; duration is still recorded.
24
+ *
25
+ * @example
26
+ * export const myAction = authActionClient
27
+ * .inputSchema(schema)
28
+ * .action(
29
+ * withWideEvent('myAction', async ({ parsedInput, ctx }) => {
30
+ * // business logic only
31
+ * })
32
+ * )
33
+ */
34
+ export function withWideEvent<TArgs extends ActionServerArgs, TResult>(
35
+ actionName: string,
36
+ serverCode: (args: TArgs) => Promise<TResult>
37
+ ): (args: TArgs) => Promise<TResult> {
38
+ return async args => {
39
+ const startedAt = Date.now()
40
+ const input = args.parsedInput ?? args.clientInput
41
+
42
+ recordWideEvent({
43
+ 'action.name': actionName,
44
+ ...flattenForWideEvent(input, 'input')
45
+ })
8
46
 
9
- if (authSession)
47
+ try {
48
+ const result = await serverCode(args)
10
49
  recordWideEvent({
11
- 'user.id': authSession.user.id,
12
- 'user.admin': authSession.isAdmin,
13
- 'session.id': authSession.session.id
50
+ outcome: 'success',
51
+ duration_ms: Date.now() - startedAt
14
52
  })
53
+ return result
54
+ } catch (error) {
55
+ recordWideEvent({
56
+ outcome: 'error',
57
+ duration_ms: Date.now() - startedAt
58
+ })
59
+ throw error
60
+ }
61
+ }
62
+ }
15
63
 
16
- return next({
17
- ctx: {
18
- ...ctx,
19
- ...authSession
20
- }
64
+ /**
65
+ * Configured safe action client with authentication middleware
66
+ * This client wraps server actions with:
67
+ * - Automatic input/output validation using Zod
68
+ * - Type-safe error handling ({@link AppError} messages reach the client)
69
+ * - Server-side authentication injection
70
+ *
71
+ * All server actions using this client automatically receive the current auth session
72
+ * in the context, so protected actions can access user information.
73
+ *
74
+ * @example
75
+ * export const myAction = actionClient
76
+ * .inputSchema(z.object({ name: z.string() }))
77
+ * .action(async ({ parsedInput: { name }, ctx }) => {
78
+ * const userId = ctx.user?.id // Auth injected automatically
79
+ * return { success: true }
80
+ * })
81
+ */
82
+ export const actionClient = createSafeActionClient({
83
+ handleServerError(e) {
84
+ if (isAppError(e)) {
85
+ recordWideEvent({
86
+ 'error.name': e.name,
87
+ 'error.code': e.code,
88
+ 'error.message': e.message,
89
+ 'error.expected': true,
90
+ outcome: 'error'
91
+ })
92
+ return e.message
93
+ }
94
+
95
+ recordError(e)
96
+ recordWideEvent({
97
+ 'error.name': e.name,
98
+ 'error.expected': false,
99
+ 'error.message': e.message,
100
+ outcome: 'error'
21
101
  })
102
+ return UNEXPECTED_ERROR_MESSAGE
22
103
  }
23
- )
104
+ }).use(async ({ next, ctx }) => {
105
+ // Retrieve current authentication session
106
+ const authSession = await getAuthSession()
107
+
108
+ // Record user information in telemetry for all actions, if available
109
+ if (authSession) {
110
+ recordWideEvent({
111
+ 'user.id': authSession.user.id,
112
+ 'user.admin': authSession.isAdmin,
113
+ 'user.superAdmin': authSession.isSuperAdmin,
114
+ 'session.id': authSession.session.id
115
+ })
116
+ }
117
+
118
+ // Inject auth session into action context for all downstream actions
119
+ return next({
120
+ ctx: {
121
+ ...ctx,
122
+ ...authSession
123
+ }
124
+ })
125
+ })
24
126
 
25
127
  export const authActionClient = actionClient.use(async ({ ctx, next }) => {
26
128
  if (!ctx?.user || !ctx?.session) {
27
- throw new Error('Unauthorized')
129
+ throw new AppError(AppErrorCode.UNAUTHORIZED, 'Sign in to continue.')
28
130
  }
29
131
 
30
132
  return next({
@@ -36,9 +138,18 @@ export const authActionClient = actionClient.use(async ({ ctx, next }) => {
36
138
  })
37
139
  })
38
140
 
141
+ /**
142
+ * Action client for the admin panel.
143
+ *
144
+ * Allows both `admin` and `superadmin` roles. Use for actions that any
145
+ * admin-panel user may run, such as reading customer data.
146
+ */
39
147
  export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
40
148
  if (!ctx.isAdmin) {
41
- throw new Error('Forbidden')
149
+ throw new AppError(
150
+ AppErrorCode.FORBIDDEN,
151
+ 'You do not have permission to do this.'
152
+ )
42
153
  }
43
154
 
44
155
  return next({
@@ -49,3 +160,28 @@ export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
49
160
  }
50
161
  })
51
162
  })
163
+
164
+ /**
165
+ * Action client restricted to the `superadmin` role.
166
+ *
167
+ * Use for privileged operations, such as user management, role changes, and
168
+ * impersonation, that `admin` users cannot perform.
169
+ */
170
+ export const superAdminActionClient = adminActionClient.use(
171
+ async ({ ctx, next }) => {
172
+ if (!ctx.isSuperAdmin) {
173
+ throw new AppError(
174
+ AppErrorCode.FORBIDDEN,
175
+ 'You do not have permission to do this.'
176
+ )
177
+ }
178
+
179
+ return next({
180
+ ctx: {
181
+ ...ctx,
182
+ user: ctx.user,
183
+ session: ctx.session
184
+ }
185
+ })
186
+ }
187
+ )
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Escape characters that are special in SQL LIKE / ILIKE patterns.
3
+ *
4
+ * Postgres uses `\` as the default escape character. Without escaping, a
5
+ * user-typed `%` or `_` becomes a wildcard and can force full-table scans.
6
+ */
7
+ export function escapeLikePattern(value: string): string {
8
+ return value.replace(/[\\%_]/g, char => `\\${char}`)
9
+ }
10
+
11
+ /**
12
+ * Build a case-insensitive contains pattern: `%value%` with wildcards escaped.
13
+ */
14
+ export function likeContains(value: string): string {
15
+ return `%${escapeLikePattern(value)}%`
16
+ }
@@ -0,0 +1,7 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ /** Meta robots for authenticated or non-public surfaces. */
4
+ export const privatePageRobots = {
5
+ index: false,
6
+ follow: false
7
+ } satisfies NonNullable<Metadata['robots']>
@@ -1,4 +1,20 @@
1
+ /**
2
+ * @fileoverview Brand identity and site-wide copy.
3
+ *
4
+ * Layouts, emails, legal pages, and metadata read from here. Never hardcode
5
+ * the product name anywhere else.
6
+ *
7
+ * @module lib/siteConfig
8
+ */
1
9
  export const siteConfig = {
2
10
  name: 'Project Name',
3
- description: 'Project Description'
11
+ description: 'Project Description',
12
+ logo: '/logo.svg',
13
+ logoDark: '/logo-dark.svg',
14
+ contact: {
15
+ email: 'hello@example.com'
16
+ },
17
+ legal: {
18
+ lastUpdated: 'September 25, 2026'
19
+ }
4
20
  }
@@ -2,14 +2,100 @@
2
2
 
3
3
  import { toast } from 'sonner'
4
4
 
5
+ const DEFAULT_ERROR = 'The request did not finish. Try again in a few minutes.'
6
+
7
+ type ActionResultLike = {
8
+ serverError?: string
9
+ validationErrors?: unknown
10
+ }
11
+
12
+ /**
13
+ * Displays a success toast notification to the user.
14
+ */
5
15
  export const toastSuccessMessage = (message: string) =>
6
16
  toast.success(message, {
7
17
  richColors: true,
8
18
  position: 'top-center'
9
19
  })
10
20
 
21
+ /**
22
+ * Displays an informational toast (neutral, non-error feedback).
23
+ */
24
+ export const toastInfoMessage = (message: string) =>
25
+ toast.info(message, {
26
+ richColors: true,
27
+ position: 'top-center'
28
+ })
29
+
30
+ /**
31
+ * Displays an error toast. Uses a default message when none is provided.
32
+ */
11
33
  export const toastErrorMessage = (message?: string) =>
12
- toast.error(message || 'Something went wrong. Please try again later.', {
34
+ toast.error(message || DEFAULT_ERROR, {
13
35
  richColors: true,
14
36
  position: 'top-center'
15
37
  })
38
+
39
+ /**
40
+ * Toast an action failure from `await someAction(...)`.
41
+ * Returns true when a toast was shown so callers can bail.
42
+ *
43
+ * @example
44
+ * const result = await upsertTableAction(values)
45
+ * if (toastIfActionFailed(result)) return
46
+ */
47
+ export function toastIfActionFailed(
48
+ result: ActionResultLike | undefined | null
49
+ ): boolean {
50
+ if (!result) return false
51
+ if (result.serverError) {
52
+ toastErrorMessage(result.serverError)
53
+ return true
54
+ }
55
+ if (result.validationErrors) {
56
+ toastErrorMessage('Check the highlighted fields and try again.')
57
+ return true
58
+ }
59
+ return false
60
+ }
61
+
62
+ /**
63
+ * Toast a failure from hooks (`useSafeActionMutation`, `useAction`) or a thrown Error.
64
+ *
65
+ * Accepts:
66
+ * - `SafeActionError` / `Error` (`message`)
67
+ * - `useAction` error bag (`{ serverError, validationErrors }`)
68
+ * - plain string
69
+ *
70
+ * @example
71
+ * onError: error => toastActionError(error, 'Failed to save')
72
+ * onError: ({ error }) => toastActionError(error, 'Failed to save')
73
+ */
74
+ export function toastActionError(
75
+ error: unknown,
76
+ fallback = DEFAULT_ERROR
77
+ ): void {
78
+ if (typeof error === 'string' && error.trim()) {
79
+ toastErrorMessage(error)
80
+ return
81
+ }
82
+
83
+ if (error && typeof error === 'object') {
84
+ const bag = error as ActionResultLike & { message?: string }
85
+
86
+ if (typeof bag.serverError === 'string' && bag.serverError) {
87
+ toastErrorMessage(bag.serverError)
88
+ return
89
+ }
90
+ if (bag.validationErrors) {
91
+ toastErrorMessage('Check the highlighted fields and try again.')
92
+ return
93
+ }
94
+ if (typeof bag.message === 'string' && bag.message.trim()) {
95
+ toastErrorMessage(bag.message)
96
+ return
97
+ }
98
+ }
99
+
100
+ toastErrorMessage(fallback)
101
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared Zod fragments for action input and forms.
3
+ *
4
+ * Client-safe: no env, DB, or server-only imports, so forms can reuse the
5
+ * exact schema their action validates with.
6
+ */
7
+ import z from 'zod'
8
+
9
+ /**
10
+ * Phone number: 7 to 15 digits once separators are removed (E.164 maximum).
11
+ * Allowed characters are digits, `+`, `-`, `()`, `.`, and spaces.
12
+ */
13
+ export const phoneValidation = z
14
+ .string()
15
+ .trim()
16
+ .max(20)
17
+ .regex(/^[\d+\-().\s]+$/, 'Enter a valid phone number.')
18
+ .refine(value => {
19
+ const digits = value.replace(/\D/g, '').length
20
+ return digits >= 7 && digits <= 15
21
+ }, 'Enter a phone number with 7 to 15 digits.')
22
+
23
+ /** Email address, trimmed and lowercased. */
24
+ export const emailValidation = z.email().trim().toLowerCase()
25
+
26
+ /** Required short string, 1 to 255 characters after trimming. */
27
+ export const stringValidation = z.string().trim().min(1).max(255)
28
+
29
+ /** Optional short string (max 255). Empty string is allowed after trim. */
30
+ export const optionalStringValidation = z.string().trim().max(255).optional()
@@ -0,0 +1,48 @@
1
+ import { type NextRequest, NextResponse } from 'next/server'
2
+
3
+ /**
4
+ * Proxy middleware function
5
+ *
6
+ * Forwards the request pathname (+ search) as `x-pathname` so server layouts
7
+ * can build a same-origin login callback without losing deep links.
8
+ * @returns NextResponse that continues the request chain
9
+ */
10
+ export function proxy(request: NextRequest) {
11
+ const requestHeaders = new Headers(request.headers)
12
+ requestHeaders.set(
13
+ 'x-pathname',
14
+ `${request.nextUrl.pathname}${request.nextUrl.search}`
15
+ )
16
+
17
+ return NextResponse.next({
18
+ request: { headers: requestHeaders }
19
+ })
20
+ }
21
+
22
+ /**
23
+ * Middleware configuration
24
+ *
25
+ * Defines which routes this middleware should be applied to using the matcher pattern.
26
+ */
27
+ export const config = {
28
+ matcher: [
29
+ {
30
+ /**
31
+ * Route matching pattern
32
+ *
33
+ * This regex pattern matches all routes EXCEPT:
34
+ * - /_next/static - Next.js static assets
35
+ * - /_next/image - Next.js image optimization
36
+ * - /favicon.ico - Browser favicon
37
+ * - /.well-known/workflow/ - Internal workflow paths
38
+ *
39
+ * Excluding these paths ensures that:
40
+ * 1. Static assets load efficiently without middleware overhead
41
+ * 2. Image optimization is not interrupted
42
+ * 3. Workflow execution and resumption function correctly
43
+ */
44
+ source:
45
+ '/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)'
46
+ }
47
+ ]
48
+ }
@@ -0,0 +1,22 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import { db } from '~/db'
3
+ import { env } from '~/env'
4
+
5
+ const resetDb = async () => {
6
+ if (env.APP_ENV === 'production') {
7
+ throw new Error('Cannot reset database in production environment.')
8
+ }
9
+
10
+ await db.execute(sql`DROP SCHEMA IF EXISTS public CASCADE;`)
11
+ await db.execute(sql`CREATE SCHEMA public;`)
12
+ }
13
+
14
+ if (import.meta.main) {
15
+ try {
16
+ await resetDb()
17
+ process.exit(0)
18
+ } catch (err) {
19
+ console.error('Error running reset-db:', err)
20
+ process.exit(1)
21
+ }
22
+ }
@@ -1,11 +1,62 @@
1
- const seedData = async () => {}
1
+ /**
2
+ * @fileoverview Seeds a local database with a superadmin and a regular user.
3
+ *
4
+ * Sign in with a magic link sent to either seeded address, so point them at
5
+ * inboxes you control. A successful run writes a `seed_runs` marker and later
6
+ * runs do nothing; reset the database to seed again. Everything runs in one
7
+ * transaction, so a failure leaves no partial data.
8
+ */
9
+
10
+ import { db } from '~/db'
11
+ import { SEED_RUN_MARKER_ID, seedRunsTable, usersTable } from '~/db/schema'
12
+
13
+ /** Seeds `database` once. Returns `skipped: true` when a seed already ran. */
14
+ export async function seedDatabase(
15
+ database: typeof db = db
16
+ ): Promise<{ skipped: boolean }> {
17
+ const [existingSeedRun] = await database
18
+ .select({ id: seedRunsTable.id })
19
+ .from(seedRunsTable)
20
+ .limit(1)
21
+
22
+ if (existingSeedRun) return { skipped: true }
23
+
24
+ await database.transaction(async tx => {
25
+ const superadminId = usersTable.id.defaultFn!()
26
+ const userId = usersTable.id.defaultFn!()
27
+
28
+ await tx.insert(usersTable).values([
29
+ {
30
+ id: superadminId,
31
+ name: 'Super Admin',
32
+ email: 'superadmin@example.com',
33
+ emailVerified: true,
34
+ role: 'superadmin'
35
+ },
36
+ {
37
+ id: userId,
38
+ name: 'Demo User',
39
+ email: 'user@example.com',
40
+ emailVerified: true,
41
+ role: 'user'
42
+ }
43
+ ])
44
+
45
+ // Marker last, so a failed seed never blocks a retry.
46
+ await tx.insert(seedRunsTable).values({ id: SEED_RUN_MARKER_ID })
47
+ })
48
+
49
+ return { skipped: false }
50
+ }
2
51
 
3
52
  if (import.meta.main) {
4
- try {
5
- await seedData()
6
- } catch (err) {
7
- console.error('Error seeding data:', err)
8
- } finally {
9
- process.exit(0)
10
- }
53
+ seedDatabase(db)
54
+ .then(result => {
55
+ if (result.skipped) console.info('Database already seeded; skipping.')
56
+ process.exit(0)
57
+ })
58
+ .catch(err => {
59
+ console.error('Error running seed:', err)
60
+ process.exit(1)
61
+ })
11
62
  }
@@ -1,35 +1,43 @@
1
+ /**
2
+ * @fileoverview Sends one test email to check SMTP settings.
3
+ *
4
+ * @example
5
+ * ```bash
6
+ * bun run email:test you@example.com
7
+ * ```
8
+ */
9
+
1
10
  import { render, toPlainText } from '@react-email/render'
2
11
  import TestEmail from '~/emails/test'
3
12
  import { sendEmail } from '~/lib/nodemailer'
4
13
 
5
- async function sendTestMail() {
6
- const html = await render(
7
- TestEmail({
8
- name: 'John Doe'
9
- }),
10
- {
11
- pretty: true
12
- }
13
- )
14
-
15
- const text = toPlainText(html)
14
+ async function sendTestMail(to: string) {
15
+ const html = await render(TestEmail({ name: to }), { pretty: true })
16
16
 
17
17
  const result = await sendEmail({
18
- to: 'john@example.com',
18
+ to,
19
19
  subject: 'Test Email',
20
- text,
20
+ text: toPlainText(html),
21
21
  html
22
22
  })
23
23
 
24
- console.info('Test email sent successfully', result)
24
+ console.info('Test email sent', result)
25
25
  }
26
26
 
27
27
  if (import.meta.main) {
28
- try {
29
- await sendTestMail()
30
- } catch (err) {
31
- console.error('Error sending test mail:', err)
32
- } finally {
33
- process.exit(0)
28
+ const to = process.argv[2]
29
+
30
+ if (!to) {
31
+ console.error(
32
+ 'Pass a recipient address: bun run email:test you@example.com'
33
+ )
34
+ process.exit(1)
34
35
  }
36
+
37
+ sendTestMail(to)
38
+ .then(() => process.exit(0))
39
+ .catch(err => {
40
+ console.error('Error sending test mail:', err)
41
+ process.exit(1)
42
+ })
35
43
  }
@@ -0,0 +1,64 @@
1
+ import { afterEach, describe, test } from 'bun:test'
2
+ import { createUser, getUsers, updateUser } from '~/app/(admin)/actions/users'
3
+ import {
4
+ asAnonymous,
5
+ asUser,
6
+ expectData,
7
+ expectServerError,
8
+ Fixtures
9
+ } from '../helpers'
10
+
11
+ describe('admin user management', () => {
12
+ const fx = new Fixtures()
13
+ afterEach(() => fx.cleanup())
14
+
15
+ test('signed-out and regular users cannot reach admin actions', async () => {
16
+ const user = await fx.user()
17
+
18
+ expectServerError(
19
+ await asAnonymous(() => getUsers({ page: 1, limit: 10 })),
20
+ 'sign in'
21
+ )
22
+ expectServerError(
23
+ await asUser(user, () => getUsers({ page: 1, limit: 10 })),
24
+ 'permission'
25
+ )
26
+ })
27
+
28
+ test('a superadmin cannot remove their own superadmin role', async () => {
29
+ const superadmin = await fx.platformUser('superadmin')
30
+
31
+ const result = await asUser(superadmin, () =>
32
+ updateUser({
33
+ id: superadmin.id,
34
+ name: superadmin.name,
35
+ email: superadmin.email,
36
+ role: 'admin'
37
+ })
38
+ )
39
+
40
+ expectServerError(result, 'your own superadmin role')
41
+ })
42
+
43
+ test('admins cannot manage users; superadmins can', async () => {
44
+ const admin = await fx.platformUser('admin')
45
+ const superadmin = await fx.platformUser('superadmin')
46
+
47
+ expectServerError(
48
+ await asUser(admin, () => getUsers({ page: 1, limit: 10 })),
49
+ 'permission'
50
+ )
51
+ expectData(await asUser(superadmin, () => getUsers({ page: 1, limit: 10 })))
52
+ })
53
+
54
+ test('creating a user with a taken email explains the conflict', async () => {
55
+ const superadmin = await fx.platformUser('superadmin')
56
+ const existing = await fx.user()
57
+
58
+ const result = await asUser(superadmin, () =>
59
+ createUser({ name: 'Duplicate', email: existing.email, role: 'user' })
60
+ )
61
+
62
+ expectServerError(result, 'already uses this email')
63
+ })
64
+ })