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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throwaway DB fixtures and auth helpers for Bun tests.
|
|
3
|
+
*
|
|
4
|
+
* const fx = new Fixtures()
|
|
5
|
+
* afterEach(() => fx.cleanup())
|
|
6
|
+
* const ws = await fx.workspace({ plan: 'pro' })
|
|
7
|
+
* await asUser(ws.owner, () => someAction(...))
|
|
8
|
+
*/
|
|
9
|
+
import { createId } from '@paralleldrive/cuid2'
|
|
10
|
+
import { inArray } from 'drizzle-orm'
|
|
11
|
+
import { db } from '~/db'
|
|
12
|
+
import {
|
|
13
|
+
fileUploadsTable,
|
|
14
|
+
membersTable,
|
|
15
|
+
organizationSubscriptionsTable,
|
|
16
|
+
organizationsTable,
|
|
17
|
+
supportTicketsTable,
|
|
18
|
+
usersTable
|
|
19
|
+
} from '~/db/schema'
|
|
20
|
+
import { runWithAuthSession } from '../setup/preload'
|
|
21
|
+
|
|
22
|
+
type User = typeof usersTable.$inferSelect
|
|
23
|
+
type PlanId = (typeof organizationSubscriptionsTable.$inferInsert)['plan']
|
|
24
|
+
|
|
25
|
+
const uid = () =>
|
|
26
|
+
`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
|
27
|
+
|
|
28
|
+
export class Fixtures {
|
|
29
|
+
private userIds: string[] = []
|
|
30
|
+
private organizationIds: string[] = []
|
|
31
|
+
|
|
32
|
+
async user(
|
|
33
|
+
overrides: { name?: string; email?: string; role?: string | null } = {}
|
|
34
|
+
): Promise<User> {
|
|
35
|
+
const s = uid()
|
|
36
|
+
const [row] = await db
|
|
37
|
+
.insert(usersTable)
|
|
38
|
+
.values({
|
|
39
|
+
id: `user_${createId()}`,
|
|
40
|
+
name: overrides.name ?? `Test User ${s}`,
|
|
41
|
+
email: overrides.email ?? `test+${s}@example.com`,
|
|
42
|
+
emailVerified: true,
|
|
43
|
+
role: overrides.role ?? 'user'
|
|
44
|
+
})
|
|
45
|
+
.returning()
|
|
46
|
+
if (!row) throw new Error('Failed to create test user')
|
|
47
|
+
this.userIds.push(row.id)
|
|
48
|
+
return row
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Platform user for admin-panel tests. Belongs to no organization. */
|
|
52
|
+
platformUser(role: 'admin' | 'superadmin') {
|
|
53
|
+
return this.user({ name: `Platform ${role}`, role })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An organization with an owner, an admin, and a member, plus its
|
|
58
|
+
* subscription row. A paid `plan` gets an active subscription.
|
|
59
|
+
*/
|
|
60
|
+
async workspace(options: { plan?: PlanId } = {}) {
|
|
61
|
+
const plan = options.plan ?? 'free'
|
|
62
|
+
const owner = await this.user({ name: 'Test Owner' })
|
|
63
|
+
const admin = await this.user({ name: 'Test Admin' })
|
|
64
|
+
const member = await this.user({ name: 'Test Member' })
|
|
65
|
+
|
|
66
|
+
const s = uid()
|
|
67
|
+
const [organization] = await db
|
|
68
|
+
.insert(organizationsTable)
|
|
69
|
+
.values({ name: `Test Organization ${s}`, slug: `test-org-${s}` })
|
|
70
|
+
.returning()
|
|
71
|
+
if (!organization) throw new Error('Failed to create test organization')
|
|
72
|
+
this.organizationIds.push(organization.id)
|
|
73
|
+
|
|
74
|
+
await db.insert(membersTable).values([
|
|
75
|
+
{ organizationId: organization.id, userId: owner.id, role: 'owner' },
|
|
76
|
+
{ organizationId: organization.id, userId: admin.id, role: 'admin' },
|
|
77
|
+
{ organizationId: organization.id, userId: member.id, role: 'member' }
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
await db.insert(organizationSubscriptionsTable).values({
|
|
81
|
+
organizationId: organization.id,
|
|
82
|
+
plan,
|
|
83
|
+
status: plan === 'free' ? 'free' : 'active'
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return { organization, owner, admin, member }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Records an upload the way `generateUploadUrl` does, without R2. */
|
|
90
|
+
async upload(organizationId: string, uploadedBy: string) {
|
|
91
|
+
const fileId = createId()
|
|
92
|
+
const [row] = await db
|
|
93
|
+
.insert(fileUploadsTable)
|
|
94
|
+
.values({
|
|
95
|
+
key: `uploads/organizations/${organizationId}/${fileId}.png`,
|
|
96
|
+
originalName: 'logo.png',
|
|
97
|
+
mimeType: 'image/png',
|
|
98
|
+
size: 1024,
|
|
99
|
+
uploadedBy,
|
|
100
|
+
organizationId
|
|
101
|
+
})
|
|
102
|
+
.returning()
|
|
103
|
+
if (!row) throw new Error('Failed to create test upload')
|
|
104
|
+
return row
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async cleanup() {
|
|
108
|
+
if (this.organizationIds.length) {
|
|
109
|
+
// `file_uploads` restricts organization deletes, as in production.
|
|
110
|
+
await db
|
|
111
|
+
.delete(fileUploadsTable)
|
|
112
|
+
.where(inArray(fileUploadsTable.organizationId, this.organizationIds))
|
|
113
|
+
await db
|
|
114
|
+
.delete(organizationsTable)
|
|
115
|
+
.where(inArray(organizationsTable.id, this.organizationIds))
|
|
116
|
+
this.organizationIds = []
|
|
117
|
+
}
|
|
118
|
+
if (this.userIds.length) {
|
|
119
|
+
await db
|
|
120
|
+
.delete(supportTicketsTable)
|
|
121
|
+
.where(inArray(supportTicketsTable.userId, this.userIds))
|
|
122
|
+
await db.delete(usersTable).where(inArray(usersTable.id, this.userIds))
|
|
123
|
+
this.userIds = []
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Runs `fn` with `getAuthSession()` returning this user. */
|
|
129
|
+
export function asUser<T>(user: User, fn: () => Promise<T>): Promise<T> {
|
|
130
|
+
const isSuperAdmin = user.role === 'superadmin'
|
|
131
|
+
const isAdmin = user.role === 'admin' || isSuperAdmin
|
|
132
|
+
return runWithAuthSession(
|
|
133
|
+
{
|
|
134
|
+
user: {
|
|
135
|
+
id: user.id,
|
|
136
|
+
email: user.email,
|
|
137
|
+
name: user.name,
|
|
138
|
+
role: user.role
|
|
139
|
+
},
|
|
140
|
+
session: { id: `test_session_${user.id}`, userId: user.id },
|
|
141
|
+
isAdmin,
|
|
142
|
+
isSuperAdmin
|
|
143
|
+
},
|
|
144
|
+
fn
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Runs `fn` signed out. */
|
|
149
|
+
export function asAnonymous<T>(fn: () => Promise<T>): Promise<T> {
|
|
150
|
+
return runWithAuthSession(null, fn)
|
|
151
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { sanitizeCallbackUrl } from '~/lib/callback-url'
|
|
3
|
+
|
|
4
|
+
// Everything sanitizeCallbackUrl rejects must be something a browser would
|
|
5
|
+
// resolve off-origin, so each case is checked against URL resolution too.
|
|
6
|
+
const APP_ORIGIN = 'https://app.example.com'
|
|
7
|
+
|
|
8
|
+
const resolvesOffOrigin = (value: string) =>
|
|
9
|
+
new URL(value, APP_ORIGIN).origin !== APP_ORIGIN
|
|
10
|
+
|
|
11
|
+
describe('sanitizeCallbackUrl', () => {
|
|
12
|
+
test('keeps same-origin paths, including query and hash', () => {
|
|
13
|
+
expect(sanitizeCallbackUrl('/app')).toBe('/app')
|
|
14
|
+
expect(sanitizeCallbackUrl('/accept-invitation/inv_abc123')).toBe(
|
|
15
|
+
'/accept-invitation/inv_abc123'
|
|
16
|
+
)
|
|
17
|
+
expect(sanitizeCallbackUrl('/app/orders?status=open#top')).toBe(
|
|
18
|
+
'/app/orders?status=open#top'
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('rejects values that are not a path', () => {
|
|
23
|
+
expect(sanitizeCallbackUrl(undefined)).toBeNull()
|
|
24
|
+
expect(sanitizeCallbackUrl(['/app', '/admin'])).toBeNull()
|
|
25
|
+
expect(sanitizeCallbackUrl('app')).toBeNull()
|
|
26
|
+
expect(sanitizeCallbackUrl('https://evil.com')).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('rejects the backslash and double-slash escapes out of the origin', () => {
|
|
30
|
+
for (const value of ['//evil.com', '/\\evil.com', '/\\\\evil.com']) {
|
|
31
|
+
expect(resolvesOffOrigin(value)).toBe(true)
|
|
32
|
+
expect(sanitizeCallbackUrl(value)).toBeNull()
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('rejects tabs and newlines, which URL parsing strips before resolving', () => {
|
|
37
|
+
for (const value of ['/\t/evil.com', '/\n/evil.com', '/\r/evil.com']) {
|
|
38
|
+
expect(resolvesOffOrigin(value)).toBe(true)
|
|
39
|
+
expect(sanitizeCallbackUrl(value)).toBeNull()
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('leaves lookalike hosts on the app origin instead of rejecting them', () => {
|
|
44
|
+
// These stay same-origin, so they are safe to honour.
|
|
45
|
+
expect(sanitizeCallbackUrl('/@evil.com')).toBe('/@evil.com')
|
|
46
|
+
expect(sanitizeCallbackUrl('/%5Cevil.com')).toBe('/%5Cevil.com')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard: `'use client'` modules must not import server runtime, secrets, or
|
|
3
|
+
* the database directly. Transitive imports leak too, but this test only reads
|
|
4
|
+
* each client file's own imports, so review what a new import pulls in.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
const SRC = path.resolve(import.meta.dir, '../../src')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Import paths that must never appear in a `'use client'` module. Add every
|
|
14
|
+
* new server-only lib here.
|
|
15
|
+
*/
|
|
16
|
+
const FORBIDDEN_IMPORT_RE =
|
|
17
|
+
/from\s+['"](~\/db(?:\/(?:schema|enums|relations|common|index))?|~\/env|~\/lib\/(?:auth|safe-action|storage|email-service|nodemailer|organization-access|plan-limits|plans|billing|stripe)|server-only|drizzle-orm(?:\/[^'"]*)?)['"]/
|
|
18
|
+
|
|
19
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
20
|
+
for (const name of readdirSync(dir)) {
|
|
21
|
+
if (name === 'node_modules' || name.startsWith('.')) continue
|
|
22
|
+
const full = path.join(dir, name)
|
|
23
|
+
if (statSync(full).isDirectory()) walk(full, out)
|
|
24
|
+
else if (/\.(ts|tsx)$/.test(name)) out.push(full)
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('client / server boundary', () => {
|
|
30
|
+
test('no use-client file imports server runtime or db modules', () => {
|
|
31
|
+
const violations: string[] = []
|
|
32
|
+
|
|
33
|
+
for (const file of walk(SRC)) {
|
|
34
|
+
const source = readFileSync(file, 'utf8')
|
|
35
|
+
if (
|
|
36
|
+
!source.includes("'use client'") &&
|
|
37
|
+
!source.includes('"use client"')
|
|
38
|
+
) {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [index, line] of source.split('\n').entries()) {
|
|
43
|
+
// Type-only imports are erased, but they are banned too so a later
|
|
44
|
+
// value import is harder to slip in.
|
|
45
|
+
if (!FORBIDDEN_IMPORT_RE.test(line)) continue
|
|
46
|
+
// Explicit client-safe siblings
|
|
47
|
+
if (line.includes('auth-client')) continue
|
|
48
|
+
if (line.includes('safe-action-client')) continue
|
|
49
|
+
|
|
50
|
+
const rel = path.relative(SRC, file)
|
|
51
|
+
violations.push(`${rel}:${index + 1}: ${line.trim()}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(violations).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { isUniqueViolation } from '~/lib/errors'
|
|
3
|
+
|
|
4
|
+
describe('postgres error codes', () => {
|
|
5
|
+
test('an error whose cause points back at itself is not a match and does not hang', () => {
|
|
6
|
+
const error: { message: string; cause?: unknown } = { message: 'loop' }
|
|
7
|
+
error.cause = error
|
|
8
|
+
expect(isUniqueViolation(error)).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { canPersistImageValue, isStoredImageValue } from '~/lib/image-url'
|
|
3
|
+
|
|
4
|
+
const OWN_KEY = 'uploads/organizations/organization_abc123/logo_1.png'
|
|
5
|
+
const DATA_URL = 'data:image/png;base64,iVBORw0KGgo='
|
|
6
|
+
|
|
7
|
+
describe('stored image values', () => {
|
|
8
|
+
test('accepts organization upload keys and image data URLs', () => {
|
|
9
|
+
expect(isStoredImageValue(OWN_KEY)).toBe(true)
|
|
10
|
+
expect(isStoredImageValue(DATA_URL)).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('rejects external URLs, scripts, other types, and path traversal', () => {
|
|
14
|
+
for (const value of [
|
|
15
|
+
'https://example.com/logo.png',
|
|
16
|
+
'javascript:alert(1)',
|
|
17
|
+
'data:text/html;base64,PHNjcmlwdD4=',
|
|
18
|
+
'data:image/svg+xml;base64,PHN2Zz4=',
|
|
19
|
+
'uploads/organizations/../other/logo.png',
|
|
20
|
+
'uploads/organizations/organization_abc123/logo.html'
|
|
21
|
+
]) {
|
|
22
|
+
expect(isStoredImageValue(value)).toBe(false)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('data URLs are stored only while R2 is off', () => {
|
|
27
|
+
expect(canPersistImageValue(DATA_URL, false)).toBe(true)
|
|
28
|
+
expect(canPersistImageValue(DATA_URL, true)).toBe(false)
|
|
29
|
+
expect(canPersistImageValue(OWN_KEY, true)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { escapeLikePattern, likeContains } from '~/lib/search'
|
|
3
|
+
|
|
4
|
+
describe('escapeLikePattern', () => {
|
|
5
|
+
test('escapes LIKE wildcards and the escape character', () => {
|
|
6
|
+
expect(escapeLikePattern('%')).toBe('\\%')
|
|
7
|
+
expect(escapeLikePattern('_')).toBe('\\_')
|
|
8
|
+
expect(escapeLikePattern('\\')).toBe('\\\\')
|
|
9
|
+
expect(escapeLikePattern('100%_off\\sale')).toBe('100\\%\\_off\\\\sale')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('leaves ordinary search text unchanged', () => {
|
|
13
|
+
expect(escapeLikePattern('Quarterly report')).toBe('Quarterly report')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('likeContains', () => {
|
|
18
|
+
test('wraps an escaped pattern for contains matching', () => {
|
|
19
|
+
expect(likeContains('report')).toBe('%report%')
|
|
20
|
+
expect(likeContains('%')).toBe('%\\%%')
|
|
21
|
+
expect(likeContains('_')).toBe('%\\_%')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { eq } from 'drizzle-orm'
|
|
3
|
+
import { getContactLeads } from '~/app/(admin)/actions/contact-leads'
|
|
4
|
+
import { getFeedbacks } from '~/app/(admin)/actions/feedbacks'
|
|
5
|
+
import { createFeedbackAction } from '~/app/(app)/actions/feedbacks'
|
|
6
|
+
import { submitContactForm } from '~/app/(marketing)/contact-us/actions'
|
|
7
|
+
import { db } from '~/db'
|
|
8
|
+
import { contactLeadsTable } from '~/db/schema'
|
|
9
|
+
import {
|
|
10
|
+
asAnonymous,
|
|
11
|
+
asUser,
|
|
12
|
+
expectData,
|
|
13
|
+
expectServerError,
|
|
14
|
+
expectValidationError,
|
|
15
|
+
Fixtures
|
|
16
|
+
} from '../helpers'
|
|
17
|
+
|
|
18
|
+
describe('contact form and feedback inboxes', () => {
|
|
19
|
+
const fx = new Fixtures()
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await db
|
|
22
|
+
.delete(contactLeadsTable)
|
|
23
|
+
.where(eq(contactLeadsTable.email, 'visitor@example.com'))
|
|
24
|
+
await fx.cleanup()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('anyone can send the contact form; only admins read the leads', async () => {
|
|
28
|
+
const sent = await asAnonymous(() =>
|
|
29
|
+
submitContactForm({
|
|
30
|
+
name: 'Alex Morgan',
|
|
31
|
+
email: 'visitor@example.com',
|
|
32
|
+
subject: 'Pricing',
|
|
33
|
+
message: 'Do you offer annual invoicing?'
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
expectData(sent)
|
|
37
|
+
|
|
38
|
+
const user = await fx.user()
|
|
39
|
+
expectServerError(
|
|
40
|
+
await asUser(user, () => getContactLeads({ page: 1, limit: 10 })),
|
|
41
|
+
'permission'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const admin = await fx.platformUser('admin')
|
|
45
|
+
const leads = await asUser(admin, () =>
|
|
46
|
+
getContactLeads({ page: 1, limit: 50, search: 'visitor@example.com' })
|
|
47
|
+
)
|
|
48
|
+
expectData(leads)
|
|
49
|
+
expect(leads.data[0].map(lead => lead.subject)).toContain('Pricing')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('the contact form rejects a message too short to answer', async () => {
|
|
53
|
+
const result = await asAnonymous(() =>
|
|
54
|
+
submitContactForm({
|
|
55
|
+
name: 'Alex Morgan',
|
|
56
|
+
email: 'visitor@example.com',
|
|
57
|
+
subject: 'Hi',
|
|
58
|
+
message: 'Hi'
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
expectValidationError(result)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('admins see member feedback with its organization', async () => {
|
|
65
|
+
const ws = await fx.workspace()
|
|
66
|
+
expectData(
|
|
67
|
+
await asUser(ws.member, () =>
|
|
68
|
+
createFeedbackAction({
|
|
69
|
+
organizationId: ws.organization.id,
|
|
70
|
+
rating: 4,
|
|
71
|
+
comment: 'Setup took five minutes.'
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const admin = await fx.platformUser('admin')
|
|
77
|
+
const list = await asUser(admin, () =>
|
|
78
|
+
getFeedbacks({ page: 1, limit: 50, filters: { rating: [4] } })
|
|
79
|
+
)
|
|
80
|
+
expectData(list)
|
|
81
|
+
const row = list.data[0].find(
|
|
82
|
+
item => item.organizationId === ws.organization.id
|
|
83
|
+
)
|
|
84
|
+
expect(row?.organizationName).toBe(ws.organization.name)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { eq } from 'drizzle-orm'
|
|
3
|
+
import { createFeedbackAction } from '~/app/(app)/actions/feedbacks'
|
|
4
|
+
import { getMembers } from '~/app/(app)/actions/members'
|
|
5
|
+
import {
|
|
6
|
+
getOrganization,
|
|
7
|
+
transferOrganizationOwnershipAction,
|
|
8
|
+
updateOrganizationAction
|
|
9
|
+
} from '~/app/(app)/actions/organizations'
|
|
10
|
+
import { db } from '~/db'
|
|
11
|
+
import { membersTable, organizationsTable } from '~/db/schema'
|
|
12
|
+
import {
|
|
13
|
+
asUser,
|
|
14
|
+
expectData,
|
|
15
|
+
expectServerError,
|
|
16
|
+
expectValidationError,
|
|
17
|
+
Fixtures
|
|
18
|
+
} from '../helpers'
|
|
19
|
+
|
|
20
|
+
const PNG_DATA_URL = 'data:image/png;base64,iVBORw0KGgo='
|
|
21
|
+
|
|
22
|
+
describe('organization access', () => {
|
|
23
|
+
const fx = new Fixtures()
|
|
24
|
+
afterEach(() => fx.cleanup())
|
|
25
|
+
|
|
26
|
+
test('an outsider gets no data for an organization', async () => {
|
|
27
|
+
const ws = await fx.workspace()
|
|
28
|
+
const outsider = await fx.user()
|
|
29
|
+
|
|
30
|
+
const result = await asUser(outsider, () =>
|
|
31
|
+
getOrganization(ws.organization.id)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(result?.data ?? null).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('members cannot manage settings or list members', async () => {
|
|
38
|
+
const ws = await fx.workspace()
|
|
39
|
+
|
|
40
|
+
const update = await asUser(ws.member, () =>
|
|
41
|
+
updateOrganizationAction({
|
|
42
|
+
organizationId: ws.organization.id,
|
|
43
|
+
name: 'Renamed',
|
|
44
|
+
slug: 'renamed',
|
|
45
|
+
logo: ''
|
|
46
|
+
})
|
|
47
|
+
)
|
|
48
|
+
expectServerError(update, 'permission')
|
|
49
|
+
|
|
50
|
+
const members = await asUser(ws.member, () =>
|
|
51
|
+
getMembers({ organizationId: ws.organization.id, page: 1, limit: 10 })
|
|
52
|
+
)
|
|
53
|
+
expectServerError(members, 'permission')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('an admin can rename the organization and set a data URL logo without R2', async () => {
|
|
57
|
+
const ws = await fx.workspace()
|
|
58
|
+
|
|
59
|
+
const result = await asUser(ws.admin, () =>
|
|
60
|
+
updateOrganizationAction({
|
|
61
|
+
organizationId: ws.organization.id,
|
|
62
|
+
name: 'Renamed Org',
|
|
63
|
+
slug: `renamed-${ws.organization.id.slice(-8)}`,
|
|
64
|
+
logo: PNG_DATA_URL
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
expectData(result)
|
|
68
|
+
|
|
69
|
+
const org = await db.query.organizationsTable.findFirst({
|
|
70
|
+
where: eq(organizationsTable.id, ws.organization.id)
|
|
71
|
+
})
|
|
72
|
+
expect(org?.name).toBe('Renamed Org')
|
|
73
|
+
expect(org?.logo).toBe(PNG_DATA_URL)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("an organization cannot use another organization's upload as its logo", async () => {
|
|
77
|
+
const mine = await fx.workspace()
|
|
78
|
+
const theirs = await fx.workspace()
|
|
79
|
+
const foreign = await fx.upload(theirs.organization.id, theirs.owner.id)
|
|
80
|
+
|
|
81
|
+
const result = await asUser(mine.owner, () =>
|
|
82
|
+
updateOrganizationAction({
|
|
83
|
+
organizationId: mine.organization.id,
|
|
84
|
+
name: mine.organization.name,
|
|
85
|
+
slug: mine.organization.slug,
|
|
86
|
+
logo: foreign.key
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
expectServerError(result, 'permission')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('external image URLs are rejected before the action runs', async () => {
|
|
93
|
+
const ws = await fx.workspace()
|
|
94
|
+
|
|
95
|
+
const result = await asUser(ws.owner, () =>
|
|
96
|
+
updateOrganizationAction({
|
|
97
|
+
organizationId: ws.organization.id,
|
|
98
|
+
name: ws.organization.name,
|
|
99
|
+
slug: ws.organization.slug,
|
|
100
|
+
logo: 'https://example.com/logo.png'
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
expectValidationError(result)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a duplicate slug returns a message you can act on', async () => {
|
|
107
|
+
const mine = await fx.workspace()
|
|
108
|
+
const theirs = await fx.workspace()
|
|
109
|
+
|
|
110
|
+
const result = await asUser(mine.owner, () =>
|
|
111
|
+
updateOrganizationAction({
|
|
112
|
+
organizationId: mine.organization.id,
|
|
113
|
+
name: mine.organization.name,
|
|
114
|
+
slug: theirs.organization.slug,
|
|
115
|
+
logo: ''
|
|
116
|
+
})
|
|
117
|
+
)
|
|
118
|
+
expectServerError(result, 'slug')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('only the owner can transfer ownership, and they become an admin', async () => {
|
|
122
|
+
const ws = await fx.workspace()
|
|
123
|
+
const adminMembership = await db.query.membersTable.findFirst({
|
|
124
|
+
where: eq(membersTable.userId, ws.admin.id)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const byAdmin = await asUser(ws.admin, () =>
|
|
128
|
+
transferOrganizationOwnershipAction({
|
|
129
|
+
organizationId: ws.organization.id,
|
|
130
|
+
targetMemberId: adminMembership!.id
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
expectServerError(byAdmin, 'permission')
|
|
134
|
+
|
|
135
|
+
const byOwner = await asUser(ws.owner, () =>
|
|
136
|
+
transferOrganizationOwnershipAction({
|
|
137
|
+
organizationId: ws.organization.id,
|
|
138
|
+
targetMemberId: adminMembership!.id
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
expectData(byOwner)
|
|
142
|
+
|
|
143
|
+
const roles = await db
|
|
144
|
+
.select({ userId: membersTable.userId, role: membersTable.role })
|
|
145
|
+
.from(membersTable)
|
|
146
|
+
.where(eq(membersTable.organizationId, ws.organization.id))
|
|
147
|
+
const roleOf = (userId: string) =>
|
|
148
|
+
roles.find(row => row.userId === userId)?.role
|
|
149
|
+
expect(roleOf(ws.admin.id)).toBe('owner')
|
|
150
|
+
expect(roleOf(ws.owner.id)).toBe('admin')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('feedback requires membership in the organization', async () => {
|
|
154
|
+
const ws = await fx.workspace()
|
|
155
|
+
const outsider = await fx.user()
|
|
156
|
+
|
|
157
|
+
const denied = await asUser(outsider, () =>
|
|
158
|
+
createFeedbackAction({
|
|
159
|
+
organizationId: ws.organization.id,
|
|
160
|
+
rating: 5,
|
|
161
|
+
comment: 'Looks great.'
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
expectServerError(denied, 'access')
|
|
165
|
+
|
|
166
|
+
const allowed = await asUser(ws.member, () =>
|
|
167
|
+
createFeedbackAction({
|
|
168
|
+
organizationId: ws.organization.id,
|
|
169
|
+
rating: 4,
|
|
170
|
+
comment: 'Looks great.'
|
|
171
|
+
})
|
|
172
|
+
)
|
|
173
|
+
expectData(allowed)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Postgres for Bun tests via Electric PGlite (WASM).
|
|
3
|
+
*
|
|
4
|
+
* One instance per Bun worker process:
|
|
5
|
+
* 1. Start PGlite
|
|
6
|
+
* 2. pushSchema from Drizzle table definitions
|
|
7
|
+
* 3. Install functions and triggers (same as production setup-db)
|
|
8
|
+
*/
|
|
9
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
10
|
+
import { pushSchema } from 'drizzle-kit/api'
|
|
11
|
+
import { drizzle, type PgliteDatabase } from 'drizzle-orm/pglite'
|
|
12
|
+
import * as relations from '~/db/relations'
|
|
13
|
+
import * as schema from '~/db/schema'
|
|
14
|
+
import { setupDatabase } from '~/scripts/setup-db'
|
|
15
|
+
|
|
16
|
+
const fullSchema = { ...schema, ...relations }
|
|
17
|
+
|
|
18
|
+
type TestDatabase = PgliteDatabase<typeof fullSchema>
|
|
19
|
+
|
|
20
|
+
let initPromise: Promise<TestDatabase> | null = null
|
|
21
|
+
|
|
22
|
+
async function bootstrap(): Promise<TestDatabase> {
|
|
23
|
+
// PGlite's Emscripten runtime sets process.exitCode to 99 via proc_exit on
|
|
24
|
+
// the first WASM query. bun test then exits with that code even when every
|
|
25
|
+
// assertion passed. Snapshot first, restore only the spurious 99, and leave
|
|
26
|
+
// any real non-zero code alone.
|
|
27
|
+
const exitCodeBeforePglite = process.exitCode
|
|
28
|
+
|
|
29
|
+
const client = new PGlite()
|
|
30
|
+
const db = drizzle({ client, schema: fullSchema })
|
|
31
|
+
|
|
32
|
+
const { apply, warnings, hasDataLoss } = await pushSchema(
|
|
33
|
+
schema,
|
|
34
|
+
db as unknown as Parameters<typeof pushSchema>[1]
|
|
35
|
+
)
|
|
36
|
+
if (warnings.length) {
|
|
37
|
+
console.warn('[pglite] schema push warnings:', warnings)
|
|
38
|
+
}
|
|
39
|
+
if (hasDataLoss) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'[pglite] schema push reported data loss on a fresh database. Aborting.'
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
await apply()
|
|
45
|
+
|
|
46
|
+
await setupDatabase(db as unknown as Parameters<typeof setupDatabase>[0])
|
|
47
|
+
|
|
48
|
+
if (process.exitCode === 99) {
|
|
49
|
+
process.exitCode =
|
|
50
|
+
exitCodeBeforePglite !== undefined && exitCodeBeforePglite !== 99
|
|
51
|
+
? exitCodeBeforePglite
|
|
52
|
+
: 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return db
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Idempotent: first call boots PGlite + schema; later calls return the same db.
|
|
60
|
+
* Safe to await from preload (once per worker).
|
|
61
|
+
*/
|
|
62
|
+
export function initPgliteDatabase(): Promise<TestDatabase> {
|
|
63
|
+
if (!initPromise) {
|
|
64
|
+
initPromise = bootstrap()
|
|
65
|
+
}
|
|
66
|
+
return initPromise
|
|
67
|
+
}
|