create-ej-app 26.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 (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 +46 -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 +49 -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
@@ -0,0 +1,230 @@
1
+ 'use server'
2
+
3
+ import { and, desc, eq, ne } from 'drizzle-orm'
4
+ import { cache } from 'react'
5
+ import { z } from 'zod'
6
+ import { db } from '~/db'
7
+ import { membersTable, organizationsTable, usersTable } from '~/db/schema'
8
+ import { AppError, AppErrorCode, rethrowUniqueViolation } from '~/lib/errors'
9
+ import {
10
+ assertCanManageOrganization,
11
+ assertRole
12
+ } from '~/lib/organization-access'
13
+ import { authActionClient, withWideEvent } from '~/lib/safe-action'
14
+ import {
15
+ assertOwnedR2Key,
16
+ assertPersistableImageValue,
17
+ confirmUpload,
18
+ deleteOwnedFileQuietly,
19
+ isR2Key,
20
+ resolveImageUrl
21
+ } from '~/lib/storage'
22
+ import { stringValidation } from '~/lib/validations'
23
+ import { updateOrganizationSchema } from './organizations.validation'
24
+
25
+ /** Every organization the caller belongs to, newest membership first. */
26
+ export const getOrganizations = authActionClient.action(
27
+ withWideEvent('getOrganizations', async ({ ctx: { user } }) => {
28
+ const rows = await db
29
+ .select({
30
+ id: organizationsTable.id,
31
+ name: organizationsTable.name,
32
+ slug: organizationsTable.slug,
33
+ logo: organizationsTable.logo,
34
+ role: membersTable.role,
35
+ memberId: membersTable.id
36
+ })
37
+ .from(membersTable)
38
+ .innerJoin(
39
+ organizationsTable,
40
+ eq(membersTable.organizationId, organizationsTable.id)
41
+ )
42
+ .where(eq(membersTable.userId, user.id))
43
+ .orderBy(desc(membersTable.createdAt))
44
+
45
+ return rows.map(row => ({ ...row, logoUrl: resolveImageUrl(row.logo) }))
46
+ })
47
+ )
48
+
49
+ /** Per-request cache, so layouts and pages share one query. */
50
+ export const getOrganizationsCached = cache(getOrganizations)
51
+
52
+ /**
53
+ * One organization with the caller's role in it. Returns no data when the
54
+ * caller is not a member, so pages can respond with `notFound()`.
55
+ */
56
+ export const getOrganization = authActionClient
57
+ .inputSchema(stringValidation)
58
+ .action(
59
+ withWideEvent(
60
+ 'getOrganization',
61
+ async ({ parsedInput: organizationId, ctx: { user } }) => {
62
+ const [row] = await db
63
+ .select({
64
+ id: organizationsTable.id,
65
+ name: organizationsTable.name,
66
+ slug: organizationsTable.slug,
67
+ logo: organizationsTable.logo,
68
+ role: membersTable.role,
69
+ memberId: membersTable.id
70
+ })
71
+ .from(membersTable)
72
+ .innerJoin(
73
+ organizationsTable,
74
+ eq(membersTable.organizationId, organizationsTable.id)
75
+ )
76
+ .where(
77
+ and(
78
+ eq(membersTable.organizationId, organizationId),
79
+ eq(membersTable.userId, user.id)
80
+ )
81
+ )
82
+
83
+ return row ? { ...row, logoUrl: resolveImageUrl(row.logo) } : null
84
+ }
85
+ )
86
+ )
87
+
88
+ export const getOrganizationCached = cache(getOrganization)
89
+
90
+ /**
91
+ * Saves the organization's name, slug, and logo. The logo must be an upload
92
+ * this organization owns (or a data URL while R2 is off); the replaced file
93
+ * is deleted.
94
+ */
95
+ export const updateOrganizationAction = authActionClient
96
+ .inputSchema(updateOrganizationSchema)
97
+ .action(
98
+ withWideEvent(
99
+ 'updateOrganizationAction',
100
+ async ({ parsedInput, ctx: { user } }) => {
101
+ const { organizationId } = parsedInput
102
+ await assertCanManageOrganization(organizationId, user.id)
103
+
104
+ const org = await db.query.organizationsTable.findFirst({
105
+ where: eq(organizationsTable.id, organizationId),
106
+ columns: { logo: true }
107
+ })
108
+
109
+ if (!org) {
110
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Organization not found.')
111
+ }
112
+
113
+ const logo = parsedInput.logo || null
114
+ const logoChanged = logo !== org.logo
115
+ // An unchanged logo is already stored, even a data URL saved before R2
116
+ // was configured, so only a new value has to pass the storage rules.
117
+ if (logoChanged) assertPersistableImageValue(logo)
118
+
119
+ // Check ownership before saving, and delete the old file only after
120
+ // the save succeeds, so a failed save never loses the current logo.
121
+ if (logoChanged && isR2Key(logo)) {
122
+ await assertOwnedR2Key(logo, organizationId)
123
+ }
124
+
125
+ await db
126
+ .update(organizationsTable)
127
+ .set({ name: parsedInput.name, slug: parsedInput.slug, logo })
128
+ .where(eq(organizationsTable.id, organizationId))
129
+ .catch(error =>
130
+ rethrowUniqueViolation(
131
+ error,
132
+ 'Another organization already uses this slug. Choose a different one.'
133
+ )
134
+ )
135
+
136
+ if (logoChanged && isR2Key(logo)) {
137
+ await confirmUpload(logo, organizationId, org.logo)
138
+ } else if (logoChanged && org.logo) {
139
+ await deleteOwnedFileQuietly(org.logo, organizationId)
140
+ }
141
+
142
+ return true
143
+ }
144
+ )
145
+ )
146
+
147
+ /** Members the owner can hand ownership to: everyone except themselves. */
148
+ export const getTransferTargets = authActionClient
149
+ .inputSchema(stringValidation)
150
+ .action(
151
+ withWideEvent(
152
+ 'getTransferTargets',
153
+ async ({ parsedInput: organizationId, ctx: { user } }) => {
154
+ const owner = await assertRole(organizationId, user.id, ['owner'])
155
+
156
+ return db
157
+ .select({
158
+ memberId: membersTable.id,
159
+ name: usersTable.name,
160
+ email: usersTable.email,
161
+ role: membersTable.role
162
+ })
163
+ .from(membersTable)
164
+ .innerJoin(usersTable, eq(membersTable.userId, usersTable.id))
165
+ .where(
166
+ and(
167
+ eq(membersTable.organizationId, organizationId),
168
+ ne(membersTable.id, owner.id)
169
+ )
170
+ )
171
+ .orderBy(usersTable.name)
172
+ }
173
+ )
174
+ )
175
+
176
+ /** Makes another member the owner. The current owner becomes an admin. */
177
+ export const transferOrganizationOwnershipAction = authActionClient
178
+ .inputSchema(
179
+ z.object({
180
+ organizationId: stringValidation,
181
+ targetMemberId: stringValidation
182
+ })
183
+ )
184
+ .action(
185
+ withWideEvent(
186
+ 'transferOrganizationOwnershipAction',
187
+ async ({
188
+ parsedInput: { organizationId, targetMemberId },
189
+ ctx: { user }
190
+ }) => {
191
+ const owner = await assertRole(organizationId, user.id, ['owner'])
192
+
193
+ if (targetMemberId === owner.id) {
194
+ throw new AppError(
195
+ AppErrorCode.BAD_REQUEST,
196
+ 'You already own this organization. Choose another member.'
197
+ )
198
+ }
199
+
200
+ const target = await db.query.membersTable.findFirst({
201
+ where: and(
202
+ eq(membersTable.id, targetMemberId),
203
+ eq(membersTable.organizationId, organizationId)
204
+ ),
205
+ columns: { id: true }
206
+ })
207
+
208
+ if (!target) {
209
+ throw new AppError(
210
+ AppErrorCode.NOT_FOUND,
211
+ 'That member is no longer in this organization. Refresh and choose again.'
212
+ )
213
+ }
214
+
215
+ await db.transaction(async tx => {
216
+ await tx
217
+ .update(membersTable)
218
+ .set({ role: 'owner' })
219
+ .where(eq(membersTable.id, target.id))
220
+
221
+ await tx
222
+ .update(membersTable)
223
+ .set({ role: 'admin' })
224
+ .where(eq(membersTable.id, owner.id))
225
+ })
226
+
227
+ return true
228
+ }
229
+ )
230
+ )
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ imageUrlValidation,
4
+ slugValidation,
5
+ stringValidation
6
+ } from '~/lib/validations'
7
+
8
+ export const organizationDetailsSchema = z.object({
9
+ name: stringValidation,
10
+ slug: slugValidation
11
+ })
12
+
13
+ export const updateOrganizationSchema = organizationDetailsSchema.extend({
14
+ organizationId: stringValidation,
15
+ /** Empty string clears the logo. */
16
+ logo: imageUrlValidation
17
+ })
@@ -4,17 +4,14 @@ import { LayoutGrid } from 'lucide-react'
4
4
  import { PageHeading } from '~/components/page-heading'
5
5
  import { useOrganizationContext } from '../../_components/organization-context'
6
6
 
7
- export default function Page() {
7
+ export default function DashboardPage() {
8
8
  const org = useOrganizationContext()
9
9
 
10
10
  return (
11
- <div className="space-y-6">
12
- <PageHeading
13
- title={org.name}
14
- description="Your organization's home base."
15
- icon={LayoutGrid}
16
- />
17
- Hello, World!
18
- </div>
11
+ <PageHeading
12
+ title={org.name}
13
+ description="Your organization's home. Build your product here."
14
+ icon={LayoutGrid}
15
+ />
19
16
  )
20
17
  }
@@ -1,8 +1,8 @@
1
- import { redirect } from 'next/navigation'
2
- import { getUserOrganizationsCached } from '~/lib/organization-access'
1
+ import { notFound } from 'next/navigation'
2
+ import { getOrganizationCached } from '../../actions/organizations'
3
3
  import { OrganizationContextProvider } from '../_components/organization-context'
4
4
 
5
- export default async function Layout({
5
+ export default async function OrganizationLayout({
6
6
  children,
7
7
  params
8
8
  }: {
@@ -10,12 +10,10 @@ export default async function Layout({
10
10
  params: Promise<{ orgId: string }>
11
11
  }) {
12
12
  const { orgId } = await params
13
-
14
- const organizations = await getUserOrganizationsCached()
15
- const org = organizations.find(o => o.id === orgId)
13
+ const org = (await getOrganizationCached(orgId))?.data
16
14
 
17
15
  if (!org) {
18
- return redirect('/app')
16
+ return notFound()
19
17
  }
20
18
 
21
19
  return (
@@ -24,8 +22,7 @@ export default async function Layout({
24
22
  id: org.id,
25
23
  name: org.name,
26
24
  role: org.role,
27
- memberId: org.memberId,
28
- userId: org.userId
25
+ memberId: org.memberId
29
26
  }}
30
27
  >
31
28
  {children}
@@ -3,20 +3,19 @@
3
3
  import { zodResolver } from '@hookform/resolvers/zod'
4
4
  import { Loader2 } from 'lucide-react'
5
5
  import { useRouter } from 'next/navigation'
6
- import { useForm } from 'react-hook-form'
6
+ import { Controller, useForm } from 'react-hook-form'
7
7
  import { z } from 'zod'
8
8
  import { updateBillingEmailAction } from '~/app/(app)/actions/billing'
9
9
  import { Button } from '~/components/ui/button'
10
10
  import {
11
11
  Field,
12
- FieldContent,
13
12
  FieldDescription,
14
13
  FieldError,
15
- FieldTitle
14
+ FieldLabel
16
15
  } from '~/components/ui/field'
17
16
  import { Input } from '~/components/ui/input'
18
17
  import { useSafeActionMutation } from '~/lib/safe-action-client'
19
- import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
18
+ import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
20
19
  import { emailValidation } from '~/lib/validations'
21
20
 
22
21
  const schema = z.object({
@@ -33,66 +32,59 @@ export function BillingEmailForm({
33
32
  defaultBillingEmail: string | null
34
33
  }) {
35
34
  const router = useRouter()
36
- const { mutateAsync: updateBillingEmail } = useSafeActionMutation(
37
- updateBillingEmailAction
38
- )
39
35
 
40
- const {
41
- register,
42
- handleSubmit,
43
- reset,
44
- formState: { errors, isDirty, isSubmitting }
45
- } = useForm<Form>({
36
+ const form = useForm<Form>({
46
37
  resolver: zodResolver(schema),
47
38
  defaultValues: { billingEmail: defaultBillingEmail ?? '' }
48
39
  })
49
40
 
50
- async function onSubmit(values: Form) {
51
- try {
52
- await updateBillingEmail({
53
- organizationId: orgId,
54
- billingEmail: values.billingEmail
55
- })
56
- reset({ billingEmail: values.billingEmail })
41
+ const save = useSafeActionMutation(updateBillingEmailAction, {
42
+ onSuccess: (_, values) => {
43
+ form.reset({ billingEmail: values.billingEmail })
57
44
  toastSuccessMessage('Billing email updated')
58
45
  router.refresh()
59
- } catch (error) {
60
- toastErrorMessage(
61
- error instanceof Error
62
- ? error.message
63
- : 'Failed to update billing email'
64
- )
65
- }
66
- }
46
+ },
47
+ onError: error =>
48
+ toastActionError(error, 'The billing email was not saved. Try again.')
49
+ })
67
50
 
68
51
  return (
69
- <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
70
- <Field orientation="vertical">
71
- <FieldContent>
72
- <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
73
- Billing email
74
- </FieldTitle>
75
- <Input
76
- type="email"
77
- placeholder="billing@company.com"
78
- {...register('billingEmail')}
79
- aria-invalid={!!errors.billingEmail}
80
- />
81
- <FieldDescription className="mt-1.5 text-[13px]">
82
- Invoices and receipts are sent here. Required before purchasing a
83
- plan.
84
- </FieldDescription>
85
- <FieldError
86
- errors={[errors.billingEmail]}
87
- className="mt-1.5 text-[13px]"
88
- />
89
- </FieldContent>
90
- </Field>
52
+ <form
53
+ onSubmit={form.handleSubmit(values =>
54
+ save.mutate({ organizationId: orgId, ...values })
55
+ )}
56
+ className="flex flex-col gap-6"
57
+ >
58
+ <Controller
59
+ control={form.control}
60
+ name="billingEmail"
61
+ render={({ field, fieldState }) => (
62
+ <Field data-invalid={fieldState.invalid}>
63
+ <FieldLabel htmlFor={field.name}>Billing email</FieldLabel>
64
+ <Input
65
+ id={field.name}
66
+ type="email"
67
+ placeholder="billing@example.com"
68
+ aria-invalid={fieldState.invalid}
69
+ {...field}
70
+ />
71
+ <FieldDescription>
72
+ Required before you choose a paid plan.
73
+ </FieldDescription>
74
+ <FieldError
75
+ errors={fieldState.error ? [fieldState.error] : undefined}
76
+ />
77
+ </Field>
78
+ )}
79
+ />
91
80
 
92
81
  <div>
93
- <Button type="submit" disabled={isSubmitting || !isDirty}>
94
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
95
- Save billing email
82
+ <Button
83
+ type="submit"
84
+ disabled={save.isPending || !form.formState.isDirty}
85
+ >
86
+ {save.isPending && <Loader2 className="animate-spin" />}
87
+ Save Billing Email
96
88
  </Button>
97
89
  </div>
98
90
  </form>
@@ -3,7 +3,7 @@
3
3
  import { Loader2 } from 'lucide-react'
4
4
  import { useRouter } from 'next/navigation'
5
5
  import { useState } from 'react'
6
- import { transferOrganizationOwnershipAction } from '~/app/(app)/actions/organization'
6
+ import { transferOrganizationOwnershipAction } from '~/app/(app)/actions/organizations'
7
7
  import { Button } from '~/components/ui/button'
8
8
  import {
9
9
  Dialog,
@@ -24,8 +24,13 @@ import {
24
24
  SelectValue
25
25
  } from '~/components/ui/select'
26
26
  import { organization } from '~/lib/auth-client'
27
+ import { OWNERSHIP_DEMOTION_NOTICE } from '~/lib/rbac'
27
28
  import { useSafeActionMutation } from '~/lib/safe-action-client'
28
- import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
29
+ import {
30
+ toastActionError,
31
+ toastErrorMessage,
32
+ toastSuccessMessage
33
+ } from '~/lib/toast-message'
29
34
 
30
35
  type TransferTarget = {
31
36
  memberId: string
@@ -52,18 +57,14 @@ export function DangerZone({
52
57
  return (
53
58
  <section className="border-destructive/30 space-y-6 rounded-lg border p-6">
54
59
  <div>
55
- <h2 className="text-destructive text-lg font-semibold">Danger zone</h2>
60
+ <h2 className="text-destructive text-lg font-semibold">Danger Zone</h2>
56
61
  <p className="text-muted-foreground text-sm">
57
- These actions are irreversible. Proceed with care.
62
+ These actions are hard or impossible to undo.
58
63
  </p>
59
64
  </div>
60
65
 
61
66
  {isOwner && transferTargets.length > 0 ? (
62
- <TransferOwnershipRow
63
- orgId={orgId}
64
- isOwner={isOwner}
65
- transferTargets={transferTargets}
66
- />
67
+ <TransferOwnershipRow orgId={orgId} transferTargets={transferTargets} />
67
68
  ) : null}
68
69
 
69
70
  <LeaveOrganizationRow
@@ -101,11 +102,9 @@ function Row({
101
102
 
102
103
  function TransferOwnershipRow({
103
104
  orgId,
104
- isOwner,
105
105
  transferTargets
106
106
  }: {
107
107
  orgId: string
108
- isOwner: boolean
109
108
  transferTargets: TransferTarget[]
110
109
  }) {
111
110
  const router = useRouter()
@@ -120,17 +119,12 @@ function TransferOwnershipRow({
120
119
  setOpen(false)
121
120
  router.refresh()
122
121
  },
123
- onError: error => {
124
- toastErrorMessage(error.message ?? 'Failed to transfer ownership')
125
- }
122
+ onError: error =>
123
+ toastActionError(error, 'Ownership was not transferred. Try again.')
126
124
  }
127
125
  )
128
126
 
129
127
  function handleTransfer() {
130
- if (!isOwner) {
131
- toastErrorMessage('Only the organization owner can transfer ownership.')
132
- return
133
- }
134
128
  if (!targetId) return
135
129
 
136
130
  transferOwnership.mutate({
@@ -141,8 +135,8 @@ function TransferOwnershipRow({
141
135
 
142
136
  return (
143
137
  <Row
144
- title="Transfer ownership"
145
- description="Make another member the owner. You'll become an admin."
138
+ title="Transfer Ownership"
139
+ description="Make another member the owner. You become an admin."
146
140
  >
147
141
  <Dialog open={open} onOpenChange={setOpen}>
148
142
  <DialogTrigger asChild>
@@ -150,17 +144,14 @@ function TransferOwnershipRow({
150
144
  </DialogTrigger>
151
145
  <DialogContent className="sm:max-w-md">
152
146
  <DialogHeader>
153
- <DialogTitle>Transfer ownership</DialogTitle>
154
- <DialogDescription>
155
- Select the member who should become the new owner. You&apos;ll be
156
- demoted to admin.
157
- </DialogDescription>
147
+ <DialogTitle>Transfer Ownership</DialogTitle>
148
+ <DialogDescription>{OWNERSHIP_DEMOTION_NOTICE}</DialogDescription>
158
149
  </DialogHeader>
159
150
 
160
151
  <div className="space-y-2 py-1">
161
- <Label>New owner</Label>
152
+ <Label htmlFor="new-owner">New owner</Label>
162
153
  <Select value={targetId} onValueChange={setTargetId}>
163
- <SelectTrigger className="w-full">
154
+ <SelectTrigger id="new-owner" className="w-full">
164
155
  <SelectValue placeholder="Select a member" />
165
156
  </SelectTrigger>
166
157
  <SelectContent>
@@ -186,9 +177,9 @@ function TransferOwnershipRow({
186
177
  disabled={transferOwnership.isPending || !targetId}
187
178
  >
188
179
  {transferOwnership.isPending && (
189
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
180
+ <Loader2 className="animate-spin" />
190
181
  )}
191
- Transfer ownership
182
+ Transfer Ownership
192
183
  </Button>
193
184
  </DialogFooter>
194
185
  </DialogContent>
@@ -214,7 +205,7 @@ function LeaveOrganizationRow({
214
205
  // Guard: the only owner must transfer ownership before leaving.
215
206
  if (isSoleOwner) {
216
207
  toastErrorMessage(
217
- 'Transfer ownership to another member before leaving this organization.'
208
+ 'Transfer ownership to another member before you leave this organization.'
218
209
  )
219
210
  return
220
211
  }
@@ -223,18 +214,20 @@ function LeaveOrganizationRow({
223
214
  const { error } = await organization.leave({ organizationId: orgId })
224
215
 
225
216
  if (error) {
226
- toastErrorMessage(error.message ?? 'Failed to leave organization')
217
+ toastActionError(error, 'You are still a member. Try again.')
227
218
  setSubmitting(false)
228
219
  return
229
220
  }
230
221
 
231
222
  toastSuccessMessage(`You left ${orgName}`)
232
223
  router.push('/app')
224
+ // Re-render the app layout so the sidebar drops the organization.
225
+ router.refresh()
233
226
  }
234
227
 
235
228
  return (
236
229
  <Row
237
- title="Leave organization"
230
+ title="Leave Organization"
238
231
  description="Remove yourself from this organization."
239
232
  >
240
233
  <Dialog open={open} onOpenChange={setOpen}>
@@ -244,12 +237,12 @@ function LeaveOrganizationRow({
244
237
  <DialogContent className="sm:max-w-md">
245
238
  <DialogHeader>
246
239
  <DialogTitle>
247
- {isSoleOwner ? 'Transfer ownership first' : `Leave ${orgName}?`}
240
+ {isSoleOwner ? 'Transfer Ownership First' : `Leave ${orgName}`}
248
241
  </DialogTitle>
249
242
  <DialogDescription>
250
243
  {isSoleOwner
251
- ? "You're the only owner of this organization. Make another member the owner before you leave - or delete the organization instead."
252
- : "You'll lose access to this organization. An owner or admin will need to invite you back."}
244
+ ? "You're the only owner. Make another member the owner before you leave, or delete the organization instead."
245
+ : 'You lose access to this organization. An owner or admin must invite you to return.'}
253
246
  </DialogDescription>
254
247
  </DialogHeader>
255
248
  <DialogFooter>
@@ -266,10 +259,8 @@ function LeaveOrganizationRow({
266
259
  onClick={handleLeave}
267
260
  disabled={submitting}
268
261
  >
269
- {submitting && (
270
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
271
- )}
272
- Leave organization
262
+ {submitting && <Loader2 className="animate-spin" />}
263
+ Leave Organization
273
264
  </Button>
274
265
  ) : null}
275
266
  </DialogFooter>
@@ -296,18 +287,19 @@ function DeleteOrganizationRow({
296
287
  const { error } = await organization.delete({ organizationId: orgId })
297
288
 
298
289
  if (error) {
299
- toastErrorMessage(error.message ?? 'Failed to delete organization')
290
+ toastActionError(error, 'The organization was not deleted. Try again.')
300
291
  setSubmitting(false)
301
292
  return
302
293
  }
303
294
 
304
295
  toastSuccessMessage(`${orgName} deleted`)
305
296
  router.push('/app')
297
+ router.refresh()
306
298
  }
307
299
 
308
300
  return (
309
301
  <Row
310
- title="Delete organization"
302
+ title="Delete Organization"
311
303
  description="Permanently delete this organization and all of its data."
312
304
  >
313
305
  <Dialog
@@ -322,18 +314,19 @@ function DeleteOrganizationRow({
322
314
  </DialogTrigger>
323
315
  <DialogContent className="sm:max-w-md">
324
316
  <DialogHeader>
325
- <DialogTitle>Delete {orgName}?</DialogTitle>
317
+ <DialogTitle>Delete {orgName}</DialogTitle>
326
318
  <DialogDescription>
327
- This cannot be undone. All members, invitations and data for this
328
- organization will be permanently removed.
319
+ This permanently removes every member, invitation, and file, and
320
+ cancels the subscription. You cannot undo it.
329
321
  </DialogDescription>
330
322
  </DialogHeader>
331
323
 
332
324
  <div className="space-y-2 py-1">
333
- <Label>
325
+ <Label htmlFor="confirm-delete">
334
326
  Type <span className="font-semibold">{orgName}</span> to confirm
335
327
  </Label>
336
328
  <Input
329
+ id="confirm-delete"
337
330
  value={confirmText}
338
331
  onChange={e => setConfirmText(e.target.value)}
339
332
  placeholder={orgName}
@@ -353,8 +346,8 @@ function DeleteOrganizationRow({
353
346
  onClick={handleDelete}
354
347
  disabled={submitting || confirmText !== orgName}
355
348
  >
356
- {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
357
- Delete organization
349
+ {submitting && <Loader2 className="animate-spin" />}
350
+ Delete Organization
358
351
  </Button>
359
352
  </DialogFooter>
360
353
  </DialogContent>