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,415 +0,0 @@
1
- 'use client'
2
-
3
- import { useQueryClient } from '@tanstack/react-query'
4
- import { ColumnDef } from '@tanstack/react-table'
5
- import {
6
- Ban,
7
- Edit,
8
- MoreHorizontal,
9
- Plus,
10
- Shield,
11
- Trash2,
12
- UserCog,
13
- Users
14
- } from 'lucide-react'
15
- import { useRouter } from 'next/navigation'
16
- import {
17
- parseAsInteger,
18
- parseAsJson,
19
- parseAsString,
20
- useQueryStates
21
- } from 'nuqs'
22
- import { useState } from 'react'
23
- import { DataTable } from '~/components/data-table'
24
- import { SortOrderEnum } from '~/components/data-table/enum'
25
- import { DataTableFilter } from '~/components/data-table/types'
26
- import { PageHeading } from '~/components/page-heading'
27
- import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'
28
- import { Badge } from '~/components/ui/badge'
29
- import { Button } from '~/components/ui/button'
30
- import {
31
- DropdownMenu,
32
- DropdownMenuContent,
33
- DropdownMenuItem,
34
- DropdownMenuLabel,
35
- DropdownMenuSeparator,
36
- DropdownMenuSub,
37
- DropdownMenuSubContent,
38
- DropdownMenuSubTrigger,
39
- DropdownMenuTrigger
40
- } from '~/components/ui/dropdown-menu'
41
- import { admin } from '~/lib/auth-client'
42
- import { formatDate } from '~/lib/format-date'
43
- import { useSafeActionQuery } from '~/lib/safe-action-client'
44
- import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
45
- import { getDistinctRoles, getUsers } from '../../../../actions/users'
46
- import { CreateUserDialog } from './create-user-dialog'
47
- import { EditNameDialog } from './edit-name-dialog'
48
-
49
- type User = {
50
- id: string
51
- name: string
52
- email: string
53
- emailVerified: boolean
54
- image: string | null
55
- role: string | null
56
- banned: boolean | null
57
- createdAt: Date
58
- updatedAt: Date
59
- }
60
-
61
- const getInitials = (name: string) =>
62
- name
63
- .split(' ')
64
- .map(n => n[0])
65
- .join('')
66
- .toUpperCase()
67
- .slice(0, 2)
68
-
69
- const ADMIN_ROLES = new Set(['admin', 'superadmin'])
70
-
71
- function UserActionsCell({
72
- user,
73
- isSuperAdmin,
74
- currentUserId
75
- }: {
76
- user: User
77
- isSuperAdmin: boolean
78
- currentUserId: string
79
- }) {
80
- const isSelf = user.id === currentUserId
81
- const router = useRouter()
82
- const queryClient = useQueryClient()
83
- const [isLoading, setIsLoading] = useState(false)
84
-
85
- const canImpersonate = isSuperAdmin
86
- ? user.role !== 'superadmin'
87
- : !ADMIN_ROLES.has(user.role ?? '')
88
-
89
- async function handleImpersonate() {
90
- setIsLoading(true)
91
- try {
92
- await admin.impersonateUser({ userId: user.id })
93
- router.push('/app')
94
- } catch {
95
- toastErrorMessage('Failed to impersonate user')
96
- setIsLoading(false)
97
- }
98
- }
99
-
100
- async function handleToggleBan() {
101
- const confirmed = window.confirm(
102
- user.banned
103
- ? 'Unban this user?'
104
- : 'Ban this user? They will no longer be able to sign in.'
105
- )
106
- if (!confirmed) return
107
-
108
- setIsLoading(true)
109
- try {
110
- const action = user.banned ? admin.unbanUser : admin.banUser
111
- const { error } = await action({ userId: user.id })
112
-
113
- if (error) {
114
- toastErrorMessage(
115
- error.message ?? (user.banned ? 'Failed to unban' : 'Failed to ban')
116
- )
117
- } else {
118
- toastSuccessMessage(user.banned ? 'User unbanned' : 'User banned')
119
- queryClient.invalidateQueries({ queryKey: ['admin-users'] })
120
- }
121
- } finally {
122
- setIsLoading(false)
123
- }
124
- }
125
-
126
- async function handleDelete() {
127
- const confirmed = window.confirm(
128
- 'Delete this user? This action cannot be undone.'
129
- )
130
- if (!confirmed) return
131
-
132
- setIsLoading(true)
133
- try {
134
- const { error } = await admin.removeUser({ userId: user.id })
135
-
136
- if (error) {
137
- toastErrorMessage(error.message ?? 'Failed to delete user')
138
- } else {
139
- toastSuccessMessage('User deleted')
140
- queryClient.invalidateQueries({ queryKey: ['admin-users'] })
141
- }
142
- } finally {
143
- setIsLoading(false)
144
- }
145
- }
146
-
147
- async function handleSetRole(role: string) {
148
- setIsLoading(true)
149
- try {
150
- const { error } = await admin.setRole({
151
- userId: user.id,
152
- role: role as any
153
- })
154
-
155
- if (error) {
156
- toastErrorMessage(error.message ?? 'Failed to update role')
157
- } else {
158
- toastSuccessMessage('Role updated')
159
- queryClient.invalidateQueries({ queryKey: ['admin-users'] })
160
- }
161
- } finally {
162
- setIsLoading(false)
163
- }
164
- }
165
-
166
- return (
167
- <DropdownMenu>
168
- <DropdownMenuTrigger asChild>
169
- <Button
170
- variant="ghost"
171
- size="sm"
172
- className="h-8 w-8 p-0"
173
- aria-label="User actions"
174
- >
175
- <MoreHorizontal className="h-4 w-4" />
176
- </Button>
177
- </DropdownMenuTrigger>
178
- <DropdownMenuContent align="end" className="w-48">
179
- <DropdownMenuLabel className="text-muted-foreground max-w-44 truncate text-xs font-normal">
180
- {user.email}
181
- </DropdownMenuLabel>
182
- <DropdownMenuSeparator />
183
-
184
- <EditNameDialog userId={user.id} currentName={user.name}>
185
- <DropdownMenuItem onSelect={e => e.preventDefault()}>
186
- <Edit className="mr-2 h-4 w-4" />
187
- Edit name
188
- </DropdownMenuItem>
189
- </EditNameDialog>
190
-
191
- {canImpersonate && !isSelf && (
192
- <DropdownMenuItem disabled={isLoading} onClick={handleImpersonate}>
193
- <UserCog className="mr-2 h-4 w-4" />
194
- Impersonate
195
- </DropdownMenuItem>
196
- )}
197
-
198
- {!isSelf && (isSuperAdmin || user.role !== 'superadmin') && (
199
- <DropdownMenuItem disabled={isLoading} onClick={handleToggleBan}>
200
- <Ban className="mr-2 h-4 w-4" />
201
- {user.banned ? 'Unban user' : 'Ban user'}
202
- </DropdownMenuItem>
203
- )}
204
-
205
- {isSuperAdmin && !isSelf && (
206
- <DropdownMenuSub>
207
- <DropdownMenuSubTrigger disabled={isLoading}>
208
- <Shield className="mr-2 h-4 w-4" />
209
- Set role
210
- </DropdownMenuSubTrigger>
211
- <DropdownMenuSubContent>
212
- {(['user', 'admin', 'superadmin'] as const).map(role => (
213
- <DropdownMenuItem
214
- key={role}
215
- disabled={user.role === role || isLoading}
216
- onClick={() => handleSetRole(role)}
217
- className="capitalize"
218
- >
219
- {role}
220
- {user.role === role && (
221
- <span className="text-muted-foreground ml-auto text-xs">
222
- current
223
- </span>
224
- )}
225
- </DropdownMenuItem>
226
- ))}
227
- </DropdownMenuSubContent>
228
- </DropdownMenuSub>
229
- )}
230
-
231
- {isSuperAdmin && !isSelf && (
232
- <>
233
- <DropdownMenuSeparator />
234
- <DropdownMenuItem
235
- variant="destructive"
236
- disabled={isLoading}
237
- onClick={handleDelete}
238
- >
239
- <Trash2 className="mr-2 h-4 w-4" />
240
- Delete user
241
- </DropdownMenuItem>
242
- </>
243
- )}
244
- </DropdownMenuContent>
245
- </DropdownMenu>
246
- )
247
- }
248
-
249
- const filters: DataTableFilter[] = [
250
- {
251
- id: 'role',
252
- label: 'Role',
253
- action: getDistinctRoles,
254
- queryKey: 'admin-distinct-roles'
255
- },
256
- {
257
- id: 'banned',
258
- label: 'Status',
259
- options: [
260
- { label: 'Active', value: false },
261
- { label: 'Banned', value: true }
262
- ]
263
- }
264
- ]
265
-
266
- interface UsersTableProps {
267
- isSuperAdmin: boolean
268
- currentUserId: string
269
- }
270
-
271
- export function UsersTable({ isSuperAdmin, currentUserId }: UsersTableProps) {
272
- const [queryState, setQueryState] = useQueryStates({
273
- page: parseAsInteger.withDefault(1),
274
- limit: parseAsInteger.withDefault(10),
275
- sortBy: parseAsString,
276
- sortOrder: parseAsString,
277
- search: parseAsString,
278
- filters: parseAsJson(v => v as Record<string, (string | boolean)[]>)
279
- })
280
-
281
- const params = {
282
- page: queryState.page,
283
- limit: queryState.limit,
284
- sortBy: (queryState.sortBy ?? undefined) as
285
- | 'name'
286
- | 'email'
287
- | 'createdAt'
288
- | undefined,
289
- sortOrder: (queryState.sortOrder ?? undefined) as SortOrderEnum | undefined,
290
- search: queryState.search ?? undefined,
291
- filters: queryState.filters ?? undefined
292
- }
293
-
294
- const { data, isLoading } = useSafeActionQuery(
295
- 'admin-users',
296
- getUsers,
297
- params
298
- )
299
-
300
- const columns: ColumnDef<User, unknown>[] = [
301
- {
302
- accessorKey: 'name',
303
- header: 'User',
304
- enableSorting: true,
305
- cell: ({ row }) => (
306
- <div className="flex items-center gap-3">
307
- <Avatar className="h-8 w-8">
308
- <AvatarImage src={row.original.image ?? undefined} />
309
- <AvatarFallback className="text-xs">
310
- {getInitials(row.original.name)}
311
- </AvatarFallback>
312
- </Avatar>
313
- <div className="flex flex-col">
314
- <span className="text-sm font-medium">{row.original.name}</span>
315
- <span className="text-muted-foreground text-xs">
316
- {row.original.email}
317
- </span>
318
- </div>
319
- </div>
320
- )
321
- },
322
- {
323
- accessorKey: 'role',
324
- header: 'Role',
325
- cell: ({ row }) => (
326
- <Badge variant="outline" className="capitalize">
327
- {row.original.role ?? 'user'}
328
- </Badge>
329
- )
330
- },
331
- {
332
- accessorKey: 'banned',
333
- header: 'Status',
334
- enableSorting: false,
335
- cell: ({ row }) => (
336
- <Badge variant={row.original.banned ? 'destructive' : 'default'}>
337
- {row.original.banned ? 'Banned' : 'Active'}
338
- </Badge>
339
- )
340
- },
341
- {
342
- accessorKey: 'emailVerified',
343
- header: 'Verified',
344
- enableSorting: false,
345
- cell: ({ row }) => (
346
- <Badge variant={row.original.emailVerified ? 'default' : 'secondary'}>
347
- {row.original.emailVerified ? 'Verified' : 'Unverified'}
348
- </Badge>
349
- )
350
- },
351
- {
352
- accessorKey: 'createdAt',
353
- header: 'Joined',
354
- enableSorting: true,
355
- cell: ({ row }) => formatDate(row.original.createdAt, { short: true })
356
- },
357
- {
358
- id: 'actions',
359
- header: '',
360
- enableSorting: false,
361
- cell: ({ row }) => (
362
- <UserActionsCell
363
- user={row.original}
364
- isSuperAdmin={isSuperAdmin}
365
- currentUserId={currentUserId}
366
- />
367
- )
368
- }
369
- ]
370
-
371
- return (
372
- <div className="flex flex-1 flex-col gap-6 p-4">
373
- <PageHeading
374
- title="Users"
375
- description="Manage users and their roles."
376
- icon={Users}
377
- >
378
- {isSuperAdmin && (
379
- <CreateUserDialog>
380
- <Button size="lg">
381
- <Plus className="mr-2 h-4 w-4" />
382
- New user
383
- </Button>
384
- </CreateUserDialog>
385
- )}
386
- </PageHeading>
387
-
388
- <DataTable
389
- columns={columns}
390
- data={data?.[0]}
391
- isLoading={isLoading}
392
- totalCount={data?.[1]}
393
- enableSearch
394
- initialSearch={params.search}
395
- searchPlaceholder="Search users..."
396
- manualPagination
397
- manualSorting
398
- pageIndex={params.page - 1}
399
- pageSize={params.limit}
400
- filters={filters}
401
- activeFilters={params.filters ?? {}}
402
- onParamsChange={({ page, limit, sortBy, sortOrder, search, filters }) =>
403
- setQueryState({
404
- page,
405
- limit,
406
- sortBy: sortBy ?? null,
407
- sortOrder: sortOrder ?? null,
408
- search: search ?? null,
409
- filters: filters ?? null
410
- })
411
- }
412
- />
413
- </div>
414
- )
415
- }
@@ -1,92 +0,0 @@
1
- 'use server'
2
-
3
- import { and, eq } from 'drizzle-orm'
4
- import { z } from 'zod'
5
- import { db } from '~/db'
6
- import { membersTable, organizationsTable } from '~/db/schema'
7
- import {
8
- assertUserCanManageOrganization,
9
- getUserMembershipCached
10
- } from '~/lib/organization-access'
11
- import { authActionClient } from '~/lib/safe-action'
12
- import { confirmUpload, isR2Configured, isR2Key } from '~/lib/storage'
13
- import { stringValidation } from '~/lib/validations'
14
-
15
- export const updateOrganizationLogoAction = authActionClient
16
- .inputSchema(
17
- z.object({
18
- organizationId: stringValidation,
19
- key: z.string().min(1),
20
- oldKey: z.string().min(1).nullish()
21
- })
22
- )
23
- .action(async ({ parsedInput }) => {
24
- if (!isR2Configured() || !isR2Key(parsedInput.key)) {
25
- return { success: true }
26
- }
27
-
28
- await assertUserCanManageOrganization(parsedInput.organizationId)
29
-
30
- const org = await db.query.organizationsTable.findFirst({
31
- where: eq(organizationsTable.id, parsedInput.organizationId),
32
- columns: {
33
- id: true,
34
- logo: true
35
- }
36
- })
37
-
38
- if (!org) {
39
- throw new Error('Organization not found')
40
- }
41
-
42
- if (org.logo !== parsedInput.key) {
43
- throw new Error(
44
- 'Organization logo key does not match the current logo key'
45
- )
46
- }
47
-
48
- await confirmUpload(parsedInput.key, parsedInput.oldKey)
49
- return true
50
- })
51
-
52
- export const transferOrganizationOwnershipAction = authActionClient
53
- .inputSchema(
54
- z.object({
55
- organizationId: stringValidation,
56
- targetMemberId: stringValidation
57
- })
58
- )
59
- .action(async ({ parsedInput: { organizationId, targetMemberId } }) => {
60
- const membership = await getUserMembershipCached(organizationId)
61
- if (!membership || membership.role !== 'owner') {
62
- throw new Error('Only the organization owner can transfer ownership')
63
- }
64
-
65
- if (targetMemberId === membership.id) {
66
- throw new Error('You are already the owner of this organization')
67
- }
68
-
69
- const target = await db.query.membersTable.findFirst({
70
- where: and(
71
- eq(membersTable.id, targetMemberId),
72
- eq(membersTable.organizationId, organizationId)
73
- )
74
- })
75
- if (!target) {
76
- throw new Error('Selected member does not belong to this organization')
77
- }
78
-
79
- await db.transaction(async tx => {
80
- await tx
81
- .update(membersTable)
82
- .set({ role: 'owner' })
83
- .where(eq(membersTable.id, target.id))
84
-
85
- await tx
86
- .update(membersTable)
87
- .set({ role: 'admin' })
88
- .where(eq(membersTable.id, membership.id))
89
- })
90
-
91
- return true
92
- })
@@ -1,28 +0,0 @@
1
- import { NextResponse } from 'next/server'
2
-
3
- export function proxy() {
4
- return NextResponse.next()
5
- }
6
-
7
- export const config = {
8
- matcher: [
9
- {
10
- /**
11
- * Route matching pattern
12
- *
13
- * This regex pattern matches all routes EXCEPT:
14
- * - /_next/static - Next.js static assets
15
- * - /_next/image - Next.js image optimization
16
- * - /favicon.ico - Browser favicon
17
- * - /.well-known/workflow/ - Internal workflow paths
18
- *
19
- * Excluding these paths ensures that:
20
- * 1. Static assets load efficiently without middleware overhead
21
- * 2. Image optimization is not interrupted
22
- * 3. Workflow execution and resumption function correctly
23
- */
24
- source:
25
- '/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)'
26
- }
27
- ]
28
- }
@@ -1,44 +0,0 @@
1
- 'use client'
2
-
3
- import { UserX } from 'lucide-react'
4
- import { useRouter } from 'next/navigation'
5
- import { FC, useState } from 'react'
6
- import { Button } from '~/components/ui/button'
7
- import { admin } from '~/lib/auth-client'
8
-
9
- export const ImpersonationBanner: FC = () => {
10
- const router = useRouter()
11
- const [isStopping, setIsStopping] = useState(false)
12
-
13
- const handleStopImpersonation = async () => {
14
- try {
15
- setIsStopping(true)
16
- await admin.stopImpersonating()
17
- router.refresh()
18
- } catch (error) {
19
- console.error('Failed to stop impersonation:', error)
20
- setIsStopping(false)
21
- }
22
- }
23
-
24
- return (
25
- <div className="bg-primary text-primary-foreground w-full border-b text-sm">
26
- <div className="container flex min-h-10 items-center justify-between gap-4 py-2">
27
- <div className="flex items-center gap-2">
28
- <UserX className="size-4" />
29
- <span className="font-medium">Impersonating User</span>
30
- </div>
31
- <div className="flex items-center gap-3">
32
- <Button
33
- variant="ghost"
34
- size="sm"
35
- onClick={handleStopImpersonation}
36
- disabled={isStopping}
37
- >
38
- {isStopping ? 'Stopping...' : 'Stop Impersonation'}
39
- </Button>
40
- </div>
41
- </div>
42
- </div>
43
- )
44
- }
@@ -1,38 +0,0 @@
1
- import { eq } from 'drizzle-orm'
2
- import { cache } from 'react'
3
- import { db } from '~/db'
4
- import { organizationSubscriptionsTable } from '~/db/schema'
5
- import { PLANS } from './plans'
6
-
7
- export const getOrganizationPlan = async (organizationId: string) => {
8
- const sub = await db.query.organizationSubscriptionsTable.findFirst({
9
- where: eq(organizationSubscriptionsTable.organizationId, organizationId)
10
- })
11
-
12
- if (!sub) {
13
- throw new Error(
14
- `Organization: ${organizationId} does not have a subscription`
15
- )
16
- }
17
-
18
- const effectivePlan =
19
- sub.status === 'trialing' ||
20
- sub.status === 'active' ||
21
- sub.status === 'past_due'
22
- ? sub.plan
23
- : 'free'
24
-
25
- const plan = PLANS.find(plan => plan.id === effectivePlan)
26
- if (!plan) {
27
- throw new Error(
28
- `Organization: ${organizationId} does not have a valid subscription plan, ${effectivePlan}`
29
- )
30
- }
31
-
32
- return {
33
- plan,
34
- sub
35
- }
36
- }
37
-
38
- export const getOrganizationPlanCached = cache(getOrganizationPlan)