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.
- package/README.md +125 -226
- package/cli.ts +79 -29
- package/package.json +2 -2
- package/template/api/.vscode/settings.json +1 -1
- package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
- package/template/api/CLAUDE.md +1 -0
- package/template/api/package.json +10 -10
- package/template/nextjs/.dockerignore +1 -0
- package/template/nextjs/AGENTS.md +260 -0
- package/template/nextjs/CLAUDE.md +1 -0
- package/template/nextjs/README.md +109 -0
- package/template/nextjs/bunfig.toml +3 -0
- package/template/nextjs/eslint.config.mjs +6 -5
- package/template/nextjs/gitignore +6 -1
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +46 -39
- package/template/nextjs/public/logo-dark.svg +4 -0
- package/template/nextjs/public/logo.svg +4 -0
- package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
- package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
- package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
- package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
- package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
- package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
- package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
- package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
- package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
- package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
- package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
- package/template/nextjs/src/app/error.tsx +37 -0
- package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/index.ts +60 -0
- package/template/nextjs/src/app/global-error.tsx +38 -0
- package/template/nextjs/src/app/globals.css +29 -0
- package/template/nextjs/src/app/layout.tsx +45 -18
- package/template/nextjs/src/app/not-found.tsx +25 -3
- package/template/nextjs/src/app/robots.ts +29 -0
- package/template/nextjs/src/app/sitemap.ts +21 -0
- package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
- package/template/nextjs/src/components/dashboard-header.tsx +70 -0
- package/template/nextjs/src/components/data-table/enum.ts +5 -0
- package/template/nextjs/src/components/data-table/features.ts +49 -0
- package/template/nextjs/src/components/data-table/index.tsx +961 -0
- package/template/nextjs/src/components/data-table/types.ts +76 -0
- package/template/nextjs/src/components/error.tsx +71 -0
- package/template/nextjs/src/components/loader.tsx +18 -2
- package/template/nextjs/src/components/logo.tsx +49 -0
- package/template/nextjs/src/components/navigation-search.tsx +140 -0
- package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
- package/template/nextjs/src/components/page-heading.tsx +38 -0
- package/template/nextjs/src/components/react-query-provider.tsx +41 -2
- package/template/nextjs/src/components/route-error.tsx +55 -0
- package/template/nextjs/src/components/screen-size.tsx +36 -0
- package/template/nextjs/src/components/site-footer.tsx +20 -4
- package/template/nextjs/src/components/theme-provider.tsx +48 -0
- package/template/nextjs/src/components/theme-toggle.tsx +58 -5
- package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
- package/template/nextjs/src/components/ui/avatar.tsx +112 -0
- package/template/nextjs/src/components/ui/badge.tsx +54 -0
- package/template/nextjs/src/components/ui/button.tsx +3 -1
- package/template/nextjs/src/components/ui/card.tsx +4 -4
- package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
- package/template/nextjs/src/components/ui/command.tsx +192 -0
- package/template/nextjs/src/components/ui/dialog.tsx +164 -0
- package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/nextjs/src/components/ui/input-group.tsx +156 -0
- package/template/nextjs/src/components/ui/select.tsx +195 -0
- package/template/nextjs/src/components/ui/sheet.tsx +143 -0
- package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
- package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
- package/template/nextjs/src/components/ui/table.tsx +116 -0
- package/template/nextjs/src/components/ui/textarea.tsx +18 -0
- package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
- package/template/nextjs/src/components/user-menu.tsx +206 -0
- package/template/nextjs/src/db/common.ts +10 -0
- package/template/nextjs/src/db/index.ts +16 -6
- package/template/nextjs/src/db/relations.ts +21 -0
- package/template/nextjs/src/db/schema.ts +85 -46
- package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
- package/template/nextjs/src/emails/magic-link.tsx +15 -13
- package/template/nextjs/src/emails/test.tsx +11 -9
- package/template/nextjs/src/emails/welcome.tsx +12 -9
- package/template/nextjs/src/env.ts +1 -1
- package/template/nextjs/src/hooks/use-mobile.ts +14 -12
- package/template/nextjs/src/instrumentation.ts +20 -2
- package/template/nextjs/src/lib/actions.ts +55 -0
- package/template/nextjs/src/lib/admin-navigation.ts +43 -0
- package/template/nextjs/src/lib/auth-client.ts +20 -9
- package/template/nextjs/src/lib/auth-permissions.ts +61 -0
- package/template/nextjs/src/lib/auth.ts +33 -3
- package/template/nextjs/src/lib/callback-url.ts +28 -0
- package/template/nextjs/src/lib/constants.ts +21 -0
- package/template/nextjs/src/lib/delay.ts +10 -0
- package/template/nextjs/src/lib/email-service.ts +21 -0
- package/template/nextjs/src/lib/errors.ts +74 -0
- package/template/nextjs/src/lib/format-currency.ts +22 -24
- package/template/nextjs/src/lib/format-date.ts +69 -6
- package/template/nextjs/src/lib/nodemailer.ts +27 -3
- package/template/nextjs/src/lib/otel.ts +92 -2
- package/template/nextjs/src/lib/safe-action-client.ts +60 -9
- package/template/nextjs/src/lib/safe-action.ts +152 -16
- package/template/nextjs/src/lib/search.ts +16 -0
- package/template/nextjs/src/lib/seo.ts +7 -0
- package/template/nextjs/src/lib/siteConfig.ts +17 -1
- package/template/nextjs/src/lib/toast-message.ts +87 -1
- package/template/nextjs/src/lib/validations.ts +30 -0
- package/template/nextjs/src/proxy.ts +48 -0
- package/template/nextjs/src/scripts/reset-db.ts +22 -0
- package/template/nextjs/src/scripts/seed.ts +59 -8
- package/template/nextjs/src/scripts/test-mail.ts +28 -20
- package/template/nextjs/tests/admin/users.test.ts +64 -0
- package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
- package/template/nextjs/tests/db/seed.test.ts +49 -0
- package/template/nextjs/tests/helpers/action.ts +71 -0
- package/template/nextjs/tests/helpers/fixtures.ts +78 -0
- package/template/nextjs/tests/helpers/index.ts +2 -0
- package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
- package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/nextjs/tests/lib/errors.test.ts +10 -0
- package/template/nextjs/tests/lib/search.test.ts +23 -0
- package/template/nextjs/tests/setup/pglite-db.ts +63 -0
- package/template/nextjs/tests/setup/preload.ts +95 -0
- package/template/nextjs/tsconfig.json +1 -0
- package/template/saas-starter/.dockerignore +1 -0
- package/template/saas-starter/.env.example +15 -15
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/README.md +124 -2
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/eslint.config.mjs +6 -5
- package/template/saas-starter/gitignore +6 -1
- package/template/saas-starter/next.config.ts +19 -1
- package/template/saas-starter/package.json +49 -44
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
- package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
- package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
- package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
- package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
- package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
- package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
- package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
- package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
- package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
- package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
- package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
- package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
- package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
- package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
- package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
- package/template/saas-starter/src/app/actions/uploads.ts +36 -0
- package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/index.ts +60 -0
- package/template/saas-starter/src/app/global-error.tsx +38 -0
- package/template/saas-starter/src/app/globals.css +29 -0
- package/template/saas-starter/src/app/layout.tsx +43 -19
- package/template/saas-starter/src/app/not-found.tsx +25 -3
- package/template/saas-starter/src/app/robots.ts +14 -10
- package/template/saas-starter/src/app/sitemap.ts +2 -1
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +65 -64
- package/template/saas-starter/src/components/data-table/types.ts +5 -4
- package/template/saas-starter/src/components/error.tsx +45 -0
- package/template/saas-starter/src/components/file-upload.tsx +65 -26
- package/template/saas-starter/src/components/loader.tsx +18 -2
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +2 -2
- package/template/saas-starter/src/components/page-heading.tsx +6 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +36 -0
- package/template/saas-starter/src/components/site-footer.tsx +20 -4
- package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
- package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
- package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
- package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
- package/template/saas-starter/src/components/theme-provider.tsx +48 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/badge.tsx +6 -1
- package/template/saas-starter/src/components/ui/button.tsx +2 -0
- package/template/saas-starter/src/components/ui/command.tsx +1 -1
- package/template/saas-starter/src/components/user-menu.tsx +147 -24
- package/template/saas-starter/src/db/common.ts +33 -1
- package/template/saas-starter/src/db/enums.ts +24 -0
- package/template/saas-starter/src/db/index.ts +14 -5
- package/template/saas-starter/src/db/relations.ts +40 -3
- package/template/saas-starter/src/db/schema.ts +186 -35
- package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
- package/template/saas-starter/src/emails/invitation.tsx +34 -21
- package/template/saas-starter/src/emails/magic-link.tsx +15 -13
- package/template/saas-starter/src/emails/test.tsx +11 -9
- package/template/saas-starter/src/emails/welcome.tsx +12 -9
- package/template/saas-starter/src/env.ts +1 -1
- package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
- package/template/saas-starter/src/instrumentation.ts +20 -2
- package/template/saas-starter/src/lib/actions.ts +34 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
- package/template/saas-starter/src/lib/app-navigation.ts +45 -41
- package/template/saas-starter/src/lib/auth-client.ts +8 -3
- package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
- package/template/saas-starter/src/lib/auth.ts +122 -21
- package/template/saas-starter/src/lib/billing.ts +59 -24
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/constants.ts +78 -9
- package/template/saas-starter/src/lib/delay.ts +10 -0
- package/template/saas-starter/src/lib/email-service.ts +30 -1
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +22 -24
- package/template/saas-starter/src/lib/format-date.ts +69 -6
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +27 -3
- package/template/saas-starter/src/lib/organization-access.ts +56 -76
- package/template/saas-starter/src/lib/otel.ts +92 -2
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +40 -13
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
- package/template/saas-starter/src/lib/safe-action.ts +137 -17
- package/template/saas-starter/src/lib/search.ts +16 -0
- package/template/saas-starter/src/lib/seo.ts +7 -0
- package/template/saas-starter/src/lib/siteConfig.ts +17 -1
- package/template/saas-starter/src/lib/storage.ts +176 -46
- package/template/saas-starter/src/lib/stripe.ts +4 -1
- package/template/saas-starter/src/lib/toast-message.ts +87 -1
- package/template/saas-starter/src/lib/validations.ts +85 -2
- package/template/saas-starter/src/proxy.ts +48 -0
- package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
- package/template/saas-starter/src/scripts/seed.ts +82 -8
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
- package/template/saas-starter/src/scripts/test-mail.ts +28 -20
- package/template/saas-starter/tests/admin/users.test.ts +64 -0
- package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
- package/template/saas-starter/tests/db/seed.test.ts +57 -0
- package/template/saas-starter/tests/helpers/action.ts +71 -0
- package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
- package/template/saas-starter/tests/helpers/index.ts +2 -0
- package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
- package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/saas-starter/tests/lib/errors.test.ts +10 -0
- package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
- package/template/saas-starter/tests/lib/search.test.ts +23 -0
- package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
- package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
- package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
- package/template/saas-starter/tests/setup/preload.ts +108 -0
- package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
- package/template/saas-starter/tsconfig.json +1 -0
- package/cli.js +0 -147
- package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
- package/template/nextjs/src/app/(app)/layout.tsx +0 -20
- package/template/nextjs/terraform/artifact-registry.tf +0 -5
- package/template/nextjs/terraform/backend-service.tf +0 -13
- package/template/nextjs/terraform/cleanup.sh +0 -9
- package/template/nextjs/terraform/cloudbuild.tf +0 -70
- package/template/nextjs/terraform/cloudrun.tf +0 -68
- package/template/nextjs/terraform/database.tf +0 -60
- package/template/nextjs/terraform/forwarding-rule.tf +0 -43
- package/template/nextjs/terraform/gitignore +0 -4
- package/template/nextjs/terraform/ip-address.tf +0 -11
- package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
- package/template/nextjs/terraform/network.tf +0 -21
- package/template/nextjs/terraform/pre.sh +0 -3
- package/template/nextjs/terraform/providers.tf +0 -18
- package/template/nextjs/terraform/secret.txt +0 -11
- package/template/nextjs/terraform/setup.sh +0 -25
- package/template/nextjs/terraform/ssl-certs.tf +0 -32
- package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
- package/template/nextjs/terraform/target-https-proxy.tf +0 -14
- package/template/nextjs/terraform/terraform.tfvars +0 -4
- package/template/nextjs/terraform/url-map.tf +0 -23
- package/template/nextjs/terraform/variables.tf +0 -21
- package/template/saas-starter/next-env.d.ts +0 -6
- package/template/saas-starter/src/actions/uploads.ts +0 -28
- package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
- package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
- package/template/saas-starter/src/app/proxy.ts +0 -28
- package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
- package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
|
@@ -1,93 +1,73 @@
|
|
|
1
1
|
import 'server-only'
|
|
2
2
|
|
|
3
|
-
import { and,
|
|
4
|
-
import { cache } from 'react'
|
|
3
|
+
import { and, eq, inArray } from 'drizzle-orm'
|
|
5
4
|
import { db } from '~/db'
|
|
6
|
-
import { membersTable
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import { resolveImageUrl } from './storage'
|
|
5
|
+
import { membersTable } from '~/db/schema'
|
|
6
|
+
import { AppError, AppErrorCode } from './errors'
|
|
7
|
+
import type { OrganizationRole } from './rbac'
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
.select({
|
|
17
|
-
id: organizationsTable.id,
|
|
18
|
-
name: organizationsTable.name,
|
|
19
|
-
slug: organizationsTable.slug,
|
|
20
|
-
logo: organizationsTable.logo,
|
|
21
|
-
role: membersTable.role,
|
|
22
|
-
userId: membersTable.userId,
|
|
23
|
-
memberId: membersTable.id
|
|
24
|
-
})
|
|
25
|
-
.from(membersTable)
|
|
26
|
-
.innerJoin(
|
|
27
|
-
organizationsTable,
|
|
28
|
-
eq(membersTable.organizationId, organizationsTable.id)
|
|
29
|
-
)
|
|
30
|
-
.where(eq(membersTable.userId, session.user.id))
|
|
31
|
-
.orderBy(desc(membersTable.createdAt))
|
|
32
|
-
|
|
33
|
-
return orgs.map(o => ({ ...o, logo: resolveImageUrl(o.logo) }))
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const getUserOrganizationsCached = cache(getUserOrganizations)
|
|
37
|
-
|
|
38
|
-
export const getUserMembershipCached = cache(async (organizationId: string) => {
|
|
39
|
-
const session = await getAuthSession()
|
|
40
|
-
if (!session) return null
|
|
41
|
-
|
|
42
|
-
const member = await db.query.membersTable.findFirst({
|
|
9
|
+
/**
|
|
10
|
+
* The caller's membership in an organization, or null when they have none.
|
|
11
|
+
*/
|
|
12
|
+
export async function getMembership(organizationId: string, userId: string) {
|
|
13
|
+
const membership = await db.query.membersTable.findFirst({
|
|
43
14
|
where: and(
|
|
44
15
|
eq(membersTable.organizationId, organizationId),
|
|
45
|
-
eq(membersTable.userId,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return member ?? null
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
export const getOrganizationMembers = cache(async (organizationId: string) =>
|
|
53
|
-
db.query.membersTable.findMany({
|
|
54
|
-
where: eq(membersTable.organizationId, organizationId),
|
|
55
|
-
with: {
|
|
56
|
-
user: {
|
|
57
|
-
columns: { id: true, name: true, email: true, image: true }
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
orderBy: [desc(membersTable.createdAt)]
|
|
16
|
+
eq(membersTable.userId, userId)
|
|
17
|
+
),
|
|
18
|
+
columns: { id: true, role: true }
|
|
61
19
|
})
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
export const getOrganizationMemberCount = cache(
|
|
65
|
-
async (organizationId: string) => {
|
|
66
|
-
const [row] = await db
|
|
67
|
-
.select({ count: count() })
|
|
68
|
-
.from(membersTable)
|
|
69
|
-
.where(eq(membersTable.organizationId, organizationId))
|
|
70
20
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
21
|
+
return membership ?? null
|
|
22
|
+
}
|
|
74
23
|
|
|
75
|
-
|
|
76
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Asserts the caller belongs to the organization, in any role.
|
|
26
|
+
* Throws {@link AppError} with code FORBIDDEN otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export async function assertMember(organizationId: string, userId: string) {
|
|
29
|
+
const membership = await getMembership(organizationId, userId)
|
|
77
30
|
if (!membership) {
|
|
78
|
-
throw new
|
|
31
|
+
throw new AppError(
|
|
32
|
+
AppErrorCode.FORBIDDEN,
|
|
33
|
+
'You do not have access to this organization.'
|
|
34
|
+
)
|
|
79
35
|
}
|
|
80
36
|
return membership
|
|
81
37
|
}
|
|
82
38
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Asserts the caller holds one of `allowedRoles` in the organization.
|
|
41
|
+
*/
|
|
42
|
+
export async function assertRole(
|
|
43
|
+
organizationId: string,
|
|
44
|
+
userId: string,
|
|
45
|
+
allowedRoles: ReadonlyArray<OrganizationRole>
|
|
46
|
+
) {
|
|
47
|
+
const membership = await db.query.membersTable.findFirst({
|
|
48
|
+
where: and(
|
|
49
|
+
eq(membersTable.organizationId, organizationId),
|
|
50
|
+
eq(membersTable.userId, userId),
|
|
51
|
+
inArray(membersTable.role, [...allowedRoles])
|
|
52
|
+
),
|
|
53
|
+
columns: { id: true, role: true }
|
|
54
|
+
})
|
|
55
|
+
if (!membership) {
|
|
56
|
+
throw new AppError(
|
|
57
|
+
AppErrorCode.FORBIDDEN,
|
|
58
|
+
'You do not have permission to perform this action.'
|
|
59
|
+
)
|
|
91
60
|
}
|
|
92
61
|
return membership
|
|
93
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Asserts the caller can manage the organization (owner or admin). Use it for
|
|
66
|
+
* settings, billing, members, and uploads.
|
|
67
|
+
*/
|
|
68
|
+
export function assertCanManageOrganization(
|
|
69
|
+
organizationId: string,
|
|
70
|
+
userId: string
|
|
71
|
+
) {
|
|
72
|
+
return assertRole(organizationId, userId, ['owner', 'admin'])
|
|
73
|
+
}
|
|
@@ -1,17 +1,107 @@
|
|
|
1
|
-
import { SpanStatusCode, trace } from '@opentelemetry/api'
|
|
1
|
+
import { SpanStatusCode, trace, type Attributes } from '@opentelemetry/api'
|
|
2
2
|
|
|
3
|
+
const MAX_STRING_ATTR_LENGTH = 256
|
|
4
|
+
const MAX_ARRAY_ATTR_ITEMS = 20
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helper function to retrieve the currently active span from the OpenTelemetry context.
|
|
8
|
+
* Useful for attaching additional information to the current trace.
|
|
9
|
+
*/
|
|
3
10
|
function getSpan() {
|
|
4
11
|
const span = trace.getActiveSpan()
|
|
5
12
|
return span
|
|
6
13
|
}
|
|
7
14
|
|
|
8
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Records a "wide event" with arbitrary attributes to the current span.
|
|
17
|
+
* This allows adding high-cardinality data or domain-specific context
|
|
18
|
+
* to the trace without starting a new child span.
|
|
19
|
+
*
|
|
20
|
+
* @param attributes - Key-value pairs of attributes to add to the span
|
|
21
|
+
*/
|
|
22
|
+
export function recordWideEvent(attributes: Attributes) {
|
|
9
23
|
const span = getSpan()
|
|
10
24
|
if (!span) return
|
|
11
25
|
|
|
12
26
|
span.setAttributes(attributes)
|
|
13
27
|
}
|
|
14
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Flatten a value tree into OTEL-safe attributes under `prefix`.
|
|
31
|
+
* Nested objects use dotted keys; long strings are truncated; large
|
|
32
|
+
* arrays are capped. Use for action `parsedInput` on wide events.
|
|
33
|
+
*/
|
|
34
|
+
export function flattenForWideEvent(
|
|
35
|
+
value: unknown,
|
|
36
|
+
prefix = 'input'
|
|
37
|
+
): Attributes {
|
|
38
|
+
const out: Attributes = {}
|
|
39
|
+
|
|
40
|
+
const visit = (current: unknown, path: string) => {
|
|
41
|
+
if (current === null || current === undefined) return
|
|
42
|
+
|
|
43
|
+
if (typeof current === 'string') {
|
|
44
|
+
out[path] =
|
|
45
|
+
current.length > MAX_STRING_ATTR_LENGTH
|
|
46
|
+
? `${current.slice(0, MAX_STRING_ATTR_LENGTH)}…`
|
|
47
|
+
: current
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof current === 'number' || typeof current === 'boolean') {
|
|
52
|
+
out[path] = current
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof current === 'bigint') {
|
|
57
|
+
out[path] = current.toString()
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(current)) {
|
|
62
|
+
const limited = current.slice(0, MAX_ARRAY_ATTR_ITEMS)
|
|
63
|
+
const allPrimitive = limited.every(
|
|
64
|
+
item =>
|
|
65
|
+
item === null ||
|
|
66
|
+
item === undefined ||
|
|
67
|
+
typeof item === 'string' ||
|
|
68
|
+
typeof item === 'number' ||
|
|
69
|
+
typeof item === 'boolean'
|
|
70
|
+
)
|
|
71
|
+
if (allPrimitive) {
|
|
72
|
+
out[path] = limited.map(item =>
|
|
73
|
+
item === null || item === undefined ? '' : String(item)
|
|
74
|
+
)
|
|
75
|
+
if (current.length > MAX_ARRAY_ATTR_ITEMS) {
|
|
76
|
+
out[`${path}.length`] = current.length
|
|
77
|
+
}
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
out[`${path}.length`] = current.length
|
|
81
|
+
limited.forEach((item, index) => visit(item, `${path}.${index}`))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof current === 'object') {
|
|
86
|
+
for (const [key, child] of Object.entries(
|
|
87
|
+
current as Record<string, unknown>
|
|
88
|
+
)) {
|
|
89
|
+
visit(child, path ? `${path}.${key}` : key)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
visit(value, prefix)
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Records an exception to the current span and sets the span status to ERROR.
|
|
100
|
+
* captures the error message and stack trace if available.
|
|
101
|
+
*
|
|
102
|
+
* @param err - The error to record (can be an Error object or unknown)
|
|
103
|
+
* @returns The trace ID associated with the error, useful for correlation
|
|
104
|
+
*/
|
|
15
105
|
export function recordError(err: unknown) {
|
|
16
106
|
const span = getSpan()
|
|
17
107
|
if (!span) return
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { eq } from 'drizzle-orm'
|
|
4
|
+
import { cache } from 'react'
|
|
5
|
+
import { db } from '~/db'
|
|
6
|
+
import { organizationSubscriptionsTable } from '~/db/schema'
|
|
7
|
+
import { AppError, AppErrorCode } from './errors'
|
|
8
|
+
import { PLANS, isActiveSubscriptionStatus } from './plans'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The plan an organization is entitled to right now, and its subscription row.
|
|
12
|
+
*
|
|
13
|
+
* Only a trialing, active, or past-due subscription grants its paid plan;
|
|
14
|
+
* every other status falls back to the free plan.
|
|
15
|
+
*/
|
|
16
|
+
export const getOrganizationPlan = cache(async (organizationId: string) => {
|
|
17
|
+
const sub = await db.query.organizationSubscriptionsTable.findFirst({
|
|
18
|
+
where: eq(organizationSubscriptionsTable.organizationId, organizationId)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
if (!sub) {
|
|
22
|
+
throw new AppError(
|
|
23
|
+
AppErrorCode.NOT_FOUND,
|
|
24
|
+
'This organization has no subscription record.'
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const planId = isActiveSubscriptionStatus(sub.status) ? sub.plan : 'free'
|
|
29
|
+
const plan = PLANS.find(candidate => candidate.id === planId)
|
|
30
|
+
|
|
31
|
+
if (!plan) {
|
|
32
|
+
// A configuration bug, not a customer error: masked and recorded.
|
|
33
|
+
throw new Error(`Plan ${planId} is missing from the plan registry.`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { plan, sub }
|
|
37
|
+
})
|
|
@@ -1,24 +1,25 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
1
3
|
import { env } from '~/env'
|
|
2
4
|
|
|
5
|
+
export type PlanId = 'free' | 'pro' | 'ultimate'
|
|
6
|
+
|
|
7
|
+
type PlanPrice = {
|
|
8
|
+
id: string
|
|
9
|
+
/** Minor units, as Stripe stores them: 2000 is $20.00. */
|
|
10
|
+
amount: number
|
|
11
|
+
currency: string
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
export type Plan = {
|
|
4
|
-
id:
|
|
15
|
+
id: PlanId
|
|
5
16
|
name: string
|
|
6
17
|
maxMembers: number
|
|
7
|
-
stripePrice?: {
|
|
8
|
-
monthly: {
|
|
9
|
-
id: string
|
|
10
|
-
amount: number
|
|
11
|
-
currency: string
|
|
12
|
-
}
|
|
13
|
-
annual: {
|
|
14
|
-
id: string
|
|
15
|
-
amount: number
|
|
16
|
-
currency: string
|
|
17
|
-
}
|
|
18
|
-
}
|
|
18
|
+
stripePrice?: { monthly: PlanPrice; annual: PlanPrice }
|
|
19
19
|
canUseTrial?: boolean
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Every plan, cheapest first. Stripe prices come from `bun run stripe:setup`. */
|
|
22
23
|
export const PLANS: Plan[] = [
|
|
23
24
|
{
|
|
24
25
|
id: 'free',
|
|
@@ -61,3 +62,29 @@ export const PLANS: Plan[] = [
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
]
|
|
65
|
+
|
|
66
|
+
/** Subscription statuses that keep the paid plan's features switched on. */
|
|
67
|
+
export const ACTIVE_SUBSCRIPTION_STATUSES = [
|
|
68
|
+
'trialing',
|
|
69
|
+
'active',
|
|
70
|
+
'past_due'
|
|
71
|
+
] as const
|
|
72
|
+
|
|
73
|
+
export function isActiveSubscriptionStatus(status: string): boolean {
|
|
74
|
+
return (ACTIVE_SUBSCRIPTION_STATUSES as readonly string[]).includes(status)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Statuses with no live Stripe subscription behind them. Any other status
|
|
79
|
+
* (including `unpaid`, `paused`, and `incomplete`) still bills or can resume,
|
|
80
|
+
* so changes go through the billing portal instead of a new checkout.
|
|
81
|
+
*/
|
|
82
|
+
export const ENDED_SUBSCRIPTION_STATUSES = [
|
|
83
|
+
'free',
|
|
84
|
+
'canceled',
|
|
85
|
+
'incomplete_expired'
|
|
86
|
+
] as const
|
|
87
|
+
|
|
88
|
+
export function hasLiveSubscription(status: string): boolean {
|
|
89
|
+
return !(ENDED_SUBSCRIPTION_STATUSES as readonly string[]).includes(status)
|
|
90
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organization role predicates and labels, shared by server and client.
|
|
3
|
+
*
|
|
4
|
+
* The roles are the Better Auth organization plugin defaults:
|
|
5
|
+
* - owner: full control, including ownership transfer and deletion
|
|
6
|
+
* - admin: manages settings, billing, and members, but not ownership
|
|
7
|
+
* - member: standard access to the organization's workspace
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type OrganizationRole = 'owner' | 'admin' | 'member'
|
|
11
|
+
|
|
12
|
+
/** Roles an owner or admin can give to someone. Ownership moves only by transfer. */
|
|
13
|
+
export const ASSIGNABLE_ROLES = ['admin', 'member'] as const
|
|
14
|
+
|
|
15
|
+
export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number]
|
|
16
|
+
|
|
17
|
+
/** True for the manager tier: owner and admin. */
|
|
18
|
+
export function isManagerRole(role: string | null | undefined): boolean {
|
|
19
|
+
return role === 'owner' || role === 'admin'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Descriptions shown when you pick a role for a member or an invitation. */
|
|
23
|
+
export const ROLE_DESCRIPTIONS: Record<AssignableRole, string> = {
|
|
24
|
+
admin: 'Admin: manage settings, billing, and members',
|
|
25
|
+
member: 'Member: use the workspace'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shown to an owner before they transfer ownership to another member. */
|
|
29
|
+
export const OWNERSHIP_DEMOTION_NOTICE =
|
|
30
|
+
'After the transfer, your role becomes admin: you keep every permission except ownership transfer and organization deletion.'
|
|
31
|
+
|
|
32
|
+
/** Role with its article, for sentences such as "join Acme as an admin". */
|
|
33
|
+
export function roleArticle(role: string): string {
|
|
34
|
+
switch (role) {
|
|
35
|
+
case 'owner':
|
|
36
|
+
return 'an owner'
|
|
37
|
+
case 'admin':
|
|
38
|
+
return 'an admin'
|
|
39
|
+
case 'member':
|
|
40
|
+
return 'a member'
|
|
41
|
+
default:
|
|
42
|
+
return `a ${role}`
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -8,25 +8,52 @@ import {
|
|
|
8
8
|
} from '@tanstack/react-query'
|
|
9
9
|
import type { SafeActionResult } from 'next-safe-action'
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** Type helper for any server action function */
|
|
12
|
+
export type AnyAction = (
|
|
12
13
|
...args: any[]
|
|
13
14
|
) => Promise<SafeActionResult<any, any, any, any, any>>
|
|
15
|
+
|
|
16
|
+
/** Extracts the input type from a server action */
|
|
14
17
|
type ActionInput<T extends AnyAction> = Parameters<T>[0]
|
|
18
|
+
|
|
19
|
+
/** Extracts the data type from a server action result */
|
|
15
20
|
type ActionData<T extends AnyAction> = Awaited<ReturnType<T>>['data']
|
|
16
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Client-side error for server action failures.
|
|
24
|
+
* Carries the user-facing `serverError` string (including unmasked AppError
|
|
25
|
+
* messages) and optional Zod validation errors.
|
|
26
|
+
*/
|
|
17
27
|
class SafeActionError extends Error {
|
|
18
28
|
constructor(
|
|
19
29
|
public message: string,
|
|
20
30
|
public validationErrors?: Record<string, string[] | undefined>
|
|
21
31
|
) {
|
|
22
32
|
super(message)
|
|
33
|
+
this.name = 'SafeActionError'
|
|
23
34
|
}
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
/**
|
|
38
|
+
* React Query hook for server actions that fetch data
|
|
39
|
+
* Wraps server actions with proper error handling and caching support
|
|
40
|
+
*
|
|
41
|
+
* @param key - Unique query key for caching
|
|
42
|
+
* @param action - Server action function to execute
|
|
43
|
+
* @param input - Input parameters for the server action (optional)
|
|
44
|
+
* @param options - React Query options (staleTime, refetchInterval, etc.)
|
|
45
|
+
*
|
|
46
|
+
* @returns React Query result with data, loading, and error states
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* const { data, isLoading } = useSafeActionQuery('cart-items', getCartItems)
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
26
53
|
export function useSafeActionQuery<TAction extends AnyAction>(
|
|
27
54
|
key: string,
|
|
28
55
|
action: TAction,
|
|
29
|
-
input
|
|
56
|
+
input?: ActionInput<TAction>,
|
|
30
57
|
options?: Omit<UseQueryOptions<ActionData<TAction>>, 'queryKey' | 'queryFn'>
|
|
31
58
|
) {
|
|
32
59
|
return useQuery({
|
|
@@ -39,7 +66,10 @@ export function useSafeActionQuery<TAction extends AnyAction>(
|
|
|
39
66
|
}
|
|
40
67
|
|
|
41
68
|
if (result?.validationErrors) {
|
|
42
|
-
throw new SafeActionError(
|
|
69
|
+
throw new SafeActionError(
|
|
70
|
+
'Check the highlighted fields and try again.',
|
|
71
|
+
result.validationErrors
|
|
72
|
+
)
|
|
43
73
|
}
|
|
44
74
|
|
|
45
75
|
return result?.data
|
|
@@ -48,13 +78,31 @@ export function useSafeActionQuery<TAction extends AnyAction>(
|
|
|
48
78
|
})
|
|
49
79
|
}
|
|
50
80
|
|
|
81
|
+
/**
|
|
82
|
+
* React Query mutation hook for server actions that modify data
|
|
83
|
+
* Wraps server actions with proper error handling and optimistic updates support
|
|
84
|
+
*
|
|
85
|
+
* @param action - Server action function to execute
|
|
86
|
+
* @param options - React Query mutation options (onSuccess, onError, etc.)
|
|
87
|
+
*
|
|
88
|
+
* @returns React Query mutation result with mutate function and states
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```tsx
|
|
92
|
+
* const { mutate, isPending } = useSafeActionMutation(addToCart, {
|
|
93
|
+
* onSuccess: (data) => {
|
|
94
|
+
* toastSuccessMessage('Added to cart!')
|
|
95
|
+
* queryClient.invalidateQueries({ queryKey: ['cart-items'] })
|
|
96
|
+
* },
|
|
97
|
+
* onError: (error) => {
|
|
98
|
+
* toastActionError(error, 'Failed to add to cart')
|
|
99
|
+
* }
|
|
100
|
+
* })
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
51
103
|
export function useSafeActionMutation<TAction extends AnyAction>(
|
|
52
104
|
action: TAction,
|
|
53
|
-
options?: UseMutationOptions<
|
|
54
|
-
ActionData<TAction>,
|
|
55
|
-
SafeActionError,
|
|
56
|
-
ActionInput<TAction>
|
|
57
|
-
>
|
|
105
|
+
options?: UseMutationOptions<ActionData<TAction>, Error, ActionInput<TAction>>
|
|
58
106
|
) {
|
|
59
107
|
return useMutation({
|
|
60
108
|
mutationFn: async (input: ActionInput<TAction>) => {
|
|
@@ -65,7 +113,10 @@ export function useSafeActionMutation<TAction extends AnyAction>(
|
|
|
65
113
|
}
|
|
66
114
|
|
|
67
115
|
if (result?.validationErrors) {
|
|
68
|
-
throw new SafeActionError(
|
|
116
|
+
throw new SafeActionError(
|
|
117
|
+
'Check the highlighted fields and try again.',
|
|
118
|
+
result.validationErrors
|
|
119
|
+
)
|
|
69
120
|
}
|
|
70
121
|
|
|
71
122
|
return result?.data
|