create-ej-app 25.0.0 → 27.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 (385) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +83 -28
  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 -0
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -40
  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 +8 -0
  140. package/template/saas-starter/.env.example +25 -0
  141. package/template/saas-starter/.vscode/extensions.json +11 -0
  142. package/template/saas-starter/.vscode/settings.json +3 -0
  143. package/template/saas-starter/AGENTS.md +302 -0
  144. package/template/saas-starter/CLAUDE.md +1 -0
  145. package/template/saas-starter/Dockerfile +15 -0
  146. package/template/saas-starter/README.md +125 -0
  147. package/template/saas-starter/bunfig.toml +3 -0
  148. package/template/saas-starter/components.json +25 -0
  149. package/template/saas-starter/drizzle.config.ts +10 -0
  150. package/template/saas-starter/eslint.config.mjs +24 -0
  151. package/template/saas-starter/gitignore +14 -0
  152. package/template/saas-starter/next.config.ts +32 -0
  153. package/template/saas-starter/package.json +97 -0
  154. package/template/saas-starter/postcss.config.mjs +7 -0
  155. package/template/saas-starter/prettier.config.mjs +11 -0
  156. package/template/saas-starter/public/logo-dark.svg +4 -0
  157. package/template/saas-starter/public/logo.svg +4 -0
  158. package/template/saas-starter/public/placeholder.svg +1 -0
  159. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  160. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  161. package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
  162. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  163. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  164. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  165. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  166. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  167. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
  168. package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
  169. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  170. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  171. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  172. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  173. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  174. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  175. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  176. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  177. package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
  178. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  179. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  180. package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
  181. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  182. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
  188. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
  189. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
  190. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  191. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
  192. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
  193. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
  194. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
  195. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  196. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  197. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
  198. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
  199. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
  200. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  201. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
  202. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
  203. package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
  204. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
  205. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
  206. package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
  207. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  208. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  209. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  210. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  211. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  212. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
  213. package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
  214. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
  215. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
  216. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
  217. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  218. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  219. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  220. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  221. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  222. package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
  223. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  224. package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
  225. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
  226. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
  227. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  228. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  229. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  230. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  231. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  232. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  233. package/template/saas-starter/src/app/error.tsx +37 -0
  234. package/template/saas-starter/src/app/favicon.ico +0 -0
  235. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  236. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  237. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  238. package/template/saas-starter/src/app/global-error.tsx +38 -0
  239. package/template/saas-starter/src/app/globals.css +269 -0
  240. package/template/saas-starter/src/app/layout.tsx +77 -0
  241. package/template/saas-starter/src/app/not-found.tsx +49 -0
  242. package/template/saas-starter/src/app/robots.ts +36 -0
  243. package/template/saas-starter/src/app/sitemap.ts +21 -0
  244. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  245. package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
  246. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  247. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  248. package/template/saas-starter/src/components/data-table/index.tsx +961 -0
  249. package/template/saas-starter/src/components/data-table/types.ts +76 -0
  250. package/template/saas-starter/src/components/error.tsx +71 -0
  251. package/template/saas-starter/src/components/file-upload.tsx +254 -0
  252. package/template/saas-starter/src/components/loader.tsx +41 -0
  253. package/template/saas-starter/src/components/logo.tsx +49 -0
  254. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  255. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  256. package/template/saas-starter/src/components/page-heading.tsx +38 -0
  257. package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
  258. package/template/saas-starter/src/components/route-error.tsx +55 -0
  259. package/template/saas-starter/src/components/screen-size.tsx +76 -0
  260. package/template/saas-starter/src/components/site-footer.tsx +39 -0
  261. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  262. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  263. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  264. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  265. package/template/saas-starter/src/components/theme-provider.tsx +115 -0
  266. package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
  267. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  268. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  269. package/template/saas-starter/src/components/ui/badge.tsx +54 -0
  270. package/template/saas-starter/src/components/ui/button.tsx +69 -0
  271. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  272. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  273. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  274. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  275. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  276. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  277. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  278. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  279. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  280. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  281. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  282. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  283. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  284. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  285. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  286. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  287. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  288. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  289. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  290. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  291. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  292. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  293. package/template/saas-starter/src/components/user-menu.tsx +213 -0
  294. package/template/saas-starter/src/db/common.ts +63 -0
  295. package/template/saas-starter/src/db/enums.ts +50 -0
  296. package/template/saas-starter/src/db/index.ts +60 -0
  297. package/template/saas-starter/src/db/relations.ts +112 -0
  298. package/template/saas-starter/src/db/schema.ts +342 -0
  299. package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
  300. package/template/saas-starter/src/emails/invitation.tsx +69 -0
  301. package/template/saas-starter/src/emails/magic-link.tsx +51 -0
  302. package/template/saas-starter/src/emails/test.tsx +34 -0
  303. package/template/saas-starter/src/emails/welcome.tsx +37 -0
  304. package/template/saas-starter/src/env.ts +66 -0
  305. package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
  306. package/template/saas-starter/src/instrumentation.ts +44 -0
  307. package/template/saas-starter/src/lib/actions.ts +55 -0
  308. package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
  309. package/template/saas-starter/src/lib/app-navigation.ts +63 -0
  310. package/template/saas-starter/src/lib/auth-client.ts +34 -0
  311. package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
  312. package/template/saas-starter/src/lib/auth.ts +312 -0
  313. package/template/saas-starter/src/lib/billing.ts +152 -0
  314. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  315. package/template/saas-starter/src/lib/cn.ts +4 -0
  316. package/template/saas-starter/src/lib/constants.ts +91 -0
  317. package/template/saas-starter/src/lib/delay.ts +12 -0
  318. package/template/saas-starter/src/lib/email-service.ts +118 -0
  319. package/template/saas-starter/src/lib/errors.ts +74 -0
  320. package/template/saas-starter/src/lib/format-currency.ts +25 -0
  321. package/template/saas-starter/src/lib/format-date.ts +75 -0
  322. package/template/saas-starter/src/lib/image-url.ts +54 -0
  323. package/template/saas-starter/src/lib/nodemailer.ts +67 -0
  324. package/template/saas-starter/src/lib/organization-access.ts +73 -0
  325. package/template/saas-starter/src/lib/otel.ts +117 -0
  326. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  327. package/template/saas-starter/src/lib/plans.ts +90 -0
  328. package/template/saas-starter/src/lib/rbac.ts +44 -0
  329. package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
  330. package/template/saas-starter/src/lib/safe-action.ts +187 -0
  331. package/template/saas-starter/src/lib/search.ts +16 -0
  332. package/template/saas-starter/src/lib/seo.ts +7 -0
  333. package/template/saas-starter/src/lib/siteConfig.ts +20 -0
  334. package/template/saas-starter/src/lib/storage.ts +311 -0
  335. package/template/saas-starter/src/lib/stripe.ts +9 -0
  336. package/template/saas-starter/src/lib/toast-message.ts +101 -0
  337. package/template/saas-starter/src/lib/validations.ts +95 -0
  338. package/template/saas-starter/src/proxy.ts +48 -0
  339. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  340. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  341. package/template/saas-starter/src/scripts/seed.ts +85 -0
  342. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  343. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
  344. package/template/saas-starter/src/scripts/test-mail.ts +43 -0
  345. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  346. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  347. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  348. package/template/saas-starter/tests/helpers/action.ts +71 -0
  349. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  350. package/template/saas-starter/tests/helpers/index.ts +2 -0
  351. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  352. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  353. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  354. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  355. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  356. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  357. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  358. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  359. package/template/saas-starter/tests/setup/preload.ts +108 -0
  360. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  361. package/template/saas-starter/tsconfig.json +52 -0
  362. package/cli.js +0 -143
  363. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  364. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  365. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  366. package/template/nextjs/terraform/backend-service.tf +0 -13
  367. package/template/nextjs/terraform/cleanup.sh +0 -9
  368. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  369. package/template/nextjs/terraform/cloudrun.tf +0 -68
  370. package/template/nextjs/terraform/database.tf +0 -60
  371. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  372. package/template/nextjs/terraform/gitignore +0 -4
  373. package/template/nextjs/terraform/ip-address.tf +0 -11
  374. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  375. package/template/nextjs/terraform/network.tf +0 -21
  376. package/template/nextjs/terraform/pre.sh +0 -3
  377. package/template/nextjs/terraform/providers.tf +0 -18
  378. package/template/nextjs/terraform/secret.txt +0 -11
  379. package/template/nextjs/terraform/setup.sh +0 -25
  380. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  381. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  382. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  383. package/template/nextjs/terraform/terraform.tfvars +0 -4
  384. package/template/nextjs/terraform/url-map.tf +0 -23
  385. package/template/nextjs/terraform/variables.tf +0 -21
@@ -0,0 +1,25 @@
1
+ import { notFound } from 'next/navigation'
2
+ import { getOrganizationCached } from '~/app/(app)/actions/organizations'
3
+ import { SupportTicketConversation } from '~/components/support/support-ticket-conversation'
4
+ import { isManagerRole } from '~/lib/rbac'
5
+
6
+ export default async function OrganizationSupportTicketPage({
7
+ params
8
+ }: {
9
+ params: Promise<{ orgId: string; ticketId: string }>
10
+ }) {
11
+ const { orgId, ticketId } = await params
12
+ const org = (await getOrganizationCached(orgId))?.data
13
+
14
+ if (!org || !isManagerRole(org.role)) {
15
+ return notFound()
16
+ }
17
+
18
+ return (
19
+ <SupportTicketConversation
20
+ mode="app"
21
+ ticketId={ticketId}
22
+ backHref={`/app/${orgId}/support-tickets`}
23
+ />
24
+ )
25
+ }
@@ -0,0 +1,25 @@
1
+ import { notFound } from 'next/navigation'
2
+ import { getOrganizationCached } from '~/app/(app)/actions/organizations'
3
+ import { SupportTicketsTable } from '~/components/support/support-tickets-table'
4
+ import { isManagerRole } from '~/lib/rbac'
5
+
6
+ export default async function OrganizationSupportTicketsPage({
7
+ params
8
+ }: {
9
+ params: Promise<{ orgId: string }>
10
+ }) {
11
+ const { orgId } = await params
12
+ const org = (await getOrganizationCached(orgId))?.data
13
+
14
+ if (!org || !isManagerRole(org.role)) {
15
+ return notFound()
16
+ }
17
+
18
+ return (
19
+ <SupportTicketsTable
20
+ mode="app"
21
+ organizationId={orgId}
22
+ basePath={`/app/${orgId}/support-tickets`}
23
+ />
24
+ )
25
+ }
@@ -0,0 +1,31 @@
1
+ 'use client'
2
+
3
+ import { DashboardHeader } from '~/components/dashboard-header'
4
+ import { AppSearch } from './app-search'
5
+ import { FeedbackDialog } from './feedback-dialog'
6
+
7
+ export function AppHeader({
8
+ user,
9
+ organizations,
10
+ isSuperAdmin,
11
+ isImpersonating
12
+ }: {
13
+ user: {
14
+ name?: string | null
15
+ email?: string | null
16
+ image?: string | null
17
+ }
18
+ organizations: { id: string; role: string }[]
19
+ isSuperAdmin: boolean
20
+ isImpersonating: boolean
21
+ }) {
22
+ return (
23
+ <DashboardHeader
24
+ user={user}
25
+ isSuperAdmin={isSuperAdmin}
26
+ isImpersonating={isImpersonating}
27
+ search={<AppSearch organizations={organizations} />}
28
+ actions={<FeedbackDialog />}
29
+ />
30
+ )
31
+ }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+
3
+ import { useParams } from 'next/navigation'
4
+ import { NavigationSearch } from '~/components/navigation-search'
5
+ import { getAppNavigation } from '~/lib/app-navigation'
6
+
7
+ export function AppSearch({
8
+ organizations
9
+ }: {
10
+ organizations: { id: string; role: string }[]
11
+ }) {
12
+ const params = useParams()
13
+ const orgId = params?.orgId as string | undefined
14
+ const role = organizations.find(o => o.id === orgId)?.role
15
+
16
+ if (!orgId || !role) {
17
+ return null
18
+ }
19
+
20
+ return (
21
+ <NavigationSearch
22
+ groups={getAppNavigation(orgId, role)}
23
+ dialogDescription="Search this organization's pages and settings."
24
+ />
25
+ )
26
+ }
@@ -0,0 +1,30 @@
1
+ 'use client'
2
+
3
+ import { useParams } from 'next/navigation'
4
+ import { NavigationSidebar } from '~/components/navigation-sidebar'
5
+ import { getAppNavigation } from '~/lib/app-navigation'
6
+ import {
7
+ OrganizationSwitcher,
8
+ type SwitcherOrganization
9
+ } from './organization-switcher'
10
+
11
+ export function AppSidebar({
12
+ organizations
13
+ }: {
14
+ organizations: (SwitcherOrganization & { role: string })[]
15
+ }) {
16
+ const params = useParams()
17
+ const orgId = params?.orgId as string | undefined
18
+ const role = organizations.find(o => o.id === orgId)?.role
19
+
20
+ if (!orgId || !role) {
21
+ return null
22
+ }
23
+
24
+ return (
25
+ <NavigationSidebar
26
+ groups={getAppNavigation(orgId, role)}
27
+ header={<OrganizationSwitcher organizations={organizations} />}
28
+ />
29
+ )
30
+ }
@@ -0,0 +1,108 @@
1
+ 'use client'
2
+
3
+ import { MessageCircle, Star } from 'lucide-react'
4
+ import { useAction } from 'next-safe-action/hooks'
5
+ import { useParams } from 'next/navigation'
6
+ import { useState } from 'react'
7
+ import { Button } from '~/components/ui/button'
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ DialogTrigger
16
+ } from '~/components/ui/dialog'
17
+ import { Textarea } from '~/components/ui/textarea'
18
+ import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
19
+ import { createFeedbackAction } from '../../actions/feedbacks'
20
+
21
+ export function FeedbackDialog() {
22
+ const params = useParams()
23
+ const organizationId =
24
+ typeof params?.orgId === 'string' ? params.orgId : undefined
25
+ const [open, setOpen] = useState(false)
26
+ const [rating, setRating] = useState(5)
27
+ const [comment, setComment] = useState('')
28
+
29
+ const { execute, isExecuting } = useAction(createFeedbackAction, {
30
+ onSuccess: () => {
31
+ toastSuccessMessage('Thanks for the feedback')
32
+ setOpen(false)
33
+ setRating(5)
34
+ setComment('')
35
+ },
36
+ onError: ({ error }) => {
37
+ toastActionError(error, 'Your feedback was not sent. Try again.')
38
+ }
39
+ })
40
+
41
+ return (
42
+ <Dialog open={open} onOpenChange={setOpen}>
43
+ <DialogTrigger asChild>
44
+ <Button
45
+ variant="outline"
46
+ size="sm"
47
+ className={organizationId ? 'inline-flex' : 'hidden'}
48
+ aria-label="Share feedback"
49
+ >
50
+ <MessageCircle className="size-4" aria-hidden="true" />
51
+ <span className="hidden sm:inline">Feedback</span>
52
+ </Button>
53
+ </DialogTrigger>
54
+ <DialogContent>
55
+ <DialogHeader>
56
+ <DialogTitle>Share Feedback</DialogTitle>
57
+ <DialogDescription>
58
+ Tell us what works well and what should improve.
59
+ </DialogDescription>
60
+ </DialogHeader>
61
+
62
+ <div className="space-y-4">
63
+ <div className="flex gap-1">
64
+ {Array.from({ length: 5 }, (_, index) => {
65
+ const value = index + 1
66
+ return (
67
+ <Button
68
+ key={value}
69
+ type="button"
70
+ variant="ghost"
71
+ size="icon"
72
+ onClick={() => setRating(value)}
73
+ aria-label={`${value} star rating`}
74
+ >
75
+ <Star
76
+ className={`h-5 w-5 ${
77
+ value <= rating
78
+ ? 'fill-warning text-warning'
79
+ : 'text-muted-foreground'
80
+ }`}
81
+ />
82
+ </Button>
83
+ )
84
+ })}
85
+ </div>
86
+
87
+ <Textarea
88
+ value={comment}
89
+ onChange={event => setComment(event.target.value)}
90
+ placeholder="Write your feedback…"
91
+ className="min-h-32"
92
+ />
93
+ </div>
94
+
95
+ <DialogFooter>
96
+ <Button
97
+ onClick={() =>
98
+ organizationId && execute({ organizationId, rating, comment })
99
+ }
100
+ disabled={isExecuting || !comment.trim()}
101
+ >
102
+ Submit Feedback
103
+ </Button>
104
+ </DialogFooter>
105
+ </DialogContent>
106
+ </Dialog>
107
+ )
108
+ }
@@ -0,0 +1,37 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext } from 'react'
4
+
5
+ type OrganizationContextType = {
6
+ id: string
7
+ name: string
8
+ role: string
9
+ memberId: string
10
+ }
11
+
12
+ // No default: a component rendered outside the provider is a bug, and the
13
+ // hook throws rather than passing off an empty organization as real.
14
+ const OrganizationContext = createContext<OrganizationContextType | null>(null)
15
+
16
+ export const OrganizationContextProvider = ({
17
+ children,
18
+ value
19
+ }: {
20
+ children: React.ReactNode
21
+ value: OrganizationContextType
22
+ }) => {
23
+ return <OrganizationContext value={value}>{children}</OrganizationContext>
24
+ }
25
+
26
+ /** The organization in the current `/app/[orgId]` route. */
27
+ export const useOrganizationContext = () => {
28
+ const context = useContext(OrganizationContext)
29
+
30
+ if (!context) {
31
+ throw new Error(
32
+ 'useOrganizationContext must be used within an OrganizationContextProvider'
33
+ )
34
+ }
35
+
36
+ return context
37
+ }
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+
3
+ import { Building2, ChevronsUpDown, LogOut, PlusCircle } from 'lucide-react'
4
+ import Image from 'next/image'
5
+ import { useParams, useRouter } from 'next/navigation'
6
+ import { useMemo, useState } from 'react'
7
+ import { ConfirmDialog } from '~/components/confirm-dialog'
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuLabel,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger
15
+ } from '~/components/ui/dropdown-menu'
16
+ import {
17
+ SidebarMenu,
18
+ SidebarMenuButton,
19
+ SidebarMenuItem
20
+ } from '~/components/ui/sidebar'
21
+ import { organization } from '~/lib/auth-client'
22
+ import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
23
+
24
+ export type SwitcherOrganization = {
25
+ id: string
26
+ name: string
27
+ role: string
28
+ logoUrl: string | null
29
+ }
30
+
31
+ function OrganizationLogo({
32
+ logo,
33
+ name,
34
+ size
35
+ }: {
36
+ logo: string | null
37
+ name: string
38
+ size: 'sm' | 'lg'
39
+ }) {
40
+ if (logo) {
41
+ return (
42
+ <div
43
+ className={
44
+ size === 'lg'
45
+ ? 'relative aspect-square size-8 shrink-0 overflow-hidden rounded-lg'
46
+ : 'relative size-full overflow-hidden rounded-sm'
47
+ }
48
+ >
49
+ <Image
50
+ src={logo}
51
+ alt={name}
52
+ fill
53
+ sizes={size === 'lg' ? '32px' : '24px'}
54
+ className="object-cover"
55
+ unoptimized={logo.startsWith('data:')}
56
+ />
57
+ </div>
58
+ )
59
+ }
60
+
61
+ return size === 'lg' ? (
62
+ <div className="bg-primary text-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg">
63
+ <Building2 className="size-4" />
64
+ </div>
65
+ ) : (
66
+ <Building2 className="size-4 shrink-0" />
67
+ )
68
+ }
69
+
70
+ export function OrganizationSwitcher({
71
+ organizations
72
+ }: {
73
+ organizations: SwitcherOrganization[]
74
+ }) {
75
+ const router = useRouter()
76
+ const params = useParams()
77
+ const activeOrgId = params?.orgId as string | undefined
78
+
79
+ const activeOrg = useMemo(
80
+ () => organizations.find(o => o.id === activeOrgId) ?? organizations[0],
81
+ [organizations, activeOrgId]
82
+ )
83
+
84
+ // Managers leave from Settings; members cannot open Settings, so they get
85
+ // the option here.
86
+ const canLeaveHere = activeOrgId !== undefined && activeOrg?.role === 'member'
87
+ const [confirmLeave, setConfirmLeave] = useState(false)
88
+ const [isLeaving, setIsLeaving] = useState(false)
89
+
90
+ async function leaveOrganization() {
91
+ if (!activeOrg) return
92
+ setIsLeaving(true)
93
+ const { error } = await organization.leave({
94
+ organizationId: activeOrg.id
95
+ })
96
+ setIsLeaving(false)
97
+
98
+ if (error) {
99
+ toastActionError(error, 'You are still a member. Try again.')
100
+ return
101
+ }
102
+
103
+ setConfirmLeave(false)
104
+ toastSuccessMessage(`You left ${activeOrg.name}`)
105
+ router.push('/app')
106
+ router.refresh()
107
+ }
108
+
109
+ return (
110
+ <SidebarMenu>
111
+ <SidebarMenuItem>
112
+ <DropdownMenu>
113
+ <DropdownMenuTrigger asChild>
114
+ <SidebarMenuButton
115
+ size="lg"
116
+ className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
117
+ >
118
+ <OrganizationLogo
119
+ logo={activeOrg?.logoUrl ?? null}
120
+ name={activeOrg?.name ?? ''}
121
+ size="lg"
122
+ />
123
+ <div className="grid flex-1 text-left text-sm leading-tight">
124
+ <span className="truncate font-medium">
125
+ {activeOrg?.name ?? 'Select Organization'}
126
+ </span>
127
+ {activeOrg ? (
128
+ <span className="text-muted-foreground truncate text-xs capitalize">
129
+ {activeOrg.role}
130
+ </span>
131
+ ) : null}
132
+ </div>
133
+ <ChevronsUpDown className="ml-auto size-4 shrink-0" />
134
+ </SidebarMenuButton>
135
+ </DropdownMenuTrigger>
136
+ <DropdownMenuContent
137
+ className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
138
+ align="start"
139
+ side="bottom"
140
+ sideOffset={4}
141
+ >
142
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
143
+ Organizations
144
+ </DropdownMenuLabel>
145
+ {organizations.map(org => (
146
+ <DropdownMenuItem
147
+ key={org.id}
148
+ onClick={() => router.push(`/app/${org.id}/dashboard`)}
149
+ className="gap-2 p-2"
150
+ >
151
+ <div className="flex size-6 items-center justify-center overflow-hidden rounded-sm border">
152
+ <OrganizationLogo
153
+ logo={org.logoUrl}
154
+ name={org.name}
155
+ size="sm"
156
+ />
157
+ </div>
158
+ <span className="truncate">{org.name}</span>
159
+ </DropdownMenuItem>
160
+ ))}
161
+ <DropdownMenuSeparator />
162
+ <DropdownMenuItem
163
+ className="gap-2 p-2"
164
+ onClick={() => router.push('/app/onboarding')}
165
+ >
166
+ <div className="bg-background text-muted-foreground flex size-6 items-center justify-center rounded-md border">
167
+ <PlusCircle className="size-4" />
168
+ </div>
169
+ <div className="text-muted-foreground font-medium">
170
+ Create Organization
171
+ </div>
172
+ </DropdownMenuItem>
173
+ {canLeaveHere ? (
174
+ <DropdownMenuItem
175
+ variant="destructive"
176
+ className="gap-2 p-2"
177
+ onClick={() => setConfirmLeave(true)}
178
+ >
179
+ <div className="flex size-6 items-center justify-center rounded-md border">
180
+ <LogOut className="size-4" />
181
+ </div>
182
+ <div className="font-medium">Leave Organization</div>
183
+ </DropdownMenuItem>
184
+ ) : null}
185
+ </DropdownMenuContent>
186
+ </DropdownMenu>
187
+ </SidebarMenuItem>
188
+
189
+ <ConfirmDialog
190
+ open={confirmLeave}
191
+ onOpenChange={setConfirmLeave}
192
+ title="Leave Organization"
193
+ description={`You lose access to ${activeOrg?.name ?? 'this organization'}. An owner or admin must invite you to return.`}
194
+ confirmLabel="Leave Organization"
195
+ pending={isLeaving}
196
+ onConfirm={leaveOrganization}
197
+ />
198
+ </SidebarMenu>
199
+ )
200
+ }
@@ -0,0 +1,64 @@
1
+ import type { Metadata } from 'next'
2
+ import { cookies, headers } from 'next/headers'
3
+ import { redirect } from 'next/navigation'
4
+ import { NuqsAdapter } from 'nuqs/adapters/next/app'
5
+ import { ErrorDisplay } from '~/components/error'
6
+ import { SidebarInset, SidebarProvider } from '~/components/ui/sidebar'
7
+ import { getAuthSession } from '~/lib/auth'
8
+ import { sanitizeCallbackUrl } from '~/lib/callback-url'
9
+ import { privatePageRobots } from '~/lib/seo'
10
+ import { getOrganizationsCached } from '../actions/organizations'
11
+ import { AppHeader } from './_components/app-header'
12
+ import { AppSidebar } from './_components/app-sidebar'
13
+
14
+ export const metadata: Metadata = {
15
+ robots: privatePageRobots
16
+ }
17
+
18
+ export default async function AppLayout({
19
+ children
20
+ }: {
21
+ children: React.ReactNode
22
+ }) {
23
+ const authSession = await getAuthSession()
24
+
25
+ if (!authSession) {
26
+ const headerStore = await headers()
27
+ const callbackUrl =
28
+ sanitizeCallbackUrl(headerStore.get('x-pathname') ?? undefined) ?? '/app'
29
+ redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`)
30
+ }
31
+
32
+ const organizations = await getOrganizationsCached()
33
+
34
+ if (!organizations?.data) {
35
+ return (
36
+ <ErrorDisplay
37
+ title="Organizations Failed to Load"
38
+ message="Refresh the page to try again."
39
+ link={{ href: '/app', text: 'Retry' }}
40
+ />
41
+ )
42
+ }
43
+
44
+ const cookieStore = await cookies()
45
+ const sidebarStateCookie = cookieStore.get('sidebar_state')?.value
46
+ const defaultOpen = sidebarStateCookie !== 'false'
47
+
48
+ return (
49
+ <NuqsAdapter>
50
+ <SidebarProvider defaultOpen={defaultOpen}>
51
+ <AppSidebar organizations={organizations.data} />
52
+ <SidebarInset>
53
+ <AppHeader
54
+ user={authSession.user}
55
+ organizations={organizations.data}
56
+ isSuperAdmin={authSession.isSuperAdmin}
57
+ isImpersonating={!!authSession.session.impersonatedBy}
58
+ />
59
+ <div className="flex flex-1 flex-col gap-4 p-4">{children}</div>
60
+ </SidebarInset>
61
+ </SidebarProvider>
62
+ </NuqsAdapter>
63
+ )
64
+ }
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { Loader2 } from 'lucide-react'
5
+ import { useRouter } from 'next/navigation'
6
+ import { useState } from 'react'
7
+ import { Controller, useForm } from 'react-hook-form'
8
+ import { z } from 'zod'
9
+ import { organizationDetailsSchema } from '~/app/(app)/actions/organizations.validation'
10
+ import { Button } from '~/components/ui/button'
11
+ import {
12
+ Field,
13
+ FieldDescription,
14
+ FieldError,
15
+ FieldLabel
16
+ } from '~/components/ui/field'
17
+ import { Input } from '~/components/ui/input'
18
+ import { organization } from '~/lib/auth-client'
19
+ import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
20
+ import { slugifyName } from '~/lib/validations'
21
+
22
+ type Form = z.infer<typeof organizationDetailsSchema>
23
+
24
+ export function CreateOrganizationForm() {
25
+ const router = useRouter()
26
+ const [slugEdited, setSlugEdited] = useState(false)
27
+
28
+ const form = useForm<Form>({
29
+ resolver: zodResolver(organizationDetailsSchema),
30
+ defaultValues: { name: '', slug: '' }
31
+ })
32
+
33
+ async function onSubmit(values: Form) {
34
+ const { data, error } = await organization.create(values)
35
+
36
+ if (error || !data) {
37
+ toastActionError(error, 'The organization was not created. Try again.')
38
+ return
39
+ }
40
+
41
+ toastSuccessMessage(`${data.name} created`)
42
+ router.push(`/app/${data.id}/dashboard`)
43
+ // Re-render the app layout so the sidebar lists the new organization.
44
+ router.refresh()
45
+ }
46
+
47
+ const isSubmitting = form.formState.isSubmitting
48
+
49
+ return (
50
+ <form
51
+ onSubmit={form.handleSubmit(onSubmit)}
52
+ className="flex flex-col gap-6"
53
+ >
54
+ <Controller
55
+ control={form.control}
56
+ name="name"
57
+ render={({ field, fieldState }) => (
58
+ <Field data-invalid={fieldState.invalid}>
59
+ <FieldLabel htmlFor={field.name}>Organization name</FieldLabel>
60
+ <Input
61
+ id={field.name}
62
+ autoFocus
63
+ placeholder="Acme Inc."
64
+ aria-invalid={fieldState.invalid}
65
+ {...field}
66
+ onChange={event => {
67
+ field.onChange(event)
68
+ if (!slugEdited) {
69
+ form.setValue('slug', slugifyName(event.target.value), {
70
+ shouldValidate: form.formState.isSubmitted
71
+ })
72
+ }
73
+ }}
74
+ />
75
+ <FieldError
76
+ errors={fieldState.error ? [fieldState.error] : undefined}
77
+ />
78
+ </Field>
79
+ )}
80
+ />
81
+
82
+ <Controller
83
+ control={form.control}
84
+ name="slug"
85
+ render={({ field, fieldState }) => (
86
+ <Field data-invalid={fieldState.invalid}>
87
+ <FieldLabel htmlFor={field.name}>Slug</FieldLabel>
88
+ <Input
89
+ id={field.name}
90
+ placeholder="acme-inc"
91
+ aria-invalid={fieldState.invalid}
92
+ {...field}
93
+ onChange={event => {
94
+ setSlugEdited(true)
95
+ field.onChange(event)
96
+ }}
97
+ />
98
+ <FieldDescription>
99
+ Identifies the organization in links. Use lowercase letters,
100
+ numbers, and hyphens.
101
+ </FieldDescription>
102
+ <FieldError
103
+ errors={fieldState.error ? [fieldState.error] : undefined}
104
+ />
105
+ </Field>
106
+ )}
107
+ />
108
+
109
+ <div>
110
+ <Button type="submit" disabled={isSubmitting}>
111
+ {isSubmitting && <Loader2 className="animate-spin" />}
112
+ Create Organization
113
+ </Button>
114
+ </div>
115
+ </form>
116
+ )
117
+ }