create-ej-app 26.0.0 → 28.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +47 -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 +50 -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
|
@@ -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
|
|
7
|
+
export default function DashboardPage() {
|
|
8
8
|
const org = useOrganizationContext()
|
|
9
9
|
|
|
10
10
|
return (
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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}
|
package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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>
|
package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx
CHANGED
|
@@ -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/
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
145
|
-
description="Make another member the owner. You
|
|
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
|
|
154
|
-
<DialogDescription>
|
|
155
|
-
Select the member who should become the new owner. You'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="
|
|
180
|
+
<Loader2 className="animate-spin" />
|
|
190
181
|
)}
|
|
191
|
-
Transfer
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
240
|
+
{isSoleOwner ? 'Transfer Ownership First' : `Leave ${orgName}`}
|
|
248
241
|
</DialogTitle>
|
|
249
242
|
<DialogDescription>
|
|
250
243
|
{isSoleOwner
|
|
251
|
-
? "You're the only owner
|
|
252
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
317
|
+
<DialogTitle>Delete {orgName}</DialogTitle>
|
|
326
318
|
<DialogDescription>
|
|
327
|
-
This
|
|
328
|
-
|
|
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="
|
|
357
|
-
Delete
|
|
349
|
+
{submitting && <Loader2 className="animate-spin" />}
|
|
350
|
+
Delete Organization
|
|
358
351
|
</Button>
|
|
359
352
|
</DialogFooter>
|
|
360
353
|
</DialogContent>
|