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
@@ -2,18 +2,20 @@ import * as React from 'react'
2
2
 
3
3
  const MOBILE_BREAKPOINT = 768
4
4
 
5
- export function useIsMobile() {
6
- const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
5
+ function subscribe(onStoreChange: () => void) {
6
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
7
+ mql.addEventListener('change', onStoreChange)
8
+ return () => mql.removeEventListener('change', onStoreChange)
9
+ }
10
+
11
+ function getSnapshot() {
12
+ return window.innerWidth < MOBILE_BREAKPOINT
13
+ }
7
14
 
8
- React.useEffect(() => {
9
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
- const onChange = () => {
11
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
- }
13
- mql.addEventListener('change', onChange)
14
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
- return () => mql.removeEventListener('change', onChange)
16
- }, [])
15
+ function getServerSnapshot() {
16
+ return false
17
+ }
17
18
 
18
- return !!isMobile
19
+ export function useIsMobile() {
20
+ return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
19
21
  }
@@ -5,12 +5,25 @@ import {
5
5
  SimpleSpanProcessor
6
6
  } from '@opentelemetry/sdk-trace-base'
7
7
  import { registerOTel } from '@vercel/otel'
8
+ import { siteConfig } from '~/lib/siteConfig'
8
9
 
10
+ /**
11
+ * Custom SpanProcessor that samples spans to reduce telemetry volume.
12
+ * Exports every error span, exports every span slower than 5s.
13
+ */
9
14
  class SmartSamplingSpanProcessor extends SimpleSpanProcessor {
15
+ /**
16
+ * Called when a span ends. Evaluates whether the span should be exported based on its properties.
17
+ *
18
+ * @param span - The readable span that has just ended
19
+ */
10
20
  onEnd(span: ReadableSpan): void {
11
21
  const isError = span.status.code === SpanStatusCode.ERROR
22
+ // span.duration is [seconds, nanoseconds]
23
+ // Spans slower than 5s
12
24
  const isSlow = span.duration[0] * 1000 + span.duration[1] / 1e6 > 5000
13
- const isLucky = Math.random() < 0 // Adjust this value to adjust the sampling rate for non-error and non-slow spans
25
+ // Random sampling: Adjust probability as needed (e.g. 0.1 for 10%)
26
+ const isLucky = Math.random() < 0 // Currently disabled for non-essential traces
14
27
 
15
28
  if (isError || isSlow || isLucky) {
16
29
  super.onEnd(span)
@@ -18,9 +31,14 @@ class SmartSamplingSpanProcessor extends SimpleSpanProcessor {
18
31
  }
19
32
  }
20
33
 
34
+ /**
35
+ * Registers OpenTelemetry instrumentation for the application.
36
+ * This function should be exported as `register` from `src/instrumentation.ts`
37
+ * to be automatically picked up by Next.js.
38
+ */
21
39
  export function register() {
22
40
  registerOTel({
23
- serviceName: 'my-nextjs-app',
41
+ serviceName: siteConfig.name,
24
42
  spanProcessors: [new SmartSamplingSpanProcessor(new ConsoleSpanExporter())]
25
43
  })
26
44
  }
@@ -1,6 +1,26 @@
1
+ /**
2
+ * Reusable Zod fragments for paginated, sortable list actions.
3
+ */
1
4
  import { z } from 'zod'
2
5
  import { SortOrderEnum } from '~/components/data-table/enum'
3
6
 
7
+ /**
8
+ * Returns a reusable Zod schema fragment for cursor-less offset pagination.
9
+ *
10
+ * - `page`: 1-based page number (defaults to 1)
11
+ * - `limit`: Number of items per page; clamped between 10 and 100 (defaults to `defaultPageSize`)
12
+ *
13
+ * Spread the returned object into a `z.object({})` call to add pagination
14
+ * to any query schema.
15
+ *
16
+ * @param defaultPageSize - Default number of items per page (defaults to 10)
17
+ *
18
+ * @example
19
+ * z.object({
20
+ * ...getPaginationSchema(20),
21
+ * ...getSortSchema(['title', 'createdAt'])
22
+ * })
23
+ */
4
24
  export const getPaginationSchema = (defaultPageSize = 10) => ({
5
25
  page: z.coerce.number().int().positive().optional().default(1),
6
26
  limit: z.coerce
@@ -13,6 +33,20 @@ export const getPaginationSchema = (defaultPageSize = 10) => ({
13
33
  .default(defaultPageSize)
14
34
  })
15
35
 
36
+ /**
37
+ * Generates a reusable Zod schema fragment for sort parameters.
38
+ *
39
+ * @param keys - A non-empty tuple of the allowed `sortBy` field names for
40
+ * the resource (e.g. `['title', 'createdAt', 'updatedAt']`).
41
+ * @returns An object with `sortOrder` and `sortBy` Zod validators that can
42
+ * be spread into a `z.object({})` query schema.
43
+ *
44
+ * @example
45
+ * z.object({
46
+ * ...paginationSchema,
47
+ * ...getSortSchema(['title', 'createdAt', 'updatedAt'])
48
+ * })
49
+ */
16
50
  export const getSortSchema = <T extends string>(
17
51
  keys: readonly [T, ...T[]]
18
52
  ) => ({
@@ -1,4 +1,11 @@
1
- import { Home, Users } from 'lucide-react'
1
+ /**
2
+ * @fileoverview Admin panel navigation, shared by the sidebar and the
3
+ * command search.
4
+ *
5
+ * @module lib/admin-navigation
6
+ */
7
+
8
+ import { Home, MessageCircle, MessageSquare, Ticket, Users } from 'lucide-react'
2
9
  import { SidebarNavGroup } from '~/components/navigation-sidebar'
3
10
 
4
11
  const adminNavigation: SidebarNavGroup[] = [
@@ -13,8 +20,29 @@ const adminNavigation: SidebarNavGroup[] = [
13
20
  }
14
21
  ]
15
22
  },
23
+ {
24
+ label: 'Customers',
25
+ items: [
26
+ {
27
+ title: 'Support Tickets',
28
+ url: '/admin/support-tickets',
29
+ icon: Ticket
30
+ },
31
+ {
32
+ title: 'Feedback',
33
+ url: '/admin/feedbacks',
34
+ icon: MessageCircle
35
+ },
36
+ {
37
+ title: 'Contact Leads',
38
+ url: '/admin/contact-leads',
39
+ icon: MessageSquare
40
+ }
41
+ ]
42
+ },
16
43
  {
17
44
  label: 'Settings',
45
+ superadminOnly: true,
18
46
  items: [
19
47
  {
20
48
  title: 'Users & Roles',
@@ -25,6 +53,10 @@ const adminNavigation: SidebarNavGroup[] = [
25
53
  }
26
54
  ]
27
55
 
56
+ /**
57
+ * The admin navigation visible to the current user. Superadmin-only groups
58
+ * are hidden from `admin` users.
59
+ */
28
60
  export const getAdminNavigation = (isSuperAdmin: boolean): SidebarNavGroup[] =>
29
61
  isSuperAdmin
30
62
  ? adminNavigation
@@ -1,58 +1,62 @@
1
- import { CreditCard, Home, Settings, Users } from 'lucide-react'
1
+ import { CreditCard, Home, Settings, Ticket, Users } from 'lucide-react'
2
2
  import { SidebarNavGroup } from '~/components/navigation-sidebar'
3
+ import { isManagerRole } from './rbac'
3
4
 
4
- export type UserOrganization = {
5
- id: string
6
- name: string
7
- slug: string
8
- logo: string | null
5
+ /**
6
+ * Sidebar and command-search entries for one organization. Managers (owner
7
+ * and admin) also get support and settings pages.
8
+ */
9
+ export function getAppNavigation(
10
+ organizationId: string,
9
11
  role: string
10
- userId: string
11
- memberId: string
12
- }
13
-
14
- export const canManageOrganization = (role: string): boolean =>
15
- role === 'owner' || role === 'admin'
16
-
17
- export const getAppNavigation = (
18
- orgId: string,
19
- role: string
20
- ): SidebarNavGroup[] => {
12
+ ): SidebarNavGroup[] {
21
13
  const groups: SidebarNavGroup[] = [
22
14
  {
23
- label: 'Overview',
15
+ label: 'Workspace',
24
16
  items: [
25
17
  {
26
18
  title: 'Dashboard',
27
- url: `/app/${orgId}/dashboard`,
19
+ url: `/app/${organizationId}/dashboard`,
28
20
  icon: Home
29
21
  }
30
22
  ]
31
23
  }
32
24
  ]
33
25
 
34
- if (canManageOrganization(role)) {
35
- groups.push({
36
- label: 'Settings',
37
- items: [
38
- {
39
- title: 'General',
40
- url: `/app/${orgId}/settings`,
41
- icon: Settings,
42
- exact: true
43
- },
44
- {
45
- title: 'Members',
46
- url: `/app/${orgId}/settings/members`,
47
- icon: Users
48
- },
49
- {
50
- title: 'Billing',
51
- url: `/app/${orgId}/settings/billing`,
52
- icon: CreditCard
53
- }
54
- ]
55
- })
26
+ if (isManagerRole(role)) {
27
+ groups.push(
28
+ {
29
+ label: 'Help',
30
+ items: [
31
+ {
32
+ title: 'Support',
33
+ url: `/app/${organizationId}/support-tickets`,
34
+ icon: Ticket
35
+ }
36
+ ]
37
+ },
38
+ {
39
+ label: 'Settings',
40
+ items: [
41
+ {
42
+ title: 'General',
43
+ url: `/app/${organizationId}/settings`,
44
+ icon: Settings,
45
+ exact: true
46
+ },
47
+ {
48
+ title: 'Members',
49
+ url: `/app/${organizationId}/settings/members`,
50
+ icon: Users
51
+ },
52
+ {
53
+ title: 'Billing',
54
+ url: `/app/${organizationId}/settings/billing`,
55
+ icon: CreditCard
56
+ }
57
+ ]
58
+ }
59
+ )
56
60
  }
57
61
 
58
62
  return groups
@@ -1,5 +1,9 @@
1
1
  'use client'
2
2
 
3
+ /**
4
+ * Browser auth client. The admin plugin gets the same access control as the
5
+ * server so `admin.hasPermission` checks agree on both sides.
6
+ */
3
7
  import {
4
8
  adminClient,
5
9
  inferAdditionalFields,
@@ -9,20 +13,21 @@ import {
9
13
  } from 'better-auth/client/plugins'
10
14
  import { createAuthClient } from 'better-auth/react'
11
15
  import type { auth } from './auth'
16
+ import { ac, roles } from './auth-permissions'
12
17
 
13
18
  export const {
14
19
  signIn,
15
20
  signOut,
16
21
  useSession,
22
+ updateUser,
17
23
  getLastUsedLoginMethod,
18
24
  admin,
19
- organization,
20
- useListOrganizations
25
+ organization
21
26
  } = createAuthClient({
22
27
  plugins: [
23
28
  inferAdditionalFields<typeof auth>(),
24
29
  magicLinkClient(),
25
- adminClient(),
30
+ adminClient({ ac, roles }),
26
31
  organizationClient(),
27
32
  lastLoginMethodClient()
28
33
  ]
@@ -1,22 +1,61 @@
1
+ /**
2
+ * @fileoverview Access-control definitions for the Better Auth admin plugin.
3
+ *
4
+ * The platform has three roles:
5
+ *
6
+ * - `superadmin`: full control of the admin panel. Can list, view, create,
7
+ * update, change the role of, ban and impersonate any user (including other
8
+ * admins and superadmins).
9
+ * - `admin`: can open the admin panel and work with customer-facing data
10
+ * (support tickets, feedback, and contact leads), but is intentionally
11
+ * granted no user-management or impersonation permissions.
12
+ * - `user`: a regular customer with no admin-panel access
13
+ *
14
+ * These statements/roles are shared by both the server (`auth.ts`) and the
15
+ * client (`auth-client.ts`) so permission checks stay consistent everywhere.
16
+ *
17
+ * @module lib/auth-permissions
18
+ */
19
+
1
20
  import { createAccessControl } from 'better-auth/plugins/access'
2
21
  import { defaultStatements } from 'better-auth/plugins/admin/access'
3
22
 
4
- const statement = {
5
- ...defaultStatements
6
- } as const
23
+ /**
24
+ * The full set of permissions available across the admin panel.
25
+ *
26
+ * `defaultStatements` contributes the admin plugin's built-in `user` and
27
+ * `session` resources.
28
+ */
29
+ export const statement = { ...defaultStatements } as const
7
30
 
8
31
  export const ac = createAccessControl(statement)
9
32
 
10
- const user = ac.newRole({})
33
+ /** Regular customer with no admin-panel privileges. */
34
+ export const user = ac.newRole({})
11
35
 
12
- const admin = ac.newRole({
13
- user: ['list', 'ban', 'impersonate', 'set-password', 'get', 'update'],
14
- session: defaultStatements.session
15
- })
36
+ /**
37
+ * Operational admin who can open the admin panel but has no
38
+ * `user` or `session` permissions. User management and impersonation are
39
+ * therefore denied.
40
+ */
41
+ export const admin = ac.newRole({})
16
42
 
17
- const superadmin = ac.newRole({
18
- user: defaultStatements.user,
19
- session: defaultStatements.session
43
+ /**
44
+ * Full-control admin who receives every built-in user/session permission,
45
+ * including `impersonate-admins`.
46
+ *
47
+ * Each resource is granted explicitly rather than spreading
48
+ * `defaultStatements` (which is the statement *definition*, not a grant).
49
+ * This way a newly added resource is denied by default until it is
50
+ * deliberately listed here.
51
+ */
52
+ export const superadmin = ac.newRole({
53
+ user: [...defaultStatements.user],
54
+ session: [...defaultStatements.session]
20
55
  })
21
56
 
57
+ /** Role map passed to the admin plugin on both server and client. */
22
58
  export const roles = { user, admin, superadmin }
59
+
60
+ /** Roles that are allowed to reach the admin panel at all. */
61
+ export const adminRoles = ['admin', 'superadmin'] as const
@@ -1,3 +1,5 @@
1
+ import 'server-only'
2
+
1
3
  import { validateEmail } from '@ejekanshjain/simple-email-validator'
2
4
  import { createId } from '@paralleldrive/cuid2'
3
5
  import { APIError, betterAuth } from 'better-auth'
@@ -9,7 +11,8 @@ import {
9
11
  magicLink,
10
12
  organization
11
13
  } from 'better-auth/plugins'
12
- import { eq } from 'drizzle-orm'
14
+ import { and, count, eq, isNull, ne } from 'drizzle-orm'
15
+ import { alias } from 'drizzle-orm/pg-core'
13
16
  import { headers } from 'next/headers'
14
17
  import { cache } from 'react'
15
18
  import { start } from 'workflow/api'
@@ -18,23 +21,77 @@ import {
18
21
  accountsTable,
19
22
  invitationsTable,
20
23
  membersTable,
21
- organizationsTable,
22
24
  organizationSubscriptionsTable,
25
+ organizationsTable,
23
26
  sessionsTable,
24
27
  usersTable,
25
28
  verificationsTable
26
29
  } from '~/db/schema'
27
30
  import { env } from '~/env'
28
- import { ac, roles } from './auth-permissions'
31
+ import { ac, adminRoles, roles } from './auth-permissions'
32
+ import {
33
+ INVITATION_EXPIRES_IN_DAYS,
34
+ MAX_ORGANIZATIONS_PER_USER
35
+ } from './constants'
29
36
  import {
30
37
  sendInvitationEmail,
31
38
  sendMagicLinkEmail,
32
39
  sendWelcomeEmail
33
40
  } from './email-service'
34
- import { getOrganizationPlanCached } from './organization-plan-limits'
41
+ import { getOrganizationPlan } from './plan-limits'
35
42
  import { siteConfig } from './siteConfig'
43
+ import { deleteOrganizationUploads } from './storage'
36
44
  import { stripeClient } from './stripe'
37
45
 
46
+ /**
47
+ * Blocks organization logos set through the Better Auth client. Logos go
48
+ * through `updateOrganizationAction`, which checks the upload belongs to the
49
+ * organization before saving it.
50
+ */
51
+ function rejectClientLogo(logo: string | null | undefined) {
52
+ if (logo) {
53
+ throw new APIError('BAD_REQUEST', {
54
+ message: 'Change the logo from the organization settings page.'
55
+ })
56
+ }
57
+ }
58
+
59
+ /** True when `userId` is the only owner of at least one organization. */
60
+ async function ownsOrganizationAlone(userId: string): Promise<boolean> {
61
+ const otherOwners = alias(membersTable, 'other_owners')
62
+
63
+ const [row] = await db
64
+ .select({ organizationId: membersTable.organizationId })
65
+ .from(membersTable)
66
+ .leftJoin(
67
+ otherOwners,
68
+ and(
69
+ eq(otherOwners.organizationId, membersTable.organizationId),
70
+ eq(otherOwners.role, 'owner'),
71
+ ne(otherOwners.userId, userId)
72
+ )
73
+ )
74
+ .where(
75
+ and(
76
+ eq(membersTable.userId, userId),
77
+ eq(membersTable.role, 'owner'),
78
+ isNull(otherOwners.id)
79
+ )
80
+ )
81
+ .limit(1)
82
+
83
+ return Boolean(row)
84
+ }
85
+
86
+ /**
87
+ * Better Auth configuration.
88
+ *
89
+ * Features:
90
+ * - Magic link email sign-in
91
+ * - OAuth (GitHub, Google) with account linking
92
+ * - Admin plugin with `user`, `admin`, and `superadmin` roles
93
+ * - Organization plugin with owner, admin, and member roles
94
+ */
38
95
  export const auth = betterAuth({
39
96
  database: drizzleAdapter(db, {
40
97
  provider: 'pg',
@@ -73,6 +130,11 @@ export const auth = betterAuth({
73
130
  databaseHooks: {
74
131
  user: {
75
132
  create: {
133
+ // Magic-link sign-up sends an empty name; fall back to the email's
134
+ // local part so greetings and avatars never render blank.
135
+ before: async user => ({
136
+ data: { ...user, name: user.name.trim() || user.email.split('@')[0]! }
137
+ }),
76
138
  after: async user => {
77
139
  try {
78
140
  await start(sendWelcomeEmail, [
@@ -86,6 +148,16 @@ export const auth = betterAuth({
86
148
  console.error('Failed to send welcome email:', error)
87
149
  }
88
150
  }
151
+ },
152
+ delete: {
153
+ before: async user => {
154
+ if (await ownsOrganizationAlone(user.id)) {
155
+ throw new APIError('BAD_REQUEST', {
156
+ message:
157
+ 'This user is the only owner of an organization. Transfer ownership or delete the organization first.'
158
+ })
159
+ }
160
+ }
89
161
  }
90
162
  }
91
163
  },
@@ -107,20 +179,29 @@ export const auth = betterAuth({
107
179
  }),
108
180
  admin({
109
181
  ac,
110
- roles
182
+ roles,
183
+ adminRoles: [...adminRoles],
184
+ defaultRole: 'user'
111
185
  }),
112
186
  organization({
113
- organizationLimit: 10,
114
- invitationExpiresIn: 7 * 24 * 60 * 60, // 7 days
187
+ organizationLimit: MAX_ORGANIZATIONS_PER_USER,
188
+ invitationExpiresIn: INVITATION_EXPIRES_IN_DAYS * 24 * 60 * 60,
115
189
  cancelPendingInvitationsOnReInvite: true,
116
- sendInvitationEmail: async ({ id, email, organization, inviter }) => {
190
+ sendInvitationEmail: async ({
191
+ id,
192
+ email,
193
+ role,
194
+ organization,
195
+ inviter
196
+ }) => {
117
197
  try {
118
198
  await start(sendInvitationEmail, [
119
199
  email,
120
200
  {
121
201
  inviterName: inviter.user.name,
122
202
  organizationName: organization.name,
123
- inviteLink: `${env.BETTER_AUTH_URL}/accept-invitation/${id}`,
203
+ inviteUrl: `${env.BETTER_AUTH_URL}/accept-invitation/${id}`,
204
+ role,
124
205
  companyName: siteConfig.name
125
206
  }
126
207
  ])
@@ -129,17 +210,28 @@ export const auth = betterAuth({
129
210
  }
130
211
  },
131
212
  organizationHooks: {
213
+ beforeCreateOrganization: async ({ organization }) => {
214
+ rejectClientLogo(organization.logo)
215
+ },
132
216
  afterCreateOrganization: async ({ organization }) => {
133
217
  await db.insert(organizationSubscriptionsTable).values({
134
218
  organizationId: organization.id
135
219
  })
136
220
  },
221
+ beforeUpdateOrganization: async ({ organization }) => {
222
+ rejectClientLogo(organization.logo)
223
+ },
224
+ // Files go first: if R2 fails, the organization and its paid plan
225
+ // both stay intact. Stripe is cancelled only once nothing else can fail.
137
226
  beforeDeleteOrganization: async ({ organization }) => {
227
+ await deleteOrganizationUploads(organization.id)
228
+
138
229
  const sub = await db.query.organizationSubscriptionsTable.findFirst({
139
230
  where: eq(
140
231
  organizationSubscriptionsTable.organizationId,
141
232
  organization.id
142
- )
233
+ ),
234
+ columns: { stripeSubscriptionId: true }
143
235
  })
144
236
 
145
237
  if (sub?.stripeSubscriptionId) {
@@ -147,29 +239,31 @@ export const auth = betterAuth({
147
239
  await stripeClient.subscriptions.cancel(sub.stripeSubscriptionId)
148
240
  } catch (error) {
149
241
  console.error(
150
- `Failed to cancel Stripe subscription ${sub.stripeSubscriptionId} for org ${organization.id} on org delete:`,
242
+ `Failed to cancel Stripe subscription ${sub.stripeSubscriptionId} while deleting organization ${organization.id}:`,
151
243
  error
152
244
  )
153
245
  throw new APIError('BAD_REQUEST', {
154
246
  message:
155
- 'Could not cancel the active subscription. Please try again later.'
247
+ 'We could not cancel the active subscription, so the organization was kept. Try again in a few minutes.'
156
248
  })
157
249
  }
158
250
  }
159
-
160
- await db
161
- .delete(organizationSubscriptionsTable)
162
- .where(
163
- eq(organizationSubscriptionsTable.organizationId, organization.id)
164
- )
165
251
  }
166
252
  },
253
+ // Better Auth caps pending invitations at this number, so leave room
254
+ // only for the seats current members do not fill.
167
255
  invitationLimit: async ({ organization }) => {
168
- const { plan } = await getOrganizationPlanCached(organization.id)
169
- return plan.maxMembers
256
+ const [{ plan }, [row]] = await Promise.all([
257
+ getOrganizationPlan(organization.id),
258
+ db
259
+ .select({ count: count() })
260
+ .from(membersTable)
261
+ .where(eq(membersTable.organizationId, organization.id))
262
+ ])
263
+ return Math.max(0, plan.maxMembers - (row?.count ?? 0))
170
264
  },
171
265
  membershipLimit: async (_, organization) => {
172
- const { plan } = await getOrganizationPlanCached(organization.id)
266
+ const { plan } = await getOrganizationPlan(organization.id)
173
267
  return plan.maxMembers
174
268
  }
175
269
  }),
@@ -196,6 +290,13 @@ export const auth = betterAuth({
196
290
  }
197
291
  })
198
292
 
293
+ /**
294
+ * The current session, cached per request.
295
+ *
296
+ * Returns null when signed out. `isAdmin` is true for both `admin` and
297
+ * `superadmin` (admin panel access). `isSuperAdmin` is true only for
298
+ * `superadmin` (user management, role changes, and impersonation).
299
+ */
199
300
  export const getAuthSession = cache(async () => {
200
301
  const authSession = await auth.api.getSession({
201
302
  headers: await headers()