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,8 +1,14 @@
1
+ /**
2
+ * @fileoverview Shared email shell: a brand rule and name at the top, the
3
+ * content, and a copyright footer. Every template wraps its body in it.
4
+ *
5
+ * @module emails/components/email-layout
6
+ */
7
+
1
8
  import {
2
9
  Body,
3
10
  Container,
4
11
  Head,
5
- Hr,
6
12
  Html,
7
13
  pixelBasedPreset,
8
14
  Preview,
@@ -11,7 +17,11 @@ import {
11
17
  Text
12
18
  } from 'react-email'
13
19
 
20
+ /** Brand color. Email clients strip CSS variables, so it is a literal here. */
21
+ const BRAND = '#171717'
22
+
14
23
  interface EmailLayoutProps {
24
+ /** Text email clients show in the inbox before the email is opened. */
15
25
  previewText: string
16
26
  companyName: string
17
27
  children: React.ReactNode
@@ -28,31 +38,37 @@ export default function EmailLayout({
28
38
  <Preview>{previewText}</Preview>
29
39
  <Tailwind
30
40
  config={{
31
- presets: [pixelBasedPreset]
41
+ presets: [pixelBasedPreset],
42
+ theme: { extend: { colors: { brand: BRAND } } }
32
43
  }}
33
44
  >
34
45
  <Body className="bg-gray-100 font-sans">
35
46
  <Container className="mx-auto my-8 max-w-xl rounded-lg bg-white shadow-sm">
36
- {/* Header */}
37
- <Section className="rounded-t-lg bg-black px-8 py-6">
38
- <Text className="m-0 text-center text-2xl font-bold text-white">
47
+ <Section
48
+ className="rounded-t-lg bg-white px-8 py-5"
49
+ style={{
50
+ borderTop: `4px solid ${BRAND}`,
51
+ borderBottom: '1px solid #E5E7EB'
52
+ }}
53
+ >
54
+ <Text className="m-0 text-lg font-bold tracking-tight text-gray-900">
39
55
  {companyName}
40
56
  </Text>
41
57
  </Section>
42
58
 
43
- {/* Content */}
44
59
  <Section className="px-8 py-6">{children}</Section>
45
60
 
46
- {/* Footer */}
47
- <Section className="rounded-b-lg bg-gray-50 px-8 py-6">
48
- <Hr className="my-4 border-gray-200" />
61
+ <Section
62
+ className="rounded-b-lg bg-gray-50 px-8 py-6"
63
+ style={{ borderTop: '1px solid #E5E7EB' }}
64
+ >
49
65
  <Text className="m-0 text-center text-sm text-gray-500">
50
66
  &copy; {new Date().getUTCFullYear()} {companyName}. All rights
51
67
  reserved.
52
68
  </Text>
53
69
  <Text className="m-0 mt-2 text-center text-xs text-gray-400">
54
- This is an automated email. Please do not reply directly to this
55
- message.
70
+ This is an automated email. Replies to this address are not
71
+ read.
56
72
  </Text>
57
73
  </Section>
58
74
  </Container>
@@ -1,49 +1,51 @@
1
1
  import { Button, Heading, Text } from 'react-email'
2
- import EmailLayout from './components/email-layout'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { siteConfig } from '~/lib/siteConfig'
3
4
 
4
5
  export interface MagicLinkEmailProps {
5
6
  magicLink: string
6
7
  companyName: string
7
8
  }
8
9
 
10
+ /** Passwordless sign-in link. The link works once and expires in 5 minutes. */
9
11
  export default function MagicLinkEmail({
10
12
  magicLink,
11
13
  companyName
12
14
  }: MagicLinkEmailProps) {
13
- const previewText = `Your magic link to sign in to ${companyName}`
14
-
15
15
  return (
16
- <EmailLayout previewText={previewText} companyName={companyName}>
16
+ <EmailLayout
17
+ previewText={`Your link to sign in to ${companyName}`}
18
+ companyName={companyName}
19
+ >
17
20
  <Heading className="mb-4 text-2xl font-bold text-gray-900">
18
- Sign in to {companyName}
21
+ Sign In to {companyName}
19
22
  </Heading>
20
23
 
21
24
  <Text className="mb-4 text-base text-gray-700">
22
- Click the button below to securely sign in to your account. This link
23
- will expire in 5 minutes.
25
+ Select the button below to sign in. The link expires in 5 minutes.
24
26
  </Text>
25
27
 
26
28
  <Button
27
29
  href={magicLink}
28
- className="mb-4 rounded-lg bg-black px-6 py-3 text-center text-base font-semibold text-white no-underline"
30
+ className="bg-brand mb-4 rounded-lg px-6 py-3 text-center text-base font-semibold text-white no-underline"
29
31
  >
30
32
  Sign In
31
33
  </Button>
32
34
 
33
35
  <Text className="mb-4 text-sm text-gray-600">
34
- Or copy and paste this link into your browser:
36
+ Or copy this link into your browser:
35
37
  </Text>
36
38
 
37
- <Text className="mb-4 text-sm break-all text-blue-600">{magicLink}</Text>
39
+ <Text className="text-brand mb-4 text-sm break-all">{magicLink}</Text>
38
40
 
39
41
  <Text className="text-sm text-gray-500">
40
- If you didn't request this email, you can safely ignore it.
42
+ If you didn&apos;t ask to sign in, you can ignore this email.
41
43
  </Text>
42
44
  </EmailLayout>
43
45
  )
44
46
  }
45
47
 
46
48
  MagicLinkEmail.PreviewProps = {
47
- magicLink: 'https://example.com/magic-link?token=abc123',
48
- companyName: 'Example Site'
49
+ magicLink: 'https://example.com/magic-link?token=your_magic_link_token_here',
50
+ companyName: siteConfig.name
49
51
  } satisfies MagicLinkEmailProps
@@ -1,25 +1,27 @@
1
1
  import { Button, Heading, Text } from 'react-email'
2
- import EmailLayout from './components/email-layout'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { siteConfig } from '~/lib/siteConfig'
3
4
 
4
5
  interface TestEmailProps {
5
6
  name: string
6
7
  }
7
8
 
9
+ /** Sent by `bun run email:test` to check SMTP settings. */
8
10
  export default function TestEmail({ name }: TestEmailProps) {
9
11
  return (
10
- <EmailLayout previewText="This is a test email" companyName="Test">
12
+ <EmailLayout
13
+ previewText="Test email from your app"
14
+ companyName={siteConfig.name}
15
+ >
11
16
  <Heading className="mb-4 text-2xl font-bold text-gray-900">
12
17
  Test Email for {name}
13
18
  </Heading>
14
- <Text className="mb-4 text-base text-gray-700">
15
- This is a test email template to verify email functionality.
16
- </Text>
17
19
  <Text className="mb-6 text-base text-gray-700">
18
- If you're seeing this, the email system is working correctly.
20
+ Your SMTP settings work: this email reached your inbox.
19
21
  </Text>
20
22
  <Button
21
23
  href="https://example.com"
22
- className="rounded-md bg-black px-5 py-3 text-center text-sm font-medium text-white"
24
+ className="bg-brand rounded-md px-5 py-3 text-center text-sm font-medium text-white"
23
25
  >
24
26
  Test Button
25
27
  </Button>
@@ -28,5 +30,5 @@ export default function TestEmail({ name }: TestEmailProps) {
28
30
  }
29
31
 
30
32
  TestEmail.PreviewProps = {
31
- name: 'John Doe'
32
- }
33
+ name: 'Alex Morgan'
34
+ } satisfies TestEmailProps
@@ -1,34 +1,37 @@
1
1
  import { Heading, Text } from 'react-email'
2
- import EmailLayout from './components/email-layout'
2
+ import EmailLayout from '~/emails/components/email-layout'
3
+ import { siteConfig } from '~/lib/siteConfig'
3
4
 
4
5
  export interface WelcomeEmailProps {
5
6
  userName: string
6
7
  companyName: string
7
8
  }
8
9
 
10
+ /** Sent once, right after an account is created. */
9
11
  export default function WelcomeEmail({
10
12
  userName,
11
13
  companyName
12
14
  }: WelcomeEmailProps) {
13
- const previewText = `Welcome to ${companyName}! Your account has been created successfully.`
14
-
15
15
  return (
16
- <EmailLayout previewText={previewText} companyName={companyName}>
16
+ <EmailLayout
17
+ previewText={`Welcome to ${companyName}. Your account is ready.`}
18
+ companyName={companyName}
19
+ >
17
20
  <Heading className="mb-4 text-2xl font-bold text-gray-900">
18
- Welcome to {companyName}! 🎉
21
+ Welcome to {companyName}
19
22
  </Heading>
20
23
 
21
24
  <Text className="mb-4 text-base text-gray-700">Hi {userName},</Text>
22
25
 
23
26
  <Text className="mb-4 text-base text-gray-700">
24
- Thank you for creating an account with us. We're thrilled to have you as
25
- part of our community!
27
+ Your account is ready. Sign in any time with the email address this
28
+ message was sent to.
26
29
  </Text>
27
30
  </EmailLayout>
28
31
  )
29
32
  }
30
33
 
31
34
  WelcomeEmail.PreviewProps = {
32
- userName: 'John Doe',
33
- companyName: 'Example Site'
35
+ userName: 'Alex Morgan',
36
+ companyName: siteConfig.name
34
37
  } satisfies WelcomeEmailProps
@@ -15,7 +15,7 @@ export const env = createEnv({
15
15
  EMAIL_SERVER_USER: z.string(),
16
16
  EMAIL_SERVER_PASSWORD: z.string(),
17
17
  EMAIL_SERVER_HOST: z.string(),
18
- EMAIL_SERVER_PORT: z.string(),
18
+ EMAIL_SERVER_PORT: z.coerce.number().int().min(1).max(65535),
19
19
  EMAIL_FROM: z.email()
20
20
  },
21
21
 
@@ -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
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Reusable Zod fragments for paginated, sortable list actions.
3
+ */
4
+ import { z } from 'zod'
5
+ import { SortOrderEnum } from '~/components/data-table/enum'
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
+ */
24
+ export const getPaginationSchema = (defaultPageSize = 10) => ({
25
+ page: z.coerce.number().int().positive().optional().default(1),
26
+ limit: z.coerce
27
+ .number()
28
+ .int()
29
+ .positive()
30
+ .min(10)
31
+ .max(100)
32
+ .optional()
33
+ .default(defaultPageSize)
34
+ })
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
+ */
50
+ export const getSortSchema = <T extends string>(
51
+ keys: readonly [T, ...T[]]
52
+ ) => ({
53
+ sortOrder: z.enum(SortOrderEnum).optional(),
54
+ sortBy: z.enum(keys).optional()
55
+ })
@@ -0,0 +1,43 @@
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, Users } from 'lucide-react'
9
+ import { SidebarNavGroup } from '~/components/navigation-sidebar'
10
+
11
+ const adminNavigation: SidebarNavGroup[] = [
12
+ {
13
+ label: 'Overview',
14
+ items: [
15
+ {
16
+ title: 'Dashboard',
17
+ url: '/admin',
18
+ exact: true,
19
+ icon: Home
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ label: 'Settings',
25
+ superadminOnly: true,
26
+ items: [
27
+ {
28
+ title: 'Users & Roles',
29
+ url: '/admin/settings/users',
30
+ icon: Users
31
+ }
32
+ ]
33
+ }
34
+ ]
35
+
36
+ /**
37
+ * The admin navigation visible to the current user. Superadmin-only groups
38
+ * are hidden from `admin` users.
39
+ */
40
+ export const getAdminNavigation = (isSuperAdmin: boolean): SidebarNavGroup[] =>
41
+ isSuperAdmin
42
+ ? adminNavigation
43
+ : adminNavigation.filter(group => !group.superadminOnly)
@@ -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,
@@ -8,13 +12,20 @@ import {
8
12
  } from 'better-auth/client/plugins'
9
13
  import { createAuthClient } from 'better-auth/react'
10
14
  import type { auth } from './auth'
15
+ import { ac, roles } from './auth-permissions'
11
16
 
12
- export const { signIn, signOut, useSession, getLastUsedLoginMethod } =
13
- createAuthClient({
14
- plugins: [
15
- inferAdditionalFields<typeof auth>(),
16
- magicLinkClient(),
17
- adminClient(),
18
- lastLoginMethodClient()
19
- ]
20
- })
17
+ export const {
18
+ signIn,
19
+ signOut,
20
+ useSession,
21
+ updateUser,
22
+ getLastUsedLoginMethod,
23
+ admin
24
+ } = createAuthClient({
25
+ plugins: [
26
+ inferAdditionalFields<typeof auth>(),
27
+ magicLinkClient(),
28
+ adminClient({ ac, roles }),
29
+ lastLoginMethodClient()
30
+ ]
31
+ })
@@ -0,0 +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 the pages you add
10
+ * there, but is intentionally granted no user-management or impersonation
11
+ * 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
+
20
+ import { createAccessControl } from 'better-auth/plugins/access'
21
+ import { defaultStatements } from 'better-auth/plugins/admin/access'
22
+
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
30
+
31
+ export const ac = createAccessControl(statement)
32
+
33
+ /** Regular customer with no admin-panel privileges. */
34
+ export const user = ac.newRole({})
35
+
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({})
42
+
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]
55
+ })
56
+
57
+ /** Role map passed to the admin plugin on both server and client. */
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'
@@ -15,9 +17,18 @@ import {
15
17
  verificationsTable
16
18
  } from '~/db/schema'
17
19
  import { env } from '~/env'
20
+ import { ac, adminRoles, roles } from './auth-permissions'
18
21
  import { sendMagicLinkEmail, sendWelcomeEmail } from './email-service'
19
22
  import { siteConfig } from './siteConfig'
20
23
 
24
+ /**
25
+ * Better Auth configuration.
26
+ *
27
+ * Features:
28
+ * - Magic link email sign-in
29
+ * - OAuth (GitHub, Google) with account linking
30
+ * - Admin plugin with `user`, `admin`, and `superadmin` roles
31
+ */
21
32
  export const auth = betterAuth({
22
33
  database: drizzleAdapter(db, {
23
34
  provider: 'pg',
@@ -53,6 +64,11 @@ export const auth = betterAuth({
53
64
  databaseHooks: {
54
65
  user: {
55
66
  create: {
67
+ // Magic-link sign-up sends an empty name; fall back to the email's
68
+ // local part so greetings and avatars never render blank.
69
+ before: async user => ({
70
+ data: { ...user, name: user.name.trim() || user.email.split('@')[0]! }
71
+ }),
56
72
  after: async user => {
57
73
  try {
58
74
  await start(sendWelcomeEmail, [
@@ -85,7 +101,12 @@ export const auth = betterAuth({
85
101
  }
86
102
  }
87
103
  }),
88
- admin(),
104
+ admin({
105
+ ac,
106
+ roles,
107
+ adminRoles: [...adminRoles],
108
+ defaultRole: 'user'
109
+ }),
89
110
  lastLoginMethod()
90
111
  ],
91
112
  hooks: {
@@ -109,6 +130,13 @@ export const auth = betterAuth({
109
130
  }
110
131
  })
111
132
 
133
+ /**
134
+ * The current session, cached per request.
135
+ *
136
+ * Returns null when signed out. `isAdmin` is true for both `admin` and
137
+ * `superadmin` (admin panel access). `isSuperAdmin` is true only for
138
+ * `superadmin` (user management, role changes, and impersonation).
139
+ */
112
140
  export const getAuthSession = cache(async () => {
113
141
  const authSession = await auth.api.getSession({
114
142
  headers: await headers()
@@ -116,7 +144,9 @@ export const getAuthSession = cache(async () => {
116
144
 
117
145
  if (!authSession || !authSession.user || !authSession.session) return null
118
146
 
119
- const isAdmin = authSession.user.role === 'admin'
147
+ const role = authSession.user.role
148
+ const isSuperAdmin = role === 'superadmin'
149
+ const isAdmin = role === 'admin' || isSuperAdmin
120
150
 
121
- return { ...authSession, isAdmin }
151
+ return { ...authSession, isAdmin, isSuperAdmin }
122
152
  })
@@ -0,0 +1,28 @@
1
+ /** Origin used only to resolve a candidate callback and check where it lands. */
2
+ const RESOLUTION_ORIGIN = 'https://callback.invalid'
3
+
4
+ /**
5
+ * Narrow a caller-supplied `?callbackUrl=` down to a same-origin path.
6
+ *
7
+ * Returns the normalised path, or null when the value could leave the site.
8
+ * A leading `/` is not enough on its own: `//evil.com`, `/\evil.com`, and
9
+ * `/<tab>/evil.com` all resolve cross-origin in a browser, the last because
10
+ * URL parsing strips tabs and newlines first. The shape checks below reject
11
+ * the known forms, and resolving against a throwaway origin catches the rest.
12
+ */
13
+ export function sanitizeCallbackUrl(
14
+ value: string | string[] | undefined
15
+ ): string | null {
16
+ if (typeof value !== 'string') return null
17
+ if (!/^\/(?![/\\])/.test(value)) return null
18
+ if (/[\t\n\r]/.test(value)) return null
19
+
20
+ try {
21
+ const resolved = new URL(value, RESOLUTION_ORIGIN)
22
+ if (resolved.origin !== RESOLUTION_ORIGIN) return null
23
+
24
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`
25
+ } catch {
26
+ return null
27
+ }
28
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @fileoverview Application-wide constants for configuration and validation.
3
+ *
4
+ * Read limits, labels, and repeated values from here in the UI, validation,
5
+ * tests, and docs, so one change updates every place.
6
+ *
7
+ * @module lib/constants
8
+ */
9
+
10
+ // ============================================
11
+ // REQUEST ARRAY BOUNDS
12
+ // ============================================
13
+
14
+ /*
15
+ * Ceilings for arrays that reach SQL as an `IN (…)` list. Postgres caps a
16
+ * statement at 65535 bind parameters, and an unbounded array also builds in
17
+ * server memory first.
18
+ */
19
+
20
+ /** Values accepted in one list-filter array, such as status facets */
21
+ export const MAX_FILTER_VALUES = 100
@@ -1,2 +1,12 @@
1
+ /**
2
+ * Creates a promise that resolves after a specified delay
3
+ * Useful for adding delays in async functions or testing
4
+ *
5
+ * @param ms - Milliseconds to delay
6
+ * @returns Promise that resolves after the delay
7
+ *
8
+ * @example
9
+ * await delay(1000) // Wait 1 second
10
+ */
1
11
  export const delay = (ms: number) =>
2
12
  new Promise(resolve => setTimeout(resolve, ms))