create-ej-app 25.0.0 → 27.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -226
- package/cli.ts +83 -28
- 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 -0
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +47 -40
- 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 +8 -0
- package/template/saas-starter/.env.example +25 -0
- package/template/saas-starter/.vscode/extensions.json +11 -0
- package/template/saas-starter/.vscode/settings.json +3 -0
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/Dockerfile +15 -0
- package/template/saas-starter/README.md +125 -0
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/components.json +25 -0
- package/template/saas-starter/drizzle.config.ts +10 -0
- package/template/saas-starter/eslint.config.mjs +24 -0
- package/template/saas-starter/gitignore +14 -0
- package/template/saas-starter/next.config.ts +32 -0
- package/template/saas-starter/package.json +97 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/public/placeholder.svg +1 -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 +183 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- 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 +53 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
- 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 +377 -0
- 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 +216 -0
- 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 +189 -0
- 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 +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
- 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 +168 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
- 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 +31 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
- 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 +37 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
- 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 +131 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
- 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 +48 -0
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
- 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/api/auth/[...all]/route.ts +4 -0
- package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/favicon.ico +0 -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 +269 -0
- package/template/saas-starter/src/app/layout.tsx +77 -0
- package/template/saas-starter/src/app/not-found.tsx +49 -0
- package/template/saas-starter/src/app/robots.ts +36 -0
- package/template/saas-starter/src/app/sitemap.ts +21 -0
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +961 -0
- package/template/saas-starter/src/components/data-table/types.ts +76 -0
- package/template/saas-starter/src/components/error.tsx +71 -0
- package/template/saas-starter/src/components/file-upload.tsx +254 -0
- package/template/saas-starter/src/components/loader.tsx +41 -0
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +140 -0
- package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
- package/template/saas-starter/src/components/page-heading.tsx +38 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +76 -0
- package/template/saas-starter/src/components/site-footer.tsx +39 -0
- 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 +115 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +54 -0
- package/template/saas-starter/src/components/ui/button.tsx +69 -0
- package/template/saas-starter/src/components/ui/card.tsx +103 -0
- package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
- package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
- package/template/saas-starter/src/components/ui/command.tsx +192 -0
- package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
- package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/saas-starter/src/components/ui/field.tsx +238 -0
- package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
- package/template/saas-starter/src/components/ui/input.tsx +19 -0
- package/template/saas-starter/src/components/ui/label.tsx +24 -0
- package/template/saas-starter/src/components/ui/popover.tsx +89 -0
- package/template/saas-starter/src/components/ui/select.tsx +195 -0
- package/template/saas-starter/src/components/ui/separator.tsx +28 -0
- package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
- package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
- package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
- package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
- package/template/saas-starter/src/components/ui/switch.tsx +33 -0
- package/template/saas-starter/src/components/ui/table.tsx +116 -0
- package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
- package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
- package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
- package/template/saas-starter/src/components/user-menu.tsx +213 -0
- package/template/saas-starter/src/db/common.ts +63 -0
- package/template/saas-starter/src/db/enums.ts +50 -0
- package/template/saas-starter/src/db/index.ts +60 -0
- package/template/saas-starter/src/db/relations.ts +112 -0
- package/template/saas-starter/src/db/schema.ts +342 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
- package/template/saas-starter/src/emails/invitation.tsx +69 -0
- package/template/saas-starter/src/emails/magic-link.tsx +51 -0
- package/template/saas-starter/src/emails/test.tsx +34 -0
- package/template/saas-starter/src/emails/welcome.tsx +37 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
- package/template/saas-starter/src/instrumentation.ts +44 -0
- package/template/saas-starter/src/lib/actions.ts +55 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
- package/template/saas-starter/src/lib/app-navigation.ts +63 -0
- package/template/saas-starter/src/lib/auth-client.ts +34 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
- package/template/saas-starter/src/lib/auth.ts +312 -0
- package/template/saas-starter/src/lib/billing.ts +152 -0
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +91 -0
- package/template/saas-starter/src/lib/delay.ts +12 -0
- package/template/saas-starter/src/lib/email-service.ts +118 -0
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +25 -0
- package/template/saas-starter/src/lib/format-date.ts +75 -0
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +67 -0
- package/template/saas-starter/src/lib/organization-access.ts +73 -0
- package/template/saas-starter/src/lib/otel.ts +117 -0
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +90 -0
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
- package/template/saas-starter/src/lib/safe-action.ts +187 -0
- 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 +20 -0
- package/template/saas-starter/src/lib/storage.ts +311 -0
- package/template/saas-starter/src/lib/stripe.ts +9 -0
- package/template/saas-starter/src/lib/toast-message.ts +101 -0
- package/template/saas-starter/src/lib/validations.ts +95 -0
- 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/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +85 -0
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
- package/template/saas-starter/src/scripts/test-mail.ts +43 -0
- 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 +52 -0
- package/cli.js +0 -143
- 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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Login/Sign-in Page
|
|
3
|
+
*
|
|
4
|
+
* Authentication entry point supporting multiple sign-in methods:
|
|
5
|
+
* - Email with magic link (passwordless)
|
|
6
|
+
* - OAuth (Google, GitHub)
|
|
7
|
+
*
|
|
8
|
+
* **Behavior:**
|
|
9
|
+
* - Redirects to /admin for admin users
|
|
10
|
+
* - Redirects to /app for regular authenticated users
|
|
11
|
+
* - Shows login form for anonymous users
|
|
12
|
+
*
|
|
13
|
+
* @module app/(auth)/login/page
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import Link from 'next/link'
|
|
17
|
+
import { redirect } from 'next/navigation'
|
|
18
|
+
import { Logo } from '~/components/logo'
|
|
19
|
+
import {
|
|
20
|
+
Card,
|
|
21
|
+
CardContent,
|
|
22
|
+
CardDescription,
|
|
23
|
+
CardFooter,
|
|
24
|
+
CardHeader,
|
|
25
|
+
CardTitle
|
|
26
|
+
} from '~/components/ui/card'
|
|
27
|
+
import { Separator } from '~/components/ui/separator'
|
|
28
|
+
import { getAuthSession } from '~/lib/auth'
|
|
29
|
+
import { sanitizeCallbackUrl } from '~/lib/callback-url'
|
|
30
|
+
import { EmailLoginForm } from './email-login-form'
|
|
31
|
+
import { SocialLoginButtons } from './social-login-buttons'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Login Page Component
|
|
35
|
+
*
|
|
36
|
+
* Renders authentication page with multiple login options.
|
|
37
|
+
* Checks session and redirects already-authenticated users.
|
|
38
|
+
*
|
|
39
|
+
* @returns {JSX.Element} Login form with email and OAuth options
|
|
40
|
+
*/
|
|
41
|
+
export default async function LoginPage({
|
|
42
|
+
searchParams
|
|
43
|
+
}: {
|
|
44
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>
|
|
45
|
+
}) {
|
|
46
|
+
const authSession = await getAuthSession()
|
|
47
|
+
const resolvedParams = (await searchParams) ?? {}
|
|
48
|
+
const callbackUrl = sanitizeCallbackUrl(resolvedParams.callbackUrl)
|
|
49
|
+
|
|
50
|
+
if (authSession?.isAdmin) {
|
|
51
|
+
return redirect(callbackUrl ?? '/admin')
|
|
52
|
+
} else if (authSession) {
|
|
53
|
+
return redirect(callbackUrl ?? '/app')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
|
58
|
+
<Card className="w-full max-w-md">
|
|
59
|
+
<CardHeader className="space-y-1 text-center">
|
|
60
|
+
<Link href="/" className="mx-auto mb-4 inline-flex">
|
|
61
|
+
<Logo size={40} priority className="text-xl" />
|
|
62
|
+
</Link>
|
|
63
|
+
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
|
|
64
|
+
<CardDescription className="text-muted-foreground text-sm">
|
|
65
|
+
Sign in to your account to continue
|
|
66
|
+
</CardDescription>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardContent className="space-y-4">
|
|
69
|
+
<div className="border-muted-foreground/50 bg-muted/50 rounded-lg border border-dashed p-4">
|
|
70
|
+
<p className="text-muted-foreground text-sm">
|
|
71
|
+
<strong>New to our site?</strong> No need to create a separate
|
|
72
|
+
account. Use either option below to sign up and log in.
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<EmailLoginForm callbackUrl={callbackUrl} />
|
|
77
|
+
|
|
78
|
+
<div className="relative">
|
|
79
|
+
<div className="absolute inset-0 flex items-center">
|
|
80
|
+
<span className="w-full border-t" />
|
|
81
|
+
</div>
|
|
82
|
+
<div className="relative flex justify-center text-xs uppercase">
|
|
83
|
+
<span className="bg-background text-muted-foreground px-2">
|
|
84
|
+
Or continue with
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<SocialLoginButtons callbackUrl={callbackUrl} />
|
|
90
|
+
</CardContent>
|
|
91
|
+
<CardFooter className="flex flex-col space-y-4">
|
|
92
|
+
<Separator className="my-2" />
|
|
93
|
+
<div className="text-muted-foreground text-center text-sm">
|
|
94
|
+
<Link
|
|
95
|
+
href="/"
|
|
96
|
+
className="text-primary underline-offset-4 hover:underline"
|
|
97
|
+
>
|
|
98
|
+
Return to Home Page
|
|
99
|
+
</Link>
|
|
100
|
+
</div>
|
|
101
|
+
</CardFooter>
|
|
102
|
+
</Card>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social authentication button components
|
|
3
|
+
*
|
|
4
|
+
* Provides OAuth sign-in buttons for Google and GitHub.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client'
|
|
8
|
+
|
|
9
|
+
import { useState, useSyncExternalStore } from 'react'
|
|
10
|
+
import { Button } from '~/components/ui/button'
|
|
11
|
+
import { getLastUsedLoginMethod, signIn } from '~/lib/auth-client'
|
|
12
|
+
import { toastActionError, toastErrorMessage } from '~/lib/toast-message'
|
|
13
|
+
|
|
14
|
+
type OAuthProvider = 'google' | 'github'
|
|
15
|
+
|
|
16
|
+
const PROVIDER_LABELS: Record<OAuthProvider, string> = {
|
|
17
|
+
google: 'Google',
|
|
18
|
+
github: 'GitHub'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const emptySubscribe = () => () => {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Social Login Buttons Component
|
|
25
|
+
*
|
|
26
|
+
* Renders OAuth provider buttons (Google, GitHub).
|
|
27
|
+
* Highlights the last-used login method if available.
|
|
28
|
+
*
|
|
29
|
+
* @returns {JSX.Element} Flex container with OAuth provider buttons
|
|
30
|
+
*/
|
|
31
|
+
export const SocialLoginButtons = ({
|
|
32
|
+
callbackUrl
|
|
33
|
+
}: {
|
|
34
|
+
callbackUrl?: string | null
|
|
35
|
+
}) => {
|
|
36
|
+
// Cookie/local storage is only available on the client; server snapshot is null.
|
|
37
|
+
const lastUsedLoginMethod = useSyncExternalStore(
|
|
38
|
+
emptySubscribe,
|
|
39
|
+
() => getLastUsedLoginMethod() ?? null,
|
|
40
|
+
() => null
|
|
41
|
+
)
|
|
42
|
+
const [pendingProvider, setPendingProvider] = useState<OAuthProvider | null>(
|
|
43
|
+
null
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const target = callbackUrl || '/login'
|
|
47
|
+
const isPending = pendingProvider !== null
|
|
48
|
+
|
|
49
|
+
async function startOAuth(provider: OAuthProvider) {
|
|
50
|
+
if (pendingProvider) return
|
|
51
|
+
|
|
52
|
+
setPendingProvider(provider)
|
|
53
|
+
try {
|
|
54
|
+
const res = await signIn.social({
|
|
55
|
+
provider,
|
|
56
|
+
callbackURL: target
|
|
57
|
+
})
|
|
58
|
+
if (res?.error) throw res.error
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(`Error starting ${provider} sign-in:`, err)
|
|
61
|
+
if (
|
|
62
|
+
typeof err === 'object' &&
|
|
63
|
+
err &&
|
|
64
|
+
'message' in err &&
|
|
65
|
+
typeof err.message === 'string'
|
|
66
|
+
) {
|
|
67
|
+
toastActionError(err)
|
|
68
|
+
} else {
|
|
69
|
+
toastErrorMessage(
|
|
70
|
+
`Sign-in with ${PROVIDER_LABELS[provider]} did not start. Try again.`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
setPendingProvider(null)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col gap-2">
|
|
79
|
+
<Button
|
|
80
|
+
type="button"
|
|
81
|
+
variant="outline"
|
|
82
|
+
disabled={isPending}
|
|
83
|
+
onClick={() => startOAuth('google')}
|
|
84
|
+
>
|
|
85
|
+
<svg
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
width="16"
|
|
88
|
+
height="16"
|
|
89
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
90
|
+
aria-hidden
|
|
91
|
+
>
|
|
92
|
+
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
|
93
|
+
<path
|
|
94
|
+
fill="#4285F4"
|
|
95
|
+
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
|
|
96
|
+
/>
|
|
97
|
+
<path
|
|
98
|
+
fill="#34A853"
|
|
99
|
+
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
|
|
100
|
+
/>
|
|
101
|
+
<path
|
|
102
|
+
fill="#FBBC05"
|
|
103
|
+
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
|
|
104
|
+
/>
|
|
105
|
+
<path
|
|
106
|
+
fill="#EA4335"
|
|
107
|
+
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
|
|
108
|
+
/>
|
|
109
|
+
</g>
|
|
110
|
+
</svg>
|
|
111
|
+
{pendingProvider === 'google'
|
|
112
|
+
? 'Continuing with Google…'
|
|
113
|
+
: `Continue with Google${lastUsedLoginMethod === 'google' ? ' (last used)' : ''}`}
|
|
114
|
+
</Button>
|
|
115
|
+
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="outline"
|
|
119
|
+
disabled={isPending}
|
|
120
|
+
onClick={() => startOAuth('github')}
|
|
121
|
+
>
|
|
122
|
+
<svg
|
|
123
|
+
viewBox="0 0 24 24"
|
|
124
|
+
width="16"
|
|
125
|
+
height="16"
|
|
126
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
127
|
+
aria-hidden
|
|
128
|
+
>
|
|
129
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
130
|
+
</svg>
|
|
131
|
+
{pendingProvider === 'github'
|
|
132
|
+
? 'Continuing with GitHub…'
|
|
133
|
+
: `Continue with GitHub${lastUsedLoginMethod === 'github' ? ' (last used)' : ''}`}
|
|
134
|
+
</Button>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { Button } from '~/components/ui/button'
|
|
7
|
+
import { organization, signOut } from '~/lib/auth-client'
|
|
8
|
+
import {
|
|
9
|
+
toastActionError,
|
|
10
|
+
toastInfoMessage,
|
|
11
|
+
toastSuccessMessage
|
|
12
|
+
} from '~/lib/toast-message'
|
|
13
|
+
|
|
14
|
+
export function AcceptInvitationForm({
|
|
15
|
+
invitationId,
|
|
16
|
+
organizationName
|
|
17
|
+
}: {
|
|
18
|
+
invitationId: string
|
|
19
|
+
organizationName: string
|
|
20
|
+
}) {
|
|
21
|
+
const router = useRouter()
|
|
22
|
+
const [accepting, setAccepting] = useState(false)
|
|
23
|
+
const [declining, setDeclining] = useState(false)
|
|
24
|
+
|
|
25
|
+
async function handleAccept() {
|
|
26
|
+
setAccepting(true)
|
|
27
|
+
const { error } = await organization.acceptInvitation({ invitationId })
|
|
28
|
+
if (error) {
|
|
29
|
+
toastActionError(error, 'The invitation was not accepted. Try again.')
|
|
30
|
+
setAccepting(false)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
toastSuccessMessage(`You joined ${organizationName}`)
|
|
34
|
+
router.push('/app')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function handleDecline() {
|
|
38
|
+
setDeclining(true)
|
|
39
|
+
const { error } = await organization.rejectInvitation({ invitationId })
|
|
40
|
+
if (error) {
|
|
41
|
+
toastActionError(error, 'The invitation was not declined. Try again.')
|
|
42
|
+
setDeclining(false)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
toastInfoMessage('Invitation declined')
|
|
46
|
+
router.push('/')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const disabled = accepting || declining
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-col gap-3">
|
|
53
|
+
<Button onClick={handleAccept} disabled={disabled} className="w-full">
|
|
54
|
+
{accepting ? 'Accepting…' : 'Accept Invitation'}
|
|
55
|
+
</Button>
|
|
56
|
+
<Button
|
|
57
|
+
onClick={handleDecline}
|
|
58
|
+
disabled={disabled}
|
|
59
|
+
variant="outline"
|
|
60
|
+
className="w-full"
|
|
61
|
+
>
|
|
62
|
+
{declining ? 'Declining…' : 'Decline'}
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function WrongAccountActions({
|
|
69
|
+
invitationId
|
|
70
|
+
}: {
|
|
71
|
+
invitationId: string
|
|
72
|
+
}) {
|
|
73
|
+
const router = useRouter()
|
|
74
|
+
const [loading, setLoading] = useState(false)
|
|
75
|
+
|
|
76
|
+
async function handleSwitch() {
|
|
77
|
+
setLoading(true)
|
|
78
|
+
await signOut()
|
|
79
|
+
router.push(
|
|
80
|
+
`/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${invitationId}`)}`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex justify-center gap-3">
|
|
86
|
+
<Button asChild variant="outline">
|
|
87
|
+
<Link href="/">Go Home</Link>
|
|
88
|
+
</Button>
|
|
89
|
+
<Button onClick={handleSwitch} disabled={loading}>
|
|
90
|
+
{loading ? 'Signing out…' : 'Switch Account'}
|
|
91
|
+
</Button>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Building2 } from 'lucide-react'
|
|
2
|
+
import type { Metadata } from 'next'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { Button } from '~/components/ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardFooter,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle
|
|
12
|
+
} from '~/components/ui/card'
|
|
13
|
+
import { getAuthSession } from '~/lib/auth'
|
|
14
|
+
import { roleArticle } from '~/lib/rbac'
|
|
15
|
+
import { privatePageRobots } from '~/lib/seo'
|
|
16
|
+
import { getInvitation } from '../../actions/invitations'
|
|
17
|
+
import {
|
|
18
|
+
AcceptInvitationForm,
|
|
19
|
+
WrongAccountActions
|
|
20
|
+
} from './accept-invitation-form'
|
|
21
|
+
|
|
22
|
+
export const metadata: Metadata = {
|
|
23
|
+
title: 'Accept Invitation',
|
|
24
|
+
robots: privatePageRobots
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default async function AcceptInvitationPage({
|
|
28
|
+
params
|
|
29
|
+
}: {
|
|
30
|
+
params: Promise<{ id: string }>
|
|
31
|
+
}) {
|
|
32
|
+
const { id } = await params
|
|
33
|
+
|
|
34
|
+
const invitation = (await getInvitation(id))?.data
|
|
35
|
+
|
|
36
|
+
if (!invitation || invitation.status !== 'pending') {
|
|
37
|
+
return (
|
|
38
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
39
|
+
<Card className="w-full max-w-md text-center">
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<CardTitle>Invitation Not Found</CardTitle>
|
|
42
|
+
<CardDescription>
|
|
43
|
+
This invitation link is invalid or was already used. Ask the
|
|
44
|
+
organization admin to send a new one.
|
|
45
|
+
</CardDescription>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardFooter className="justify-center">
|
|
48
|
+
<Button asChild variant="outline">
|
|
49
|
+
<Link href="/">Go Home</Link>
|
|
50
|
+
</Button>
|
|
51
|
+
</CardFooter>
|
|
52
|
+
</Card>
|
|
53
|
+
</main>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
|
|
58
|
+
return (
|
|
59
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
60
|
+
<Card className="w-full max-w-md text-center">
|
|
61
|
+
<CardHeader>
|
|
62
|
+
<CardTitle>Invitation Expired</CardTitle>
|
|
63
|
+
<CardDescription>
|
|
64
|
+
This invitation to <strong>{invitation.organization.name}</strong>{' '}
|
|
65
|
+
expired. Ask the organization admin to send a new one.
|
|
66
|
+
</CardDescription>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardFooter className="justify-center">
|
|
69
|
+
<Button asChild variant="outline">
|
|
70
|
+
<Link href="/">Go Home</Link>
|
|
71
|
+
</Button>
|
|
72
|
+
</CardFooter>
|
|
73
|
+
</Card>
|
|
74
|
+
</main>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const authSession = await getAuthSession()
|
|
79
|
+
|
|
80
|
+
if (!authSession) {
|
|
81
|
+
return (
|
|
82
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
83
|
+
<Card className="w-full max-w-md">
|
|
84
|
+
<CardHeader className="text-center">
|
|
85
|
+
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
86
|
+
<Building2 className="text-primary size-6" />
|
|
87
|
+
</div>
|
|
88
|
+
<CardTitle>You're Invited</CardTitle>
|
|
89
|
+
<CardDescription>
|
|
90
|
+
<strong>{invitation.inviter.name}</strong> invited you to join{' '}
|
|
91
|
+
<strong>{invitation.organization.name}</strong>. Sign in to
|
|
92
|
+
accept.
|
|
93
|
+
</CardDescription>
|
|
94
|
+
</CardHeader>
|
|
95
|
+
<CardContent>
|
|
96
|
+
<Button asChild className="w-full">
|
|
97
|
+
<Link
|
|
98
|
+
href={`/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${id}`)}`}
|
|
99
|
+
>
|
|
100
|
+
Sign In to Accept
|
|
101
|
+
</Link>
|
|
102
|
+
</Button>
|
|
103
|
+
</CardContent>
|
|
104
|
+
</Card>
|
|
105
|
+
</main>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (authSession.user.email.toLowerCase() !== invitation.email.toLowerCase()) {
|
|
110
|
+
return (
|
|
111
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
112
|
+
<Card className="w-full max-w-md text-center">
|
|
113
|
+
<CardHeader>
|
|
114
|
+
<CardTitle>Wrong Account</CardTitle>
|
|
115
|
+
<CardDescription>
|
|
116
|
+
This invitation was sent to <strong>{invitation.email}</strong>{' '}
|
|
117
|
+
but you're signed in as{' '}
|
|
118
|
+
<strong>{authSession.user.email}</strong>. Switch accounts to
|
|
119
|
+
accept it.
|
|
120
|
+
</CardDescription>
|
|
121
|
+
</CardHeader>
|
|
122
|
+
<CardFooter className="justify-center">
|
|
123
|
+
<WrongAccountActions invitationId={id} />
|
|
124
|
+
</CardFooter>
|
|
125
|
+
</Card>
|
|
126
|
+
</main>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
132
|
+
<Card className="w-full max-w-md">
|
|
133
|
+
<CardHeader className="text-center">
|
|
134
|
+
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
135
|
+
<Building2 className="text-primary size-6" />
|
|
136
|
+
</div>
|
|
137
|
+
<CardTitle>Join {invitation.organization.name}</CardTitle>
|
|
138
|
+
<CardDescription>
|
|
139
|
+
<strong>{invitation.inviter.name}</strong> invited you to join{' '}
|
|
140
|
+
<strong>{invitation.organization.name}</strong>
|
|
141
|
+
{invitation.role ? ` as ${roleArticle(invitation.role)}` : ''}.
|
|
142
|
+
</CardDescription>
|
|
143
|
+
</CardHeader>
|
|
144
|
+
<CardContent>
|
|
145
|
+
<AcceptInvitationForm
|
|
146
|
+
invitationId={id}
|
|
147
|
+
organizationName={invitation.organization.name}
|
|
148
|
+
/>
|
|
149
|
+
</CardContent>
|
|
150
|
+
</Card>
|
|
151
|
+
</main>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { eq } from 'drizzle-orm'
|
|
4
|
+
import { db } from '~/db'
|
|
5
|
+
import { invitationsTable } from '~/db/schema'
|
|
6
|
+
import { actionClient, withWideEvent } from '~/lib/safe-action'
|
|
7
|
+
import { stringValidation } from '~/lib/validations'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public view of an invitation for the accept page. Invitation IDs are
|
|
11
|
+
* unguessable, and accepting still requires signing in as the invited email.
|
|
12
|
+
*/
|
|
13
|
+
export const getInvitation = actionClient.inputSchema(stringValidation).action(
|
|
14
|
+
withWideEvent('getInvitation', async ({ parsedInput: invitationId }) => {
|
|
15
|
+
const invitation = await db.query.invitationsTable.findFirst({
|
|
16
|
+
where: eq(invitationsTable.id, invitationId),
|
|
17
|
+
columns: {
|
|
18
|
+
id: true,
|
|
19
|
+
status: true,
|
|
20
|
+
expiresAt: true,
|
|
21
|
+
email: true,
|
|
22
|
+
role: true
|
|
23
|
+
},
|
|
24
|
+
with: {
|
|
25
|
+
organization: { columns: { name: true } },
|
|
26
|
+
inviter: { columns: { name: true } }
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return invitation ?? null
|
|
31
|
+
})
|
|
32
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { db } from '~/db'
|
|
4
|
+
import { contactLeadsTable } from '~/db/schema'
|
|
5
|
+
import { actionClient, withWideEvent } from '~/lib/safe-action'
|
|
6
|
+
import { contactFormSchema } from './validation'
|
|
7
|
+
|
|
8
|
+
/** Public contact form. Submissions appear under Contact Leads in the admin panel. */
|
|
9
|
+
export const submitContactForm = actionClient
|
|
10
|
+
.inputSchema(contactFormSchema)
|
|
11
|
+
.action(
|
|
12
|
+
withWideEvent('submitContactForm', async ({ parsedInput }) => {
|
|
13
|
+
await db.insert(contactLeadsTable).values({
|
|
14
|
+
name: parsedInput.name,
|
|
15
|
+
email: parsedInput.email,
|
|
16
|
+
phone: parsedInput.phone || null,
|
|
17
|
+
subject: parsedInput.subject,
|
|
18
|
+
message: parsedInput.message
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
return true
|
|
22
|
+
})
|
|
23
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
4
|
+
import { Loader2, Send } from 'lucide-react'
|
|
5
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { Button } from '~/components/ui/button'
|
|
8
|
+
import { Field, FieldError, FieldLabel } from '~/components/ui/field'
|
|
9
|
+
import { Input } from '~/components/ui/input'
|
|
10
|
+
import { Textarea } from '~/components/ui/textarea'
|
|
11
|
+
import { useSafeActionMutation } from '~/lib/safe-action-client'
|
|
12
|
+
import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
|
|
13
|
+
import { submitContactForm } from './actions'
|
|
14
|
+
import { contactFormSchema } from './validation'
|
|
15
|
+
|
|
16
|
+
type ContactFormValues = z.infer<typeof contactFormSchema>
|
|
17
|
+
|
|
18
|
+
const FIELDS = [
|
|
19
|
+
{ name: 'name', label: 'Full name', autoComplete: 'name', type: 'text' },
|
|
20
|
+
{
|
|
21
|
+
name: 'email',
|
|
22
|
+
label: 'Email address',
|
|
23
|
+
autoComplete: 'email',
|
|
24
|
+
type: 'email'
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'phone',
|
|
28
|
+
label: 'Phone number (optional)',
|
|
29
|
+
autoComplete: 'tel',
|
|
30
|
+
type: 'tel'
|
|
31
|
+
},
|
|
32
|
+
{ name: 'subject', label: 'Subject', autoComplete: 'off', type: 'text' }
|
|
33
|
+
] as const
|
|
34
|
+
|
|
35
|
+
export function ContactForm() {
|
|
36
|
+
const form = useForm<ContactFormValues>({
|
|
37
|
+
resolver: zodResolver(contactFormSchema),
|
|
38
|
+
defaultValues: {
|
|
39
|
+
name: '',
|
|
40
|
+
email: '',
|
|
41
|
+
phone: '',
|
|
42
|
+
subject: '',
|
|
43
|
+
message: ''
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const { mutate, isPending } = useSafeActionMutation(submitContactForm, {
|
|
48
|
+
onSuccess: () => {
|
|
49
|
+
toastSuccessMessage("We received your message. We'll reply by email.")
|
|
50
|
+
form.reset()
|
|
51
|
+
},
|
|
52
|
+
onError: error =>
|
|
53
|
+
toastActionError(
|
|
54
|
+
error,
|
|
55
|
+
"Your message wasn't sent. Check your connection and try again."
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<form
|
|
61
|
+
onSubmit={form.handleSubmit(values => mutate(values))}
|
|
62
|
+
className="mt-8 space-y-5"
|
|
63
|
+
aria-busy={isPending}
|
|
64
|
+
>
|
|
65
|
+
{FIELDS.map(item => (
|
|
66
|
+
<Controller
|
|
67
|
+
key={item.name}
|
|
68
|
+
control={form.control}
|
|
69
|
+
name={item.name}
|
|
70
|
+
render={({ field, fieldState }) => (
|
|
71
|
+
<Field data-invalid={fieldState.invalid}>
|
|
72
|
+
<FieldLabel htmlFor={field.name}>{item.label}</FieldLabel>
|
|
73
|
+
<Input
|
|
74
|
+
id={field.name}
|
|
75
|
+
type={item.type}
|
|
76
|
+
autoComplete={item.autoComplete}
|
|
77
|
+
aria-invalid={fieldState.invalid}
|
|
78
|
+
{...field}
|
|
79
|
+
/>
|
|
80
|
+
<FieldError
|
|
81
|
+
errors={fieldState.error ? [fieldState.error] : undefined}
|
|
82
|
+
/>
|
|
83
|
+
</Field>
|
|
84
|
+
)}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
|
|
88
|
+
<Controller
|
|
89
|
+
control={form.control}
|
|
90
|
+
name="message"
|
|
91
|
+
render={({ field, fieldState }) => (
|
|
92
|
+
<Field data-invalid={fieldState.invalid}>
|
|
93
|
+
<FieldLabel htmlFor={field.name}>Message</FieldLabel>
|
|
94
|
+
<Textarea
|
|
95
|
+
id={field.name}
|
|
96
|
+
className="min-h-32"
|
|
97
|
+
placeholder="How can we help…"
|
|
98
|
+
aria-invalid={fieldState.invalid}
|
|
99
|
+
{...field}
|
|
100
|
+
/>
|
|
101
|
+
<FieldError
|
|
102
|
+
errors={fieldState.error ? [fieldState.error] : undefined}
|
|
103
|
+
/>
|
|
104
|
+
</Field>
|
|
105
|
+
)}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<Button type="submit" className="w-full" disabled={isPending}>
|
|
109
|
+
{isPending ? (
|
|
110
|
+
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
111
|
+
) : (
|
|
112
|
+
<Send aria-hidden="true" />
|
|
113
|
+
)}
|
|
114
|
+
{isPending ? 'Sending…' : 'Send Message'}
|
|
115
|
+
</Button>
|
|
116
|
+
</form>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { siteConfig } from '~/lib/siteConfig'
|
|
3
|
+
import { ContactForm } from './contact-form'
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'Contact Us',
|
|
7
|
+
description: `Send the ${siteConfig.name} team a message.`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function ContactUsPage() {
|
|
11
|
+
return (
|
|
12
|
+
<div className="container max-w-xl py-12">
|
|
13
|
+
<h1 className="text-3xl font-bold tracking-tight">Contact Us</h1>
|
|
14
|
+
<p className="text-muted-foreground mt-2">
|
|
15
|
+
Send us a message and we'll reply by email within one business day.
|
|
16
|
+
</p>
|
|
17
|
+
<ContactForm />
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|