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,93 +1,73 @@
1
1
  import 'server-only'
2
2
 
3
- import { and, count, desc, eq } from 'drizzle-orm'
4
- import { cache } from 'react'
3
+ import { and, eq, inArray } from 'drizzle-orm'
5
4
  import { db } from '~/db'
6
- import { membersTable, organizationsTable } from '~/db/schema'
7
- import type { UserOrganization } from './app-navigation'
8
- import { getAuthSession } from './auth'
9
- import { resolveImageUrl } from './storage'
5
+ import { membersTable } from '~/db/schema'
6
+ import { AppError, AppErrorCode } from './errors'
7
+ import type { OrganizationRole } from './rbac'
10
8
 
11
- const getUserOrganizations = async (): Promise<UserOrganization[]> => {
12
- const session = await getAuthSession()
13
- if (!session) return []
14
-
15
- const orgs = await db
16
- .select({
17
- id: organizationsTable.id,
18
- name: organizationsTable.name,
19
- slug: organizationsTable.slug,
20
- logo: organizationsTable.logo,
21
- role: membersTable.role,
22
- userId: membersTable.userId,
23
- memberId: membersTable.id
24
- })
25
- .from(membersTable)
26
- .innerJoin(
27
- organizationsTable,
28
- eq(membersTable.organizationId, organizationsTable.id)
29
- )
30
- .where(eq(membersTable.userId, session.user.id))
31
- .orderBy(desc(membersTable.createdAt))
32
-
33
- return orgs.map(o => ({ ...o, logo: resolveImageUrl(o.logo) }))
34
- }
35
-
36
- export const getUserOrganizationsCached = cache(getUserOrganizations)
37
-
38
- export const getUserMembershipCached = cache(async (organizationId: string) => {
39
- const session = await getAuthSession()
40
- if (!session) return null
41
-
42
- const member = await db.query.membersTable.findFirst({
9
+ /**
10
+ * The caller's membership in an organization, or null when they have none.
11
+ */
12
+ export async function getMembership(organizationId: string, userId: string) {
13
+ const membership = await db.query.membersTable.findFirst({
43
14
  where: and(
44
15
  eq(membersTable.organizationId, organizationId),
45
- eq(membersTable.userId, session.user.id)
46
- )
47
- })
48
-
49
- return member ?? null
50
- })
51
-
52
- export const getOrganizationMembers = cache(async (organizationId: string) =>
53
- db.query.membersTable.findMany({
54
- where: eq(membersTable.organizationId, organizationId),
55
- with: {
56
- user: {
57
- columns: { id: true, name: true, email: true, image: true }
58
- }
59
- },
60
- orderBy: [desc(membersTable.createdAt)]
16
+ eq(membersTable.userId, userId)
17
+ ),
18
+ columns: { id: true, role: true }
61
19
  })
62
- )
63
-
64
- export const getOrganizationMemberCount = cache(
65
- async (organizationId: string) => {
66
- const [row] = await db
67
- .select({ count: count() })
68
- .from(membersTable)
69
- .where(eq(membersTable.organizationId, organizationId))
70
20
 
71
- return row?.count ?? 0
72
- }
73
- )
21
+ return membership ?? null
22
+ }
74
23
 
75
- export const assertUserMembership = async (organizationId: string) => {
76
- const membership = await getUserMembershipCached(organizationId)
24
+ /**
25
+ * Asserts the caller belongs to the organization, in any role.
26
+ * Throws {@link AppError} with code FORBIDDEN otherwise.
27
+ */
28
+ export async function assertMember(organizationId: string, userId: string) {
29
+ const membership = await getMembership(organizationId, userId)
77
30
  if (!membership) {
78
- throw new Error('User does not have access to this organization')
31
+ throw new AppError(
32
+ AppErrorCode.FORBIDDEN,
33
+ 'You do not have access to this organization.'
34
+ )
79
35
  }
80
36
  return membership
81
37
  }
82
38
 
83
- const MANAGER_ROLES = ['owner', 'admin']
84
-
85
- export const assertUserCanManageOrganization = async (
86
- organizationId: string
87
- ) => {
88
- const membership = await getUserMembershipCached(organizationId)
89
- if (!membership || !MANAGER_ROLES.includes(membership.role)) {
90
- throw new Error('User does not have permission to manage this organization')
39
+ /**
40
+ * Asserts the caller holds one of `allowedRoles` in the organization.
41
+ */
42
+ export async function assertRole(
43
+ organizationId: string,
44
+ userId: string,
45
+ allowedRoles: ReadonlyArray<OrganizationRole>
46
+ ) {
47
+ const membership = await db.query.membersTable.findFirst({
48
+ where: and(
49
+ eq(membersTable.organizationId, organizationId),
50
+ eq(membersTable.userId, userId),
51
+ inArray(membersTable.role, [...allowedRoles])
52
+ ),
53
+ columns: { id: true, role: true }
54
+ })
55
+ if (!membership) {
56
+ throw new AppError(
57
+ AppErrorCode.FORBIDDEN,
58
+ 'You do not have permission to perform this action.'
59
+ )
91
60
  }
92
61
  return membership
93
62
  }
63
+
64
+ /**
65
+ * Asserts the caller can manage the organization (owner or admin). Use it for
66
+ * settings, billing, members, and uploads.
67
+ */
68
+ export function assertCanManageOrganization(
69
+ organizationId: string,
70
+ userId: string
71
+ ) {
72
+ return assertRole(organizationId, userId, ['owner', 'admin'])
73
+ }
@@ -1,17 +1,107 @@
1
- import { SpanStatusCode, trace } from '@opentelemetry/api'
1
+ import { SpanStatusCode, trace, type Attributes } from '@opentelemetry/api'
2
2
 
3
+ const MAX_STRING_ATTR_LENGTH = 256
4
+ const MAX_ARRAY_ATTR_ITEMS = 20
5
+
6
+ /**
7
+ * Helper function to retrieve the currently active span from the OpenTelemetry context.
8
+ * Useful for attaching additional information to the current trace.
9
+ */
3
10
  function getSpan() {
4
11
  const span = trace.getActiveSpan()
5
12
  return span
6
13
  }
7
14
 
8
- export function recordWideEvent(attributes: Record<string, any>) {
15
+ /**
16
+ * Records a "wide event" with arbitrary attributes to the current span.
17
+ * This allows adding high-cardinality data or domain-specific context
18
+ * to the trace without starting a new child span.
19
+ *
20
+ * @param attributes - Key-value pairs of attributes to add to the span
21
+ */
22
+ export function recordWideEvent(attributes: Attributes) {
9
23
  const span = getSpan()
10
24
  if (!span) return
11
25
 
12
26
  span.setAttributes(attributes)
13
27
  }
14
28
 
29
+ /**
30
+ * Flatten a value tree into OTEL-safe attributes under `prefix`.
31
+ * Nested objects use dotted keys; long strings are truncated; large
32
+ * arrays are capped. Use for action `parsedInput` on wide events.
33
+ */
34
+ export function flattenForWideEvent(
35
+ value: unknown,
36
+ prefix = 'input'
37
+ ): Attributes {
38
+ const out: Attributes = {}
39
+
40
+ const visit = (current: unknown, path: string) => {
41
+ if (current === null || current === undefined) return
42
+
43
+ if (typeof current === 'string') {
44
+ out[path] =
45
+ current.length > MAX_STRING_ATTR_LENGTH
46
+ ? `${current.slice(0, MAX_STRING_ATTR_LENGTH)}…`
47
+ : current
48
+ return
49
+ }
50
+
51
+ if (typeof current === 'number' || typeof current === 'boolean') {
52
+ out[path] = current
53
+ return
54
+ }
55
+
56
+ if (typeof current === 'bigint') {
57
+ out[path] = current.toString()
58
+ return
59
+ }
60
+
61
+ if (Array.isArray(current)) {
62
+ const limited = current.slice(0, MAX_ARRAY_ATTR_ITEMS)
63
+ const allPrimitive = limited.every(
64
+ item =>
65
+ item === null ||
66
+ item === undefined ||
67
+ typeof item === 'string' ||
68
+ typeof item === 'number' ||
69
+ typeof item === 'boolean'
70
+ )
71
+ if (allPrimitive) {
72
+ out[path] = limited.map(item =>
73
+ item === null || item === undefined ? '' : String(item)
74
+ )
75
+ if (current.length > MAX_ARRAY_ATTR_ITEMS) {
76
+ out[`${path}.length`] = current.length
77
+ }
78
+ return
79
+ }
80
+ out[`${path}.length`] = current.length
81
+ limited.forEach((item, index) => visit(item, `${path}.${index}`))
82
+ return
83
+ }
84
+
85
+ if (typeof current === 'object') {
86
+ for (const [key, child] of Object.entries(
87
+ current as Record<string, unknown>
88
+ )) {
89
+ visit(child, path ? `${path}.${key}` : key)
90
+ }
91
+ }
92
+ }
93
+
94
+ visit(value, prefix)
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * Records an exception to the current span and sets the span status to ERROR.
100
+ * captures the error message and stack trace if available.
101
+ *
102
+ * @param err - The error to record (can be an Error object or unknown)
103
+ * @returns The trace ID associated with the error, useful for correlation
104
+ */
15
105
  export function recordError(err: unknown) {
16
106
  const span = getSpan()
17
107
  if (!span) return
@@ -0,0 +1,37 @@
1
+ import 'server-only'
2
+
3
+ import { eq } from 'drizzle-orm'
4
+ import { cache } from 'react'
5
+ import { db } from '~/db'
6
+ import { organizationSubscriptionsTable } from '~/db/schema'
7
+ import { AppError, AppErrorCode } from './errors'
8
+ import { PLANS, isActiveSubscriptionStatus } from './plans'
9
+
10
+ /**
11
+ * The plan an organization is entitled to right now, and its subscription row.
12
+ *
13
+ * Only a trialing, active, or past-due subscription grants its paid plan;
14
+ * every other status falls back to the free plan.
15
+ */
16
+ export const getOrganizationPlan = cache(async (organizationId: string) => {
17
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
18
+ where: eq(organizationSubscriptionsTable.organizationId, organizationId)
19
+ })
20
+
21
+ if (!sub) {
22
+ throw new AppError(
23
+ AppErrorCode.NOT_FOUND,
24
+ 'This organization has no subscription record.'
25
+ )
26
+ }
27
+
28
+ const planId = isActiveSubscriptionStatus(sub.status) ? sub.plan : 'free'
29
+ const plan = PLANS.find(candidate => candidate.id === planId)
30
+
31
+ if (!plan) {
32
+ // A configuration bug, not a customer error: masked and recorded.
33
+ throw new Error(`Plan ${planId} is missing from the plan registry.`)
34
+ }
35
+
36
+ return { plan, sub }
37
+ })
@@ -1,24 +1,25 @@
1
+ import 'server-only'
2
+
1
3
  import { env } from '~/env'
2
4
 
5
+ export type PlanId = 'free' | 'pro' | 'ultimate'
6
+
7
+ type PlanPrice = {
8
+ id: string
9
+ /** Minor units, as Stripe stores them: 2000 is $20.00. */
10
+ amount: number
11
+ currency: string
12
+ }
13
+
3
14
  export type Plan = {
4
- id: 'free' | 'pro' | 'ultimate'
15
+ id: PlanId
5
16
  name: string
6
17
  maxMembers: number
7
- stripePrice?: {
8
- monthly: {
9
- id: string
10
- amount: number
11
- currency: string
12
- }
13
- annual: {
14
- id: string
15
- amount: number
16
- currency: string
17
- }
18
- }
18
+ stripePrice?: { monthly: PlanPrice; annual: PlanPrice }
19
19
  canUseTrial?: boolean
20
20
  }
21
21
 
22
+ /** Every plan, cheapest first. Stripe prices come from `bun run stripe:setup`. */
22
23
  export const PLANS: Plan[] = [
23
24
  {
24
25
  id: 'free',
@@ -61,3 +62,29 @@ export const PLANS: Plan[] = [
61
62
  }
62
63
  }
63
64
  ]
65
+
66
+ /** Subscription statuses that keep the paid plan's features switched on. */
67
+ export const ACTIVE_SUBSCRIPTION_STATUSES = [
68
+ 'trialing',
69
+ 'active',
70
+ 'past_due'
71
+ ] as const
72
+
73
+ export function isActiveSubscriptionStatus(status: string): boolean {
74
+ return (ACTIVE_SUBSCRIPTION_STATUSES as readonly string[]).includes(status)
75
+ }
76
+
77
+ /**
78
+ * Statuses with no live Stripe subscription behind them. Any other status
79
+ * (including `unpaid`, `paused`, and `incomplete`) still bills or can resume,
80
+ * so changes go through the billing portal instead of a new checkout.
81
+ */
82
+ export const ENDED_SUBSCRIPTION_STATUSES = [
83
+ 'free',
84
+ 'canceled',
85
+ 'incomplete_expired'
86
+ ] as const
87
+
88
+ export function hasLiveSubscription(status: string): boolean {
89
+ return !(ENDED_SUBSCRIPTION_STATUSES as readonly string[]).includes(status)
90
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Organization role predicates and labels, shared by server and client.
3
+ *
4
+ * The roles are the Better Auth organization plugin defaults:
5
+ * - owner: full control, including ownership transfer and deletion
6
+ * - admin: manages settings, billing, and members, but not ownership
7
+ * - member: standard access to the organization's workspace
8
+ */
9
+
10
+ export type OrganizationRole = 'owner' | 'admin' | 'member'
11
+
12
+ /** Roles an owner or admin can give to someone. Ownership moves only by transfer. */
13
+ export const ASSIGNABLE_ROLES = ['admin', 'member'] as const
14
+
15
+ export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number]
16
+
17
+ /** True for the manager tier: owner and admin. */
18
+ export function isManagerRole(role: string | null | undefined): boolean {
19
+ return role === 'owner' || role === 'admin'
20
+ }
21
+
22
+ /** Descriptions shown when you pick a role for a member or an invitation. */
23
+ export const ROLE_DESCRIPTIONS: Record<AssignableRole, string> = {
24
+ admin: 'Admin: manage settings, billing, and members',
25
+ member: 'Member: use the workspace'
26
+ }
27
+
28
+ /** Shown to an owner before they transfer ownership to another member. */
29
+ export const OWNERSHIP_DEMOTION_NOTICE =
30
+ 'After the transfer, your role becomes admin: you keep every permission except ownership transfer and organization deletion.'
31
+
32
+ /** Role with its article, for sentences such as "join Acme as an admin". */
33
+ export function roleArticle(role: string): string {
34
+ switch (role) {
35
+ case 'owner':
36
+ return 'an owner'
37
+ case 'admin':
38
+ return 'an admin'
39
+ case 'member':
40
+ return 'a member'
41
+ default:
42
+ return `a ${role}`
43
+ }
44
+ }
@@ -8,25 +8,52 @@ import {
8
8
  } from '@tanstack/react-query'
9
9
  import type { SafeActionResult } from 'next-safe-action'
10
10
 
11
- type AnyAction = (
11
+ /** Type helper for any server action function */
12
+ export type AnyAction = (
12
13
  ...args: any[]
13
14
  ) => Promise<SafeActionResult<any, any, any, any, any>>
15
+
16
+ /** Extracts the input type from a server action */
14
17
  type ActionInput<T extends AnyAction> = Parameters<T>[0]
18
+
19
+ /** Extracts the data type from a server action result */
15
20
  type ActionData<T extends AnyAction> = Awaited<ReturnType<T>>['data']
16
21
 
22
+ /**
23
+ * Client-side error for server action failures.
24
+ * Carries the user-facing `serverError` string (including unmasked AppError
25
+ * messages) and optional Zod validation errors.
26
+ */
17
27
  class SafeActionError extends Error {
18
28
  constructor(
19
29
  public message: string,
20
30
  public validationErrors?: Record<string, string[] | undefined>
21
31
  ) {
22
32
  super(message)
33
+ this.name = 'SafeActionError'
23
34
  }
24
35
  }
25
36
 
37
+ /**
38
+ * React Query hook for server actions that fetch data
39
+ * Wraps server actions with proper error handling and caching support
40
+ *
41
+ * @param key - Unique query key for caching
42
+ * @param action - Server action function to execute
43
+ * @param input - Input parameters for the server action (optional)
44
+ * @param options - React Query options (staleTime, refetchInterval, etc.)
45
+ *
46
+ * @returns React Query result with data, loading, and error states
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * const { data, isLoading } = useSafeActionQuery('cart-items', getCartItems)
51
+ * ```
52
+ */
26
53
  export function useSafeActionQuery<TAction extends AnyAction>(
27
54
  key: string,
28
55
  action: TAction,
29
- input: ActionInput<TAction>,
56
+ input?: ActionInput<TAction>,
30
57
  options?: Omit<UseQueryOptions<ActionData<TAction>>, 'queryKey' | 'queryFn'>
31
58
  ) {
32
59
  return useQuery({
@@ -39,7 +66,10 @@ export function useSafeActionQuery<TAction extends AnyAction>(
39
66
  }
40
67
 
41
68
  if (result?.validationErrors) {
42
- throw new SafeActionError('Validation failed', result.validationErrors)
69
+ throw new SafeActionError(
70
+ 'Check the highlighted fields and try again.',
71
+ result.validationErrors
72
+ )
43
73
  }
44
74
 
45
75
  return result?.data
@@ -48,13 +78,31 @@ export function useSafeActionQuery<TAction extends AnyAction>(
48
78
  })
49
79
  }
50
80
 
81
+ /**
82
+ * React Query mutation hook for server actions that modify data
83
+ * Wraps server actions with proper error handling and optimistic updates support
84
+ *
85
+ * @param action - Server action function to execute
86
+ * @param options - React Query mutation options (onSuccess, onError, etc.)
87
+ *
88
+ * @returns React Query mutation result with mutate function and states
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * const { mutate, isPending } = useSafeActionMutation(addToCart, {
93
+ * onSuccess: (data) => {
94
+ * toastSuccessMessage('Added to cart!')
95
+ * queryClient.invalidateQueries({ queryKey: ['cart-items'] })
96
+ * },
97
+ * onError: (error) => {
98
+ * toastActionError(error, 'Failed to add to cart')
99
+ * }
100
+ * })
101
+ * ```
102
+ */
51
103
  export function useSafeActionMutation<TAction extends AnyAction>(
52
104
  action: TAction,
53
- options?: UseMutationOptions<
54
- ActionData<TAction>,
55
- SafeActionError,
56
- ActionInput<TAction>
57
- >
105
+ options?: UseMutationOptions<ActionData<TAction>, Error, ActionInput<TAction>>
58
106
  ) {
59
107
  return useMutation({
60
108
  mutationFn: async (input: ActionInput<TAction>) => {
@@ -65,7 +113,10 @@ export function useSafeActionMutation<TAction extends AnyAction>(
65
113
  }
66
114
 
67
115
  if (result?.validationErrors) {
68
- throw new SafeActionError('Validation failed', result.validationErrors)
116
+ throw new SafeActionError(
117
+ 'Check the highlighted fields and try again.',
118
+ result.validationErrors
119
+ )
69
120
  }
70
121
 
71
122
  return result?.data