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,20 +1,31 @@
1
1
  'use client'
2
2
 
3
- import { LogOut, UserCog } from 'lucide-react'
3
+ import {
4
+ House,
5
+ LayoutGrid,
6
+ LogOut,
7
+ ShieldCheck,
8
+ Ticket,
9
+ UserCog,
10
+ UserX
11
+ } from 'lucide-react'
4
12
  import Link from 'next/link'
5
- import { useRouter } from 'next/navigation'
13
+ import { usePathname, useRouter } from 'next/navigation'
14
+ import { useState } from 'react'
6
15
  import { ThemeToggle } from '~/components/theme-toggle'
7
16
  import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'
8
17
  import { Button } from '~/components/ui/button'
9
18
  import {
10
19
  DropdownMenu,
11
20
  DropdownMenuContent,
21
+ DropdownMenuGroup,
12
22
  DropdownMenuItem,
13
23
  DropdownMenuLabel,
14
24
  DropdownMenuSeparator,
15
25
  DropdownMenuTrigger
16
26
  } from '~/components/ui/dropdown-menu'
17
- import { signOut } from '~/lib/auth-client'
27
+ import { admin, signOut } from '~/lib/auth-client'
28
+ import { cn } from '~/lib/cn'
18
29
 
19
30
  type UserMenuProps = {
20
31
  user: {
@@ -25,16 +36,32 @@ type UserMenuProps = {
25
36
  fallbackName?: string
26
37
  fallbackInitials?: string
27
38
  wrapperClassName?: string
39
+ /** Superadmins work in both panels, so they get a way to cross over. */
40
+ isSuperAdmin?: boolean
41
+ /** This session is an admin acting as someone else. */
42
+ isImpersonating?: boolean
28
43
  }
29
44
 
30
45
  export function UserMenu({
31
46
  user,
32
47
  fallbackName = 'Unknown',
33
48
  fallbackInitials = 'NA',
34
- wrapperClassName = 'flex items-center gap-2'
49
+ wrapperClassName = 'flex items-center gap-2',
50
+ isSuperAdmin = false,
51
+ isImpersonating = false
35
52
  }: UserMenuProps) {
36
53
  const router = useRouter()
54
+ const pathname = usePathname()
55
+ const [isStopping, setIsStopping] = useState(false)
56
+
57
+ // One entry, pointing at the panel you are not in. Account pages sit outside
58
+ // both, and lead back to the admin panel.
59
+ const inAdminPanel = pathname?.startsWith('/admin') ?? false
60
+ const switchTo = inAdminPanel
61
+ ? { href: '/app', label: 'Switch to App', Icon: LayoutGrid }
62
+ : { href: '/admin', label: 'Switch to Admin', Icon: ShieldCheck }
37
63
 
64
+ const displayName = user.name ?? fallbackName
38
65
  const initials =
39
66
  user.name
40
67
  ?.split(' ')
@@ -42,47 +69,143 @@ export function UserMenu({
42
69
  .map(part => part[0])
43
70
  .join('') || fallbackInitials
44
71
 
72
+ async function stopImpersonating() {
73
+ setIsStopping(true)
74
+ try {
75
+ await admin.stopImpersonating()
76
+ // Back to the admin panel: the restored session may have no access to the
77
+ // organization page the impersonated user was looking at.
78
+ router.push('/admin')
79
+ router.refresh()
80
+ } catch {
81
+ setIsStopping(false)
82
+ }
83
+ }
84
+
45
85
  return (
46
86
  <div className={wrapperClassName}>
47
87
  <ThemeToggle />
48
88
  <DropdownMenu>
49
89
  <DropdownMenuTrigger asChild>
50
- <Button variant="ghost" className="relative size-8 rounded-full">
51
- <Avatar className="size-8 cursor-pointer">
90
+ <Button
91
+ variant="ghost"
92
+ className="relative size-8 rounded-full"
93
+ aria-label={
94
+ isImpersonating
95
+ ? `Account menu, impersonating ${displayName}`
96
+ : 'Account menu'
97
+ }
98
+ >
99
+ <Avatar
100
+ className={cn(
101
+ 'size-8',
102
+ isImpersonating &&
103
+ 'ring-warning ring-offset-background ring-2 ring-offset-2'
104
+ )}
105
+ >
52
106
  <AvatarFallback className="text-foreground">
53
107
  {initials}
54
108
  </AvatarFallback>
55
- {user.image ? <AvatarImage src={user.image} alt="User" /> : null}
109
+ {user.image ? (
110
+ <AvatarImage src={user.image} alt={displayName} />
111
+ ) : null}
56
112
  </Avatar>
113
+ {isImpersonating ? (
114
+ <span className="bg-warning text-warning-foreground ring-background absolute -right-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full ring-2">
115
+ <UserX className="size-2.5" aria-hidden="true" />
116
+ </span>
117
+ ) : null}
57
118
  </Button>
58
119
  </DropdownMenuTrigger>
59
- <DropdownMenuContent className="w-56" align="end" forceMount>
120
+ <DropdownMenuContent className="w-60" align="end" forceMount>
60
121
  <DropdownMenuLabel className="font-normal">
61
122
  <div className="flex flex-col space-y-1">
62
123
  <p className="text-foreground text-sm leading-none font-medium">
63
- {user.name ?? fallbackName}
124
+ {displayName}
64
125
  </p>
65
126
  <p className="text-muted-foreground text-xs leading-none">
66
127
  {user.email ?? ''}
67
128
  </p>
68
129
  </div>
69
130
  </DropdownMenuLabel>
131
+
132
+ {isImpersonating ? (
133
+ <>
134
+ <DropdownMenuSeparator />
135
+ <div className="border-warning/40 bg-warning/10 mx-1 mb-1 flex items-start gap-2 rounded-md border px-2 py-1.5">
136
+ <UserX
137
+ className="text-warning mt-0.5 size-3.5 shrink-0"
138
+ aria-hidden="true"
139
+ />
140
+ <p className="text-foreground text-xs leading-snug">
141
+ You are signed in as this user. Anything you do is recorded
142
+ against their account.
143
+ </p>
144
+ </div>
145
+ <DropdownMenuGroup>
146
+ <DropdownMenuItem
147
+ variant="destructive"
148
+ disabled={isStopping}
149
+ onSelect={event => {
150
+ // Hold the menu open while the session swaps back.
151
+ event.preventDefault()
152
+ void stopImpersonating()
153
+ }}
154
+ >
155
+ <UserX className="mr-2 size-4" />
156
+ <span>{isStopping ? 'Stopping…' : 'Stop Impersonating'}</span>
157
+ </DropdownMenuItem>
158
+ </DropdownMenuGroup>
159
+ </>
160
+ ) : null}
161
+
162
+ {isSuperAdmin ? (
163
+ <>
164
+ <DropdownMenuSeparator />
165
+ <DropdownMenuGroup>
166
+ <DropdownMenuItem asChild>
167
+ <Link href={switchTo.href}>
168
+ <switchTo.Icon className="mr-2 size-4" />
169
+ <span>{switchTo.label}</span>
170
+ </Link>
171
+ </DropdownMenuItem>
172
+ </DropdownMenuGroup>
173
+ </>
174
+ ) : null}
175
+
70
176
  <DropdownMenuSeparator />
71
- <DropdownMenuItem asChild>
72
- <Link href="/profile">
73
- <UserCog className="mr-2 size-4" />
74
- <span>Profile</span>
75
- </Link>
76
- </DropdownMenuItem>
77
- <DropdownMenuItem
78
- onClick={async () => {
79
- await signOut()
80
- router.push('/login')
81
- }}
82
- >
83
- <LogOut className="mr-2 size-4" />
84
- <span>Log out</span>
85
- </DropdownMenuItem>
177
+ <DropdownMenuGroup>
178
+ <DropdownMenuItem asChild>
179
+ <Link href="/">
180
+ <House className="mr-2 size-4" />
181
+ <span>Home</span>
182
+ </Link>
183
+ </DropdownMenuItem>
184
+ <DropdownMenuItem asChild>
185
+ <Link href="/profile">
186
+ <UserCog className="mr-2 size-4" />
187
+ <span>Profile</span>
188
+ </Link>
189
+ </DropdownMenuItem>
190
+ <DropdownMenuItem asChild>
191
+ <Link href="/support">
192
+ <Ticket className="mr-2 size-4" />
193
+ <span>Support</span>
194
+ </Link>
195
+ </DropdownMenuItem>
196
+ </DropdownMenuGroup>
197
+ <DropdownMenuSeparator />
198
+ <DropdownMenuGroup>
199
+ <DropdownMenuItem
200
+ onClick={async () => {
201
+ await signOut()
202
+ router.push('/login')
203
+ }}
204
+ >
205
+ <LogOut className="mr-2 size-4" />
206
+ <span>Log Out</span>
207
+ </DropdownMenuItem>
208
+ </DropdownMenuGroup>
86
209
  </DropdownMenuContent>
87
210
  </DropdownMenu>
88
211
  </div>
@@ -1,17 +1,32 @@
1
+ /**
2
+ * @fileoverview Reusable Drizzle column definitions shared across tables.
3
+ *
4
+ * Centralises common patterns (prefixed CUID primary keys, timestamps) so
5
+ * every table stays consistent.
6
+ */
1
7
  import { createId } from '@paralleldrive/cuid2'
2
8
  import { sql } from 'drizzle-orm'
3
- import { boolean, timestamp, varchar } from 'drizzle-orm/pg-core'
9
+ import {
10
+ boolean,
11
+ check,
12
+ timestamp,
13
+ varchar,
14
+ type AnyPgColumn
15
+ } from 'drizzle-orm/pg-core'
4
16
 
5
17
  export const commonFieldDefs = {
18
+ /** Generates a prefixed CUID2 primary key, such as `user_clx1234567890`. */
6
19
  id: (prefix: string) =>
7
20
  varchar('id')
8
21
  .primaryKey()
9
22
  .$defaultFn(() => prefix + '_' + createId()),
23
+ /** Nullable timezone-aware timestamp column. */
10
24
  date: (name: string) =>
11
25
  timestamp(name, {
12
26
  mode: 'date',
13
27
  withTimezone: true
14
28
  }),
29
+ /** Auto-populated `created_at` and `updated_at` timestamp pair. */
15
30
  dates: {
16
31
  createdAt: timestamp('created_at', {
17
32
  mode: 'date',
@@ -27,5 +42,22 @@ export const commonFieldDefs = {
27
42
  .$onUpdate(() => sql`NOW()`)
28
43
  .defaultNow()
29
44
  },
45
+ /** Boolean `is_active` flag, defaults to `true`. */
30
46
  isActive: boolean('is_active').notNull().default(true)
31
47
  }
48
+
49
+ /**
50
+ * CHECK constraint holding every listed column at zero or above.
51
+ *
52
+ * Invariants belong in the database, not only in Zod: the seed script, a
53
+ * `psql` session, and Drizzle Studio all write around Zod. Nullable columns
54
+ * still pass, since a CHECK rejects only a definitively false result.
55
+ */
56
+ export const nonNegativeCheck = (name: string, columns: AnyPgColumn[]) =>
57
+ check(
58
+ name,
59
+ sql.join(
60
+ columns.map(column => sql`${column} >= 0`),
61
+ sql` AND `
62
+ )
63
+ )
@@ -1,10 +1,19 @@
1
+ /**
2
+ * @fileoverview Postgres enum definitions. Each constant maps to a
3
+ * `CREATE TYPE ... AS ENUM` referenced by columns in `schema.ts`.
4
+ *
5
+ * @module db/enums
6
+ */
1
7
  import { pgEnum } from 'drizzle-orm/pg-core'
2
8
 
9
+ // ─── Billing ────────────────────────────────────────────────────
10
+
3
11
  export const organizationSubscriptionPlanEnum = pgEnum(
4
12
  'organization_subscription_plan_enum',
5
13
  ['free', 'pro', 'ultimate']
6
14
  )
7
15
 
16
+ /** Stripe's subscription statuses, plus `free` for organizations without one. */
8
17
  export const organizationSubscriptionStatusEnum = pgEnum(
9
18
  'organization_subscription_status_enum',
10
19
  [
@@ -24,3 +33,18 @@ export const organizationSubscriptionIntervalEnum = pgEnum(
24
33
  'organization_subscription_interval_enum',
25
34
  ['month', 'year']
26
35
  )
36
+
37
+ // ─── Support ────────────────────────────────────────────────────
38
+
39
+ export const supportTicketStatusEnum = pgEnum('support_ticket_status_enum', [
40
+ 'open',
41
+ 'in_progress',
42
+ 'waiting_customer',
43
+ 'resolved',
44
+ 'closed'
45
+ ])
46
+
47
+ export const supportTicketPriorityEnum = pgEnum(
48
+ 'support_ticket_priority_enum',
49
+ ['low', 'medium', 'high', 'urgent']
50
+ )
@@ -1,3 +1,5 @@
1
+ import 'server-only'
2
+
1
3
  import { drizzle } from 'drizzle-orm/postgres-js'
2
4
  import postgres from 'postgres'
3
5
  import { env } from '~/env'
@@ -12,14 +14,21 @@ const globalForDb = globalThis as unknown as {
12
14
  }
13
15
 
14
16
  // Prefer the cached client (dev) and fall back to creating a new one.
15
- // `max: 1` limits the pool size, and `prepare: false` disables prepared
16
- // statements for compatibility with environments where prepared statements
17
- // can be problematic.
17
+ // `prepare: false` disables prepared statements, which transaction-mode
18
+ // poolers such as PgBouncer and Neon's pooler reject.
18
19
  const client =
19
20
  globalForDb.client ??
20
21
  postgres(env.DATABASE_URL, {
21
- max: 1,
22
- prepare: false
22
+ prepare: false,
23
+ // For neon.tech postgres db, as its serverless
24
+ ...(env.DATABASE_URL.includes('neon.tech')
25
+ ? {
26
+ max: 5,
27
+ idle_timeout: 30, // seconds an idle connection is kept before closing
28
+ max_lifetime: 60 * 5, // recycle a connection after 5 minutes
29
+ connect_timeout: 60 // seconds to wait for a connection (Neon cold start)
30
+ }
31
+ : {})
23
32
  })
24
33
 
25
34
  // Only cache the client outside production to avoid unexpected cross-request
@@ -1,11 +1,14 @@
1
1
  import { relations } from 'drizzle-orm'
2
2
  import {
3
3
  accountsTable,
4
+ feedbacksTable,
4
5
  invitationsTable,
5
6
  membersTable,
6
- organizationsTable,
7
7
  organizationSubscriptionsTable,
8
+ organizationsTable,
8
9
  sessionsTable,
10
+ supportTicketMessagesTable,
11
+ supportTicketsTable,
9
12
  usersTable
10
13
  } from './schema'
11
14
 
@@ -38,7 +41,9 @@ export const organizationRelations = relations(
38
41
  references: [organizationSubscriptionsTable.organizationId]
39
42
  }),
40
43
  members: many(membersTable),
41
- invitations: many(invitationsTable)
44
+ invitations: many(invitationsTable),
45
+ supportTickets: many(supportTicketsTable),
46
+ feedbacks: many(feedbacksTable)
42
47
  })
43
48
  )
44
49
 
@@ -58,7 +63,7 @@ export const invitationRelations = relations(invitationsTable, ({ one }) => ({
58
63
  fields: [invitationsTable.organizationId],
59
64
  references: [organizationsTable.id]
60
65
  }),
61
- user: one(usersTable, {
66
+ inviter: one(usersTable, {
62
67
  fields: [invitationsTable.inviterId],
63
68
  references: [usersTable.id]
64
69
  })
@@ -73,3 +78,35 @@ export const organizationSubscriptionRelations = relations(
73
78
  })
74
79
  })
75
80
  )
81
+
82
+ export const supportTicketRelations = relations(
83
+ supportTicketsTable,
84
+ ({ one, many }) => ({
85
+ organization: one(organizationsTable, {
86
+ fields: [supportTicketsTable.organizationId],
87
+ references: [organizationsTable.id]
88
+ }),
89
+ user: one(usersTable, {
90
+ fields: [supportTicketsTable.userId],
91
+ references: [usersTable.id]
92
+ }),
93
+ messages: many(supportTicketMessagesTable)
94
+ })
95
+ )
96
+
97
+ export const supportTicketMessageRelations = relations(
98
+ supportTicketMessagesTable,
99
+ ({ one }) => ({
100
+ ticket: one(supportTicketsTable, {
101
+ fields: [supportTicketMessagesTable.ticketId],
102
+ references: [supportTicketsTable.id]
103
+ })
104
+ })
105
+ )
106
+
107
+ export const feedbackRelations = relations(feedbacksTable, ({ one }) => ({
108
+ organization: one(organizationsTable, {
109
+ fields: [feedbacksTable.organizationId],
110
+ references: [organizationsTable.id]
111
+ })
112
+ }))