From 1af1a92fec9e6bec29780ce54bb7c60471bd64be Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:33:14 +0530 Subject: [PATCH] feat: granular team roles (#3975) Co-authored-by: Matthias Nannt Co-authored-by: Dhruwang Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Johannes --- .env.example | 2 +- apps/docs/app/global/access-roles/page.mdx | 121 +- .../app/self-hosting/configuration/page.mdx | 2 +- apps/docs/lib/navigation.ts | 2 +- .../components/InviteOrganizationMember.tsx | 2 +- .../[environmentId]/connect/invite/page.tsx | 2 +- .../components/XMTemplateList.tsx | 4 +- .../[environmentId]/xm-templates/page.tsx | 6 +- .../app/(app)/(onboarding)/lib/onboarding.ts | 48 + .../landing/components/landing-sidebar.tsx | 185 +++ .../[organizationId]/landing/layout.tsx | 35 + .../[organizationId]/landing/page.tsx | 50 + .../organizations/[organizationId]/layout.tsx | 6 +- .../[organizationId]/products/layout.tsx | 20 + .../products/new/channel/page.tsx | 12 +- .../products/new/mode/page.tsx | 12 +- .../settings/components/ProductSettings.tsx | 64 +- .../products/new/settings/page.tsx | 29 +- .../(onboarding)/organizations/actions.ts | 19 +- .../(app)/(onboarding)/types/onboarding.ts | 8 + .../surveys/[surveyId]/edit/actions.ts | 183 ++- .../edit/components/AddActionModal.tsx | 6 +- .../edit/components/AddressQuestionForm.tsx | 2 +- .../edit/components/CTAQuestionForm.tsx | 4 +- .../edit/components/CalQuestionForm.tsx | 2 +- .../edit/components/ConsentQuestionForm.tsx | 4 +- .../components/ContactInfoQuestionForm.tsx | 2 +- .../edit/components/CreateNewActionTab.tsx | 10 +- .../edit/components/DateQuestionForm.tsx | 2 +- .../edit/components/EditWelcomeCard.tsx | 4 +- .../edit/components/EndScreenForm.tsx | 2 +- .../components/FileUploadQuestionForm.tsx | 4 +- .../edit/components/MatrixQuestionForm.tsx | 2 +- .../components/MultipleChoiceQuestionForm.tsx | 2 +- .../edit/components/NPSQuestionForm.tsx | 2 +- .../edit/components/OpenQuestionForm.tsx | 2 +- .../edit/components/PictureSelectionForm.tsx | 2 +- .../edit/components/QuestionCard.tsx | 2 +- .../edit/components/QuestionOptionChoice.tsx | 2 +- .../edit/components/QuestionsView.tsx | 2 +- .../edit/components/RankingQuestionForm.tsx | 2 +- .../edit/components/RatingQuestionForm.tsx | 2 +- .../edit/components/SettingsView.tsx | 10 +- .../edit/components/SurveyEditor.tsx | 8 +- .../edit/components/SurveyMenuBar.tsx | 4 +- .../edit/components/TargetingCard.tsx | 2 +- .../edit/components/WhenToSendCard.tsx | 15 +- .../surveys/[surveyId]/edit/page.tsx | 17 +- .../surveys/templates/actions.ts | 20 +- .../templates/components/FormbricksAICard.tsx | 2 +- .../components/TemplateContainer.tsx | 2 +- .../surveys/templates/page.tsx | 10 +- .../(people)/attributes/actions.ts | 15 +- .../components/AttributeActivityTab.tsx | 2 +- .../components/AttributeClassesTable.tsx | 8 +- .../components/AttributeDetailModal.tsx | 12 +- .../components/AttributeSettingsTab.tsx | 31 +- .../(people)/attributes/page.tsx | 31 +- .../[environmentId]/(people)/layout.tsx | 47 + .../components/DeletePersonButton.tsx | 10 +- .../[personId]/components/ResponseSection.tsx | 13 + .../components/ResponseTimeline.tsx | 4 + .../[personId]/components/ResponsesFeed.tsx | 17 +- .../(people)/people/[personId]/page.tsx | 11 +- .../(people)/people/actions.ts | 53 +- .../people/components/PersonDataView.tsx | 4 +- .../people/components/PersonTable.tsx | 10 +- .../people/components/PersonTableColumn.tsx | 7 +- .../[environmentId]/(people)/people/page.tsx | 37 +- .../(people)/segments/actions.ts | 34 +- .../components/BasicCreateSegmentModal.tsx | 2 +- .../components/BasicSegmentSettings.tsx | 57 +- .../segments/components/EditSegmentModal.tsx | 8 +- .../segments/components/SegmentTable.tsx | 3 + .../components/SegmentTableDataRow.tsx | 3 + .../SegmentTableDataRowContainer.tsx | 3 + .../(people)/segments/page.tsx | 33 +- .../environments/[environmentId]/actions.ts | 17 +- .../[environmentId]/actions/actions.ts | 48 +- .../actions/components/ActionActivityTab.tsx | 3 +- .../actions/components/ActionClassesTable.tsx | 10 +- .../actions/components/ActionDetailModal.tsx | 7 +- .../actions/components/ActionSettingsTab.tsx | 70 +- .../actions/components/AddActionModal.tsx | 17 +- .../[environmentId]/actions/page.tsx | 48 +- .../components/EnvironmentLayout.tsx | 22 +- .../components/MainNavigation.tsx | 244 +-- .../components/TopControlBar.tsx | 14 +- .../components/TopControlButtons.tsx | 22 +- .../[environmentId]/integrations/actions.ts | 34 +- .../integrations/airtable/page.tsx | 31 +- .../integrations/google-sheets/page.tsx | 31 +- .../integrations/notion/page.tsx | 36 +- .../[environmentId]/integrations/page.tsx | 32 +- .../integrations/slack/actions.ts | 20 +- .../integrations/slack/page.tsx | 37 +- .../integrations/webhooks/actions.ts | 51 +- .../components/SurveyCheckboxGroup.tsx | 60 +- .../components/WebhookDetailModal.tsx | 7 +- .../components/WebhookSettingsTab.tsx | 22 +- .../webhooks/components/WebhookTable.tsx | 3 + .../integrations/webhooks/page.tsx | 32 +- .../environments/[environmentId]/page.tsx | 16 + .../product/(setup)/app-connection/page.tsx | 4 +- .../[environmentId]/product/actions.ts | 24 +- .../product/api-keys/actions.ts | 40 +- .../api-keys/components/ApiKeyList.tsx | 3 + .../api-keys/components/EditApiKeys.tsx | 46 +- .../[environmentId]/product/api-keys/page.tsx | 45 +- .../components/ProductConfigNavigation.tsx | 10 + .../product/general/actions.ts | 20 +- .../general/components/DeleteProduct.tsx | 18 +- .../components/DeleteProductRender.tsx | 19 +- .../components/EditProductNameForm.tsx | 90 +- .../components/EditWaitingTimeForm.tsx | 96 +- .../[environmentId]/product/general/page.tsx | 30 +- .../product/languages/loading.tsx | 2 +- .../product/languages/page.tsx | 21 +- .../[environmentId]/product/layout.tsx | 23 +- .../product/look/components/EditBranding.tsx | 4 +- .../product/look/components/EditLogo.tsx | 164 +- .../look/components/EditPlacementForm.tsx | 262 ++-- .../product/look/components/ThemeStyling.tsx | 14 +- .../[environmentId]/product/look/page.tsx | 34 +- .../[environmentId]/product/tags/actions.ts | 67 +- .../tags/components/EditTagsWrapper.tsx | 109 +- .../[environmentId]/product/tags/page.tsx | 30 +- .../[environmentId]/product/teams/page.tsx | 3 + .../components/AccountSettingsNavbar.tsx | 15 +- .../(account)/notifications/actions.ts | 2 +- .../settings/(account)/notifications/page.tsx | 64 +- .../settings/(account)/profile/actions.ts | 10 +- .../profile/components/DeleteAccount.tsx | 2 +- .../components/DisableTwoFactorModal.tsx | 2 +- .../components/EnableTwoFactorModal.tsx | 2 +- .../settings/(account)/profile/page.tsx | 16 +- .../components/OrganizationSettingsNavbar.tsx | 38 +- .../(organization)/enterprise/page.tsx | 9 +- .../(organization)/general/actions.ts | 148 +- .../general/components/AIToggle.tsx | 74 +- .../general/components/AddMemberModal.tsx | 16 +- .../general/components/BulkInviteTab.tsx | 21 +- .../general/components/DeleteOrganization.tsx | 9 +- .../EditMemberships/EditMemberships.tsx | 10 +- .../EditMemberships/MemberActions.tsx | 3 +- .../EditMemberships/MembersInfo.tsx | 31 +- .../EditMemberships/OrganizationActions.tsx | 8 +- .../components/EditOrganizationName.tsx | 104 -- .../components/EditOrganizationNameForm.tsx | 89 +- .../components/IndividualInviteTab.tsx | 26 +- .../(organization)/general/lib/membership.ts | 166 +++ .../settings/(organization)/general/page.tsx | 15 +- .../(organization)/teams/[teamId]/page.tsx | 3 + .../settings/(organization)/teams/page.tsx | 3 + .../surveys/[surveyId]/(analysis)/actions.ts | 70 +- .../components/ResponseCardModal.tsx | 8 +- .../responses/components/ResponseDataView.tsx | 6 +- .../responses/components/ResponsePage.tsx | 6 +- .../responses/components/ResponseTable.tsx | 14 +- .../components/ResponseTableColumns.tsx | 6 +- .../[surveyId]/(analysis)/responses/page.tsx | 12 +- .../[surveyId]/(analysis)/summary/actions.ts | 90 +- .../summary/components/ShareEmbedSurvey.tsx | 2 +- .../summary/components/SummaryPage.tsx | 6 +- .../summary/components/SurveyAnalysisCTA.tsx | 12 +- .../components/shareEmbedModal/LinkTab.tsx | 2 +- .../(analysis)/summary/lib/emailTemplate.tsx | 2 +- .../[surveyId]/(analysis)/summary/page.tsx | 12 +- .../surveys/[surveyId]/actions.ts | 48 +- .../[surveyId]/components/CustomFilter.tsx | 2 +- .../components/ResultsShareButton.tsx | 2 +- .../components/SurveyStatusDropdown.tsx | 43 +- .../[environmentId]/surveys/actions.ts | 136 +- .../surveys/components/SurveyCard.tsx | 8 +- .../surveys/components/SurveyCopyOptions.tsx | 2 +- .../surveys/components/SurveyDropdownMenu.tsx | 2 +- .../surveys/components/SurveyList.tsx | 6 +- .../[environmentId]/surveys/page.tsx | 24 +- apps/web/app/(auth)/invite/page.tsx | 19 +- .../(organization)/billing/actions.ts | 60 +- .../billing/components/PricingTable.tsx | 2 - .../(organization)/billing/layout.tsx | 4 +- .../settings/(organization)/billing/page.tsx | 4 + .../organizations/[organizationId]/route.ts | 20 +- apps/web/app/api/(internal)/pipeline/route.ts | 32 +- .../api/cron/weekly-summary/lib/product.ts | 1 + apps/web/app/api/cron/weekly-summary/route.ts | 31 +- .../responses/[responseId]/route.ts | 1 - .../app/api/v1/users/forgot-password/route.ts | 2 +- apps/web/app/api/v1/users/me/route.ts | 32 +- .../app/api/v1/users/reset-password/route.ts | 2 +- apps/web/app/api/v1/users/route.ts | 6 +- .../api/v1/users/verification-email/route.ts | 2 +- apps/web/app/lib/api/apiHelper.ts | 30 - apps/web/app/page.tsx | 39 +- apps/web/app/s/[surveyId]/actions.ts | 4 +- .../app/s/[surveyId]/components/PinScreen.tsx | 2 +- .../[organizationId]/invite/actions.ts | 17 +- .../app/setup/organization/create/actions.ts | 2 +- .../components/RemovedFromOrganization.tsx | 2 +- .../(analysis)/responses/page.tsx | 1 + .../[sharingKey]/(analysis)/summary/page.tsx | 1 + apps/web/app/share/[sharingKey]/actions.ts | 2 +- apps/web/lib/cache/membership.ts | 26 + apps/web/lib/cache/organization.ts | 42 + apps/web/lib/cache/team.ts | 39 + .../web/lib/utils/action-client-middleware.ts | 100 ++ .../web/lib/utils/action-client.ts | 20 +- apps/web/lib/utils/helper.ts | 360 +++++ apps/web/lib/utils/services.ts | 591 ++++++++ .../components/DeleteAccountModal/actions.ts | 2 +- .../components/DeleteAccountModal/index.tsx | 4 +- .../components/RatingSmiley/index.tsx | 0 .../components/LanguageDropdown.tsx | 2 +- .../components/SurveyLinkDisplay.tsx | 2 +- .../components/ShareSurveyLink/index.tsx | 6 +- .../components/SingleResponseCard/actions.ts | 160 +- .../components/HiddenFields.tsx | 2 +- .../components/QuestionSkip.tsx | 2 +- .../components/RenderResponse.tsx | 12 +- .../components/ResponseNote.tsx | 4 +- .../components/ResponseTagsWrapper.tsx | 18 +- .../components/ResponseVariables.tsx | 2 +- .../components/SingleResponseCardBody.tsx | 0 .../components/SingleResponseCardHeader.tsx | 12 +- .../SingleResponseCard/components/Smileys.tsx | 0 .../components/VerifiedEmail.tsx | 0 .../components/SingleResponseCard/index.tsx | 12 +- .../components/SingleResponseCard/util.ts | 0 .../components/add-filter-modal.tsx | 0 .../components/advanced-targeting-card.tsx | 0 .../components/create-segment-modal.tsx | 11 +- .../components/segment-editor.tsx | 0 .../components/segment-filter.tsx | 0 .../components/segment-settings.tsx | 50 +- .../ee/advanced-targeting/lib/actions.ts | 223 +++ .../components/insight-sheet/actions.ts | 103 +- .../components/insight-sheet/index.tsx | 2 +- .../modules/ee/insights/experience/actions.ts | 48 +- .../insights/experience/components/stats.tsx | 2 +- .../experience/components/templates-card.tsx | 2 +- .../modules/ee/insights/experience/page.tsx | 8 + .../components/default-language-select.tsx | 0 .../components/edit-language.tsx | 25 +- .../components/language-indicator.tsx | 0 .../components/language-labels.tsx | 0 .../components/language-row.tsx | 0 .../components/language-select.tsx | 0 .../components/language-toggle.tsx | 0 .../components/localized-editor.tsx | 0 .../components/multi-language-card.tsx | 0 .../components/secondary-language-select.tsx | 0 .../ee/multi-language-surveys}/lib/actions.ts | 85 +- .../web/modules/ee/role-management/actions.ts | 70 + .../components/add-member-role.tsx | 24 +- .../components/edit-membership-role.tsx | 116 ++ .../ee/role-management/lib/membership.ts | 89 ++ apps/web/modules/ee/teams/lib/roles.ts | 101 ++ .../modules/ee/teams/product-teams/actions.ts | 98 ++ .../product-teams/components/access-table.tsx | 164 ++ .../product-teams/components/access-view.tsx | 52 + .../components/add-team-modal.tsx | 93 ++ .../product-teams/components/add-team.tsx | 58 + .../ee/teams/product-teams/lib/teams.ts | 277 ++++ .../modules/ee/teams/product-teams/page.tsx | 70 + .../ee/teams/product-teams/types/teams.ts | 21 + .../modules/ee/teams/team-details/actions.ts | 243 +++ .../components/add-team-member-modal.tsx | 96 ++ .../components/add-team-product-modal.tsx | 92 ++ .../team-details/components/delete-team.tsx | 79 + .../team-details/components/details-view.tsx | 87 ++ .../components/edit-team-name-form.tsx | 106 ++ .../team-details/components/team-members.tsx | 244 +++ .../components/team-navigation.tsx | 32 + .../team-details/components/team-products.tsx | 206 +++ .../team-details/components/team-settings.tsx | 29 + .../ee/teams/team-details/lib/teams.ts | 697 +++++++++ .../modules/ee/teams/team-details/page.tsx | 70 + .../ee/teams/team-details/types/teams.ts | 43 + .../web/modules/ee/teams/team-list/actions.ts | 49 + .../components/create-team-button.tsx | 29 + .../components/create-team-modal.tsx | 92 ++ .../team-list/components/teams-table.tsx | 90 ++ .../teams/team-list/components/teams-view.tsx | 30 + .../modules/ee/teams/team-list/lib/teams.ts | 198 +++ apps/web/modules/ee/teams/team-list/page.tsx | 59 + .../modules/ee/teams/team-list/types/teams.ts | 22 + apps/web/modules/ee/teams/utils/teams.ts | 34 + .../email/components/email-button.tsx | 0 .../email/components/email-footer.tsx | 0 .../email/components/email-template.tsx | 0 .../components/preview-email-template.tsx | 2 +- .../emails/auth/forgot-password-email.tsx | 0 .../auth/password-reset-notify-email.tsx | 0 .../email/emails/auth/verification-email.tsx | 0 .../emails/invite/invite-accepted-email.tsx | 0 .../email/emails/invite/invite-email.tsx | 0 .../emails/invite/onboarding-invite-email.tsx | 0 .../survey/embed-survey-preview-email.tsx | 0 .../email/emails/survey/link-survey-email.tsx | 0 .../emails/survey/response-finished-email.tsx | 0 .../create-reminder-notification-body.tsx | 0 .../live-survey-notification.tsx | 0 .../no-live-survey-notification-email.tsx | 0 .../weekly-summary/notification-footer.tsx | 0 .../weekly-summary/notification-header.tsx | 0 .../weekly-summary/notification-insight.tsx | 0 .../weekly-summary-notification-email.tsx | 0 .../web/modules}/email/index.tsx | 0 .../web/modules}/email/lib/utils.ts | 1 - .../CreateOrganizationModal/index.tsx | 14 +- .../components/FallbackInput.tsx | 4 +- .../components/RecallItemSelect.tsx | 8 +- .../components/QuestionFormInput/index.tsx | 10 +- .../components/QuestionFormInput/utils.ts | 0 .../components/TemplateList/actions.ts | 20 +- .../components/StartFromScratchTemplate.tsx | 2 +- .../TemplateList/components/Template.tsx | 2 +- .../components/TemplateFilters.tsx | 0 .../TemplateList/components/TemplateTags.tsx | 2 +- .../components/TemplateList/index.tsx | 2 +- .../components/TemplateList/lib/utils.ts | 0 .../web/modules/utils}/hooks/actions.ts | 17 +- .../modules/utils}/hooks/useGetBillingInfo.ts | 0 apps/web/package.json | 1 - apps/web/playwright/onboarding.spec.ts | 8 +- apps/web/playwright/organization.spec.ts | 129 ++ apps/web/playwright/utils/helper.ts | 2 +- ...rmbricks-organization-members-template.csv | 8 +- docker/docker-compose.yml | 2 +- .../data-migration.ts | 326 ++++ .../20241107161932_add_teams/migration.sql | 95 ++ packages/database/package.json | 3 +- packages/database/schema.prisma | 83 +- packages/ee/advanced-targeting/lib/actions.ts | 148 -- .../components/edit-membership-role.tsx | 150 -- .../components/transfer-ownership-modal.tsx | 71 - packages/ee/role-management/lib/actions.ts | 84 -- packages/email/.eslintrc.js | 7 - packages/email/package.json | 26 - packages/email/tsconfig.json | 13 - packages/lib/actionClass/auth.ts | 34 - packages/lib/actionClient/helper.ts | 17 - packages/lib/actionClient/permissions.ts | 290 ++-- packages/lib/actionClient/utils.ts | 65 - packages/lib/auth.ts | 10 +- packages/lib/authOptions.ts | 4 +- packages/lib/env.ts | 2 +- packages/lib/environment/auth.ts | 50 +- packages/lib/environment/service.ts | 8 +- packages/lib/invite/service.ts | 3 +- .../membership/hooks/useMembershipRole.tsx | 13 +- packages/lib/membership/service.ts | 221 +-- packages/lib/membership/utils.ts | 18 +- packages/lib/messages/de-DE.json | 97 +- packages/lib/messages/en-US.json | 97 +- packages/lib/messages/pt-BR.json | 95 +- packages/lib/organization/auth.ts | 4 +- packages/lib/organization/utils.ts | 155 -- packages/lib/product/auth.ts | 60 - packages/lib/product/service.ts | 72 +- packages/lib/survey/auth.ts | 31 - packages/lib/tag/auth.ts | 30 - packages/lib/tagOnResponse/auth.ts | 30 - packages/lib/tsconfig.json | 2 +- packages/lib/user/service.ts | 11 - packages/types/action-client.ts | 28 - packages/types/errors.ts | 2 +- packages/types/invites.ts | 9 +- packages/types/memberships.ts | 8 +- packages/types/product.ts | 1 + packages/types/weekly-summary.ts | 1 + packages/ui/components/Breadcrumb/index.tsx | 98 ++ packages/ui/components/DataTable/actions.ts | 24 - .../DataTable/components/DataTableToolbar.tsx | 4 +- .../components/SelectedRowSettings.tsx | 14 +- .../FileInput/components/Uploader.tsx | 14 +- packages/ui/components/FileInput/index.tsx | 4 + .../ui/components/IntegrationCard/index.tsx | 13 +- packages/ui/components/MultiSelect/index.tsx | 124 ++ .../ui/components/RatingResponse/index.tsx | 2 +- .../ui/components/ShareSurveyLink/actions.ts | 25 - packages/ui/components/TabToggle/index.tsx | 6 +- .../organisms/CodeActionForm/index.tsx | 8 +- .../components/CssSelector.tsx | 5 +- .../components/InnerHtmlSelector.tsx | 5 +- .../components/PageUrlSelector.tsx | 18 +- .../organisms/NoCodeActionForm/index.tsx | 10 +- packages/ui/tailwind.config.js | 1 + packages/ui/tsconfig.json | 7 +- pnpm-lock.yaml | 1317 +---------------- 391 files changed, 10846 insertions(+), 4654 deletions(-) create mode 100644 apps/web/app/(app)/(onboarding)/lib/onboarding.ts create mode 100644 apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx create mode 100644 apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx create mode 100644 apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx create mode 100644 apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/layout.tsx create mode 100644 apps/web/app/(app)/(onboarding)/types/onboarding.ts create mode 100644 apps/web/app/(app)/environments/[environmentId]/(people)/layout.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/product/teams/page.tsx delete mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationName.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/lib/membership.ts create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/[teamId]/page.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.tsx create mode 100644 apps/web/lib/cache/membership.ts create mode 100644 apps/web/lib/cache/organization.ts create mode 100644 apps/web/lib/cache/team.ts create mode 100644 apps/web/lib/utils/action-client-middleware.ts rename packages/lib/actionClient/index.ts => apps/web/lib/utils/action-client.ts (65%) create mode 100644 apps/web/lib/utils/helper.ts create mode 100644 apps/web/lib/utils/services.ts rename {packages/ui => apps/web/modules/account}/components/DeleteAccountModal/actions.ts (73%) rename {packages/ui => apps/web/modules/account}/components/DeleteAccountModal/index.tsx (95%) rename {packages/ui => apps/web/modules/analysis}/components/RatingSmiley/index.tsx (100%) rename {packages/ui => apps/web/modules/analysis}/components/ShareSurveyLink/components/LanguageDropdown.tsx (97%) rename {packages/ui => apps/web/modules/analysis}/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx (86%) rename {packages/ui => apps/web/modules/analysis}/components/ShareSurveyLink/index.tsx (94%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/actions.ts (51%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/HiddenFields.tsx (97%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/QuestionSkip.tsx (99%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/RenderResponse.tsx (92%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/ResponseNote.tsx (98%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/ResponseTagsWrapper.tsx (92%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/ResponseVariables.tsx (97%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/SingleResponseCardBody.tsx (100%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/SingleResponseCardHeader.tsx (97%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/Smileys.tsx (100%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/components/VerifiedEmail.tsx (100%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/index.tsx (96%) rename {packages/ui => apps/web/modules/analysis}/components/SingleResponseCard/util.ts (100%) rename {packages => apps/web/modules}/ee/advanced-targeting/components/add-filter-modal.tsx (100%) rename {packages => apps/web/modules}/ee/advanced-targeting/components/advanced-targeting-card.tsx (100%) rename {packages => apps/web/modules}/ee/advanced-targeting/components/create-segment-modal.tsx (95%) rename {packages => apps/web/modules}/ee/advanced-targeting/components/segment-editor.tsx (100%) rename {packages => apps/web/modules}/ee/advanced-targeting/components/segment-filter.tsx (100%) rename {packages => apps/web/modules}/ee/advanced-targeting/components/segment-settings.tsx (88%) create mode 100644 apps/web/modules/ee/advanced-targeting/lib/actions.ts rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/default-language-select.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/edit-language.tsx (94%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/language-indicator.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/language-labels.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/language-row.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/language-select.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/language-toggle.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/localized-editor.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/multi-language-card.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/components/secondary-language-select.tsx (100%) rename {packages/ee/multi-language => apps/web/modules/ee/multi-language-surveys}/lib/actions.ts (53%) create mode 100644 apps/web/modules/ee/role-management/actions.ts rename {packages => apps/web/modules}/ee/role-management/components/add-member-role.tsx (66%) create mode 100644 apps/web/modules/ee/role-management/components/edit-membership-role.tsx create mode 100644 apps/web/modules/ee/role-management/lib/membership.ts create mode 100644 apps/web/modules/ee/teams/lib/roles.ts create mode 100644 apps/web/modules/ee/teams/product-teams/actions.ts create mode 100644 apps/web/modules/ee/teams/product-teams/components/access-table.tsx create mode 100644 apps/web/modules/ee/teams/product-teams/components/access-view.tsx create mode 100644 apps/web/modules/ee/teams/product-teams/components/add-team-modal.tsx create mode 100644 apps/web/modules/ee/teams/product-teams/components/add-team.tsx create mode 100644 apps/web/modules/ee/teams/product-teams/lib/teams.ts create mode 100644 apps/web/modules/ee/teams/product-teams/page.tsx create mode 100644 apps/web/modules/ee/teams/product-teams/types/teams.ts create mode 100644 apps/web/modules/ee/teams/team-details/actions.ts create mode 100644 apps/web/modules/ee/teams/team-details/components/add-team-member-modal.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/add-team-product-modal.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/delete-team.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/details-view.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/edit-team-name-form.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/team-members.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/team-navigation.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/team-products.tsx create mode 100644 apps/web/modules/ee/teams/team-details/components/team-settings.tsx create mode 100644 apps/web/modules/ee/teams/team-details/lib/teams.ts create mode 100644 apps/web/modules/ee/teams/team-details/page.tsx create mode 100644 apps/web/modules/ee/teams/team-details/types/teams.ts create mode 100644 apps/web/modules/ee/teams/team-list/actions.ts create mode 100644 apps/web/modules/ee/teams/team-list/components/create-team-button.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/teams-table.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/teams-view.tsx create mode 100644 apps/web/modules/ee/teams/team-list/lib/teams.ts create mode 100644 apps/web/modules/ee/teams/team-list/page.tsx create mode 100644 apps/web/modules/ee/teams/team-list/types/teams.ts create mode 100644 apps/web/modules/ee/teams/utils/teams.ts rename {packages => apps/web/modules}/email/components/email-button.tsx (100%) rename {packages => apps/web/modules}/email/components/email-footer.tsx (100%) rename {packages => apps/web/modules}/email/components/email-template.tsx (100%) rename {packages => apps/web/modules}/email/components/preview-email-template.tsx (99%) rename {packages => apps/web/modules}/email/emails/auth/forgot-password-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/auth/password-reset-notify-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/auth/verification-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/invite/invite-accepted-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/invite/invite-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/invite/onboarding-invite-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/survey/embed-survey-preview-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/survey/link-survey-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/survey/response-finished-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/weekly-summary/create-reminder-notification-body.tsx (100%) rename {packages => apps/web/modules}/email/emails/weekly-summary/live-survey-notification.tsx (100%) rename {packages => apps/web/modules}/email/emails/weekly-summary/no-live-survey-notification-email.tsx (100%) rename {packages => apps/web/modules}/email/emails/weekly-summary/notification-footer.tsx (100%) rename {packages => apps/web/modules}/email/emails/weekly-summary/notification-header.tsx (100%) rename {packages => apps/web/modules}/email/emails/weekly-summary/notification-insight.tsx (100%) rename {packages => apps/web/modules}/email/emails/weekly-summary/weekly-summary-notification-email.tsx (100%) rename {packages => apps/web/modules}/email/index.tsx (100%) rename {packages => apps/web/modules}/email/lib/utils.ts (92%) rename {packages/ui => apps/web/modules/organization}/components/CreateOrganizationModal/index.tsx (90%) rename {packages/ui => apps/web/modules/surveys}/components/QuestionFormInput/components/FallbackInput.tsx (95%) rename {packages/ui => apps/web/modules/surveys}/components/QuestionFormInput/components/RecallItemSelect.tsx (97%) rename {packages/ui => apps/web/modules/surveys}/components/QuestionFormInput/index.tsx (98%) rename {packages/ui => apps/web/modules/surveys}/components/QuestionFormInput/utils.ts (100%) rename {packages/ui => apps/web/modules/surveys}/components/TemplateList/actions.ts (53%) rename {packages/ui => apps/web/modules/surveys}/components/TemplateList/components/StartFromScratchTemplate.tsx (97%) rename {packages/ui => apps/web/modules/surveys}/components/TemplateList/components/Template.tsx (97%) rename {packages/ui => apps/web/modules/surveys}/components/TemplateList/components/TemplateFilters.tsx (100%) rename {packages/ui => apps/web/modules/surveys}/components/TemplateList/components/TemplateTags.tsx (98%) rename {packages/ui => apps/web/modules/surveys}/components/TemplateList/index.tsx (98%) rename {packages/ui => apps/web/modules/surveys}/components/TemplateList/lib/utils.ts (100%) rename {packages/lib/organization => apps/web/modules/utils}/hooks/actions.ts (57%) rename {packages/lib/organization => apps/web/modules/utils}/hooks/useGetBillingInfo.ts (100%) create mode 100644 packages/database/data-migrations/20241107161932_add_teams/data-migration.ts create mode 100644 packages/database/migrations/20241107161932_add_teams/migration.sql delete mode 100644 packages/ee/advanced-targeting/lib/actions.ts delete mode 100644 packages/ee/role-management/components/edit-membership-role.tsx delete mode 100644 packages/ee/role-management/components/transfer-ownership-modal.tsx delete mode 100644 packages/ee/role-management/lib/actions.ts delete mode 100644 packages/email/.eslintrc.js delete mode 100644 packages/email/package.json delete mode 100644 packages/email/tsconfig.json delete mode 100644 packages/lib/actionClient/helper.ts delete mode 100644 packages/lib/actionClient/utils.ts delete mode 100644 packages/lib/organization/utils.ts delete mode 100644 packages/lib/product/auth.ts delete mode 100644 packages/types/action-client.ts create mode 100644 packages/ui/components/Breadcrumb/index.tsx delete mode 100644 packages/ui/components/DataTable/actions.ts create mode 100644 packages/ui/components/MultiSelect/index.tsx delete mode 100644 packages/ui/components/ShareSurveyLink/actions.ts diff --git a/.env.example b/.env.example index bfa0f955c5..16437ea93b 100644 --- a/.env.example +++ b/.env.example @@ -157,7 +157,7 @@ ENTERPRISE_LICENSE_KEY= # Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn) # (Role Management is an Enterprise feature) # DEFAULT_ORGANIZATION_ID= -# DEFAULT_ORGANIZATION_ROLE=admin +# DEFAULT_ORGANIZATION_ROLE=owner # Send new users to customer.io # CUSTOMER_IO_API_KEY= diff --git a/apps/docs/app/global/access-roles/page.mdx b/apps/docs/app/global/access-roles/page.mdx index 268770519d..c2d3eaf764 100644 --- a/apps/docs/app/global/access-roles/page.mdx +++ b/apps/docs/app/global/access-roles/page.mdx @@ -13,64 +13,91 @@ export const metadata = { # Organization Access Roles -Assign different roles to organization members to grant them specific rights like creating surveys, viewing responses, or managing organization members. +Learn about the different organization-level and team-level roles and how they affect permissions in Formbricks. + +## Memberships + +Permissions in Formbricks are broadly handled using organization-level roles, which apply to all teams and projects in the organization. Users on a self-hosting and Enterprise plan, have access to team-level roles, which enable more granular permissions. Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free** - and **Startup** plan in the Cloud you can invite unlimited organization members as `Admins`. + and **Startup** plan in the Cloud you can invite unlimited organization members as `Owner`. Here are the different access permissions, ranked from highest to lowest access 1. Owner -2. Admin -3. Developer -4. Editor -5. Viewer +2. Manager +3. Billing +4. Member + +### Organisational level + +All users and their organization-level roles are listed in **Organization Settings > General**. Users can hold any of the following org-level roles: + +- **Billing** users can manage payment and compliance details in the organization. +- **Org Members** can view most data in the organization and act in the products they are members of. They cannot join products on their own and need to be assigned. +- **Org Managers** have full management access to all teams and products. They can also manage the organization's membership. Org Managers can perform Team Admin actions without needing to join the team. They cannot change other organization settings. +- **Org Owners** have full access to the organization, its data, and settings. Org Owners can perform Team Admin actions without needing to join the team. + +### Permissions at product level + +- **read**: read access to all resources (except settings) in the product. +- **read & write**: read & write access to all resources (except settings) in the product. +- **manage**: read & write access to all resources including settings in the product. + +### Team-level Roles + +- **Team Contributors** can view and act on surveys and responses. +- **Team Admins** have additional permissions to manage their team's membership and products. These permissions are granted at the team-level, and don't apply to teams where they're not a Team Admin. For more information on user roles & permissions, see below: -| | Owner | Admin | Editor | Developer | Viewer | -| -------------------------------- | ----- | ----- | ------ | --------- | ------ | -| **Organization** | | | | | | -| Update organization | ✅ | ✅ | ❌ | ❌ | ❌ | -| Delete organization | ✅ | ❌ | ❌ | ❌ | ❌ | -| Add new Member | ✅ | ✅ | ❌ | ❌ | ❌ | -| Delete Member | ✅ | ✅ | ❌ | ❌ | ❌ | -| Update Member Access | ✅ | ✅ | ❌ | ❌ | ❌ | -| Update Billing | ✅ | ✅ | ❌ | ❌ | ❌ | -| **Product** | | | | | | -| Create Product | ✅ | ✅ | ❌ | ❌ | ❌ | -| Update Product Name | ✅ | ✅ | ✅ | ❌ | ❌ | -| Update Product Recontact Options | ✅ | ✅ | ✅ | ✅ | ❌ | -| Update Look & Feel | ✅ | ✅ | ✅ | ✅ | ❌ | -| Update Survey Languages | ✅ | ✅ | ✅ | ✅ | ❌ | -| Delete Product | ✅ | ✅ | ✅ | ✅ | ❌ | -| **Surveys** | | | | | | -| Create New Survey | ✅ | ✅ | ✅ | ✅ | ❌ | -| Edit Survey | ✅ | ✅ | ✅ | ✅ | ❌ | -| Delete Survey | ✅ | ✅ | ✅ | ✅ | ❌ | -| View survey results | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Response** | | | | | | -| Delete response | ✅ | ✅ | ✅ | ✅ | ❌ | -| Add tags on response | ✅ | ✅ | ✅ | ✅ | ❌ | -| Edit tags on response | ✅ | ✅ | ✅ | ✅ | ❌ | -| **Actions** | | | | | | -| Create Action | ✅ | ✅ | ✅ | ✅ | ❌ | -| Update Action | ✅ | ✅ | ✅ | ✅ | ❌ | -| Delete Action | ✅ | ✅ | ✅ | ✅ | ❌ | -| **API Keys** | | | | | | -| Create API key | ✅ | ✅ | ✅ | ✅ | ❌ | -| Update API key | ✅ | ✅ | ✅ | ✅ | ❌ | -| Delete API key | ✅ | ✅ | ✅ | ✅ | ❌ | -| **Tags** | | | | | | -| Create tags | ✅ | ✅ | ✅ | ✅ | ❌ | -| Update tags | ✅ | ✅ | ✅ | ✅ | ❌ | -| Delete tags | ✅ | ✅ | ✅ | ✅ | ❌ | -| **People** | | | | | | -| Delete Person | ✅ | ✅ | ✅ | ✅ | ❌ | -| **Integrations** | | | | | | -| Manage Integrations | ✅ | ✅ | ✅ | ✅ | ❌ | +| | Owner | Manager | Billing | Member | +| -------------------------------- | ----- | ------- | ------- | ------ | +| **Organization** | | | | | +| Update organization | ✅ | ❌ | ❌ | ❌ | +| Delete organization | ✅ | ❌ | ❌ | ❌ | +| Add new Member | ✅ | ✅ | ❌ | ❌ | +| Delete Member | ✅ | ✅ | ❌ | ❌ | +| Update Member Access | ✅ | ✅ | ❌ | ❌ | +| Update Billing | ✅ | ✅ | ✅ | ❌ | +| **Product** | | | | | +| Create Product | ✅ | ✅ | ❌ | ❌ | +| Update Product Name | ✅ | ✅ | ❌ | ✅\*\* | +| Update Product Recontact Options | ✅ | ✅ | ❌ | ✅\*\* | +| Update Look & Feel | ✅ | ✅ | ❌ | ✅\*\* | +| Update Survey Languages | ✅ | ✅ | ❌ | ✅\*\* | +| Delete Product | ✅ | ✅ | ❌ | ❌ | +| **Surveys** | | | | | +| Create New Survey | ✅ | ✅ | ❌ | ✅\* | +| Edit Survey | ✅ | ✅ | ❌ | ✅\* | +| Delete Survey | ✅ | ✅ | ❌ | ✅\* | +| View survey results | ✅ | ✅ | ❌ | ✅ | +| **Response** | | | | | +| Delete response | ✅ | ✅ | ❌ | ✅\* | +| Add tags on response | ✅ | ✅ | ❌ | ✅\* | +| Edit tags on response | ✅ | ✅ | ❌ | ✅\* | +| **Actions** | | | | | +| Create Action | ✅ | ✅ | ❌ | ✅\* | +| Update Action | ✅ | ✅ | ❌ | ✅\* | +| Delete Action | ✅ | ✅ | ❌ | ✅\* | +| **API Keys** | | | | | +| Create API key | ✅ | ✅ | ❌ | ✅\*\* | +| Update API key | ✅ | ✅ | ❌ | ✅\*\* | +| Delete API key | ✅ | ✅ | ❌ | ✅\*\* | +| **Tags** | | | | | +| Create tags | ✅ | ✅ | ❌ | ✅\* | +| Update tags | ✅ | ✅ | ❌ | ✅\* | +| Delete tags | ✅ | ✅ | ❌ | ✅\*\* | +| **People** | | | | | +| Delete Person | ✅ | ✅ | ❌ | ✅\* | +| **Integrations** | | | | | +| Manage Integrations | ✅ | ✅ | ❌ | ✅\* | + +\* - for the read & write permissions team members + +\*\* - for the manage permissions team members ## Inviting organization members diff --git a/apps/docs/app/self-hosting/configuration/page.mdx b/apps/docs/app/self-hosting/configuration/page.mdx index 7e94b7d48e..1954329eb1 100644 --- a/apps/docs/app/self-hosting/configuration/page.mdx +++ b/apps/docs/app/self-hosting/configuration/page.mdx @@ -63,7 +63,7 @@ These variables are present inside your machine’s docker-compose file. Restart | TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | | | DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b | | DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | | -| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin | +| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | owner | | OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | | | OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | | | OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | | diff --git a/apps/docs/lib/navigation.ts b/apps/docs/lib/navigation.ts index 56cbc04a00..ab4e1376ae 100644 --- a/apps/docs/lib/navigation.ts +++ b/apps/docs/lib/navigation.ts @@ -126,7 +126,7 @@ export const navigation: Array = [ { title: "Zapier", href: "/developer-docs/integrations/zapier" }, ], }, - { title: "Access Roles", href: "/global/access-roles" }, + { title: "Organization and User Management", href: "/global/access-roles" }, { title: "Styling Theme", href: "/global/styling-theme" }, ], }, diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember.tsx index 8956c45365..be3de18595 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember.tsx @@ -40,7 +40,7 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite await inviteOrganizationMemberAction({ organizationId: organization.id, email: data.email, - role: "developer", + role: "member", inviteMessage: data.inviteMessage, }); toast.success("Invite sent successful"); diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/invite/page.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/invite/page.tsx index 417bfd296c..bcc06022f6 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/invite/page.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/invite/page.tsx @@ -27,7 +27,7 @@ const Page = async ({ params }: InvitePageProps) => { } const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); - if (!membership || (membership.role !== "owner" && membership.role !== "admin")) { + if (!membership || (membership.role !== "owner" && membership.role !== "manager")) { return notFound(); } diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx index fafe783345..2a5e2c5c9d 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.tsx @@ -3,17 +3,17 @@ import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils"; import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { createSurveyAction } from "@/modules/surveys/components/TemplateList/actions"; import { ActivityIcon, ShoppingCartIcon, UsersIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { TProduct } from "@formbricks/types/product"; import { TSurveyCreateInput } from "@formbricks/types/surveys/types"; import { TXMTemplate } from "@formbricks/types/templates"; import { TUser } from "@formbricks/types/user"; -import { createSurveyAction } from "@formbricks/ui/components/TemplateList/actions"; interface XMTemplateListProps { product: TProduct; diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx index 27554ceeba..9fcda866e3 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx @@ -1,11 +1,11 @@ import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; +import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { XIcon } from "lucide-react"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { authOptions } from "@formbricks/lib/authOptions"; import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; -import { getProductByEnvironmentId, getProducts } from "@formbricks/lib/product/service"; +import { getProductByEnvironmentId, getUserProducts } from "@formbricks/lib/product/service"; import { getUser } from "@formbricks/lib/user/service"; import { Button } from "@formbricks/ui/components/Button"; import { Header } from "@formbricks/ui/components/Header"; @@ -39,7 +39,7 @@ const Page = async ({ params }: XMTemplatePageProps) => { throw new Error(t("common.product_not_found")); } - const products = await getProducts(organizationId); + const products = await getUserProducts(session.user.id, organizationId); return (
diff --git a/apps/web/app/(app)/(onboarding)/lib/onboarding.ts b/apps/web/app/(app)/(onboarding)/lib/onboarding.ts new file mode 100644 index 0000000000..ce15350c04 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/lib/onboarding.ts @@ -0,0 +1,48 @@ +"use server"; + +import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding"; +import { teamCache } from "@/lib/cache/team"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getTeamsByOrganizationId = reactCache( + (organizationId: string): Promise => + cache( + async () => { + validateInputs([organizationId, ZId]); + try { + const teams = await prisma.team.findMany({ + where: { + organizationId, + }, + select: { + id: true, + name: true, + }, + }); + + const productTeams = teams.map((team) => ({ + id: team.id, + name: team.name, + })); + + return productTeams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getTeamsByOrganizationId-${organizationId}`], + { + tags: [teamCache.tag.byOrganizationId(organizationId)], + } + )() +); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx new file mode 100644 index 0000000000..3d11bf735e --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { formbricksLogout } from "@/app/lib/formbricks"; +import FBLogo from "@/images/formbricks-wordmark.svg"; +import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; +import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react"; +import { signOut } from "next-auth/react"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { AiOutlineDiscord } from "react-icons/ai"; +import { cn } from "@formbricks/lib/cn"; +import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import { ProfileAvatar } from "@formbricks/ui/components/Avatars"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@formbricks/ui/components/DropdownMenu"; + +interface LandingSidebarProps { + isMultiOrgEnabled: boolean; + user: TUser; + organization: TOrganization; + organizations: TOrganization[]; +} + +export const LandingSidebar = ({ + isMultiOrgEnabled, + user, + organization, + organizations, +}: LandingSidebarProps) => { + const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false); + + const t = useTranslations(); + + const router = useRouter(); + + const handleEnvironmentChangeByOrganization = (organizationId: string) => { + router.push(`/organizations/${organizationId}/`); + }; + + const dropdownNavigation = [ + { + label: t("common.documentation"), + href: "https://formbricks.com/docs", + target: "_blank", + icon: ArrowUpRightIcon, + }, + { + label: t("common.join_discord"), + href: "https://formbricks.com/discord", + target: "_blank", + icon: AiOutlineDiscord, + }, + ]; + + const currentOrganizationId = organization?.id; + const currentOrganizationName = capitalizeFirstLetter(organization?.name); + + const sortedOrganizations = useMemo(() => { + return [...organizations].sort((a, b) => a.name.localeCompare(b.name)); + }, [organizations]); + + return ( + + ); +}; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx new file mode 100644 index 0000000000..678d2c903a --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx @@ -0,0 +1,35 @@ +import { getServerSession } from "next-auth"; +import { notFound, redirect } from "next/navigation"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getEnvironments } from "@formbricks/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getUserProducts } from "@formbricks/lib/product/service"; + +const LandingLayout = async ({ children, params }) => { + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return redirect(`/auth/login`); + } + + const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId); + + if (!membership) { + return notFound(); + } + + const products = await getUserProducts(session.user.id, params.organizationId); + + if (products.length !== 0) { + const firstProduct = products[0]; + const environments = await getEnvironments(firstProduct.id); + const prodEnvironment = environments.find((e) => e.type === "production"); + + if (prodEnvironment) { + return redirect(`/environments/${prodEnvironment.id}/`); + } + } + + return <>{children}; +}; + +export default LandingLayout; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx new file mode 100644 index 0000000000..bf91908452 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx @@ -0,0 +1,50 @@ +import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; +import { getServerSession } from "next-auth"; +import { getTranslations } from "next-intl/server"; +import { notFound, redirect } from "next/navigation"; +import { getEnterpriseLicense } from "@formbricks/ee/lib/service"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service"; +import { getUser } from "@formbricks/lib/user/service"; +import { Header } from "@formbricks/ui/components/Header"; + +const Page = async ({ params }) => { + const t = await getTranslations(); + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return redirect(`/auth/login`); + } + + const user = await getUser(session.user.id); + if (!user) return notFound(); + + const organization = await getOrganization(params.organizationId); + if (!organization) return notFound(); + + const organizations = await getOrganizationsByUserId(session.user.id); + + const { features } = await getEnterpriseLicense(); + + const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; + + return ( +
+ +
+
+
+
+
+
+ ); +}; + +export default Page; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx index 5100d0b18f..b76e783c7b 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx @@ -1,9 +1,8 @@ import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; -import { notFound, redirect } from "next/navigation"; +import { redirect } from "next/navigation"; import { authOptions } from "@formbricks/lib/authOptions"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; import { getOrganization } from "@formbricks/lib/organization/service"; import { getUser } from "@formbricks/lib/user/service"; @@ -27,9 +26,6 @@ const ProductOnboardingLayout = async ({ children, params }) => { throw AuthorizationError; } - const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId); - if (!membership || membership.role === "viewer") return notFound(); - const organization = await getOrganization(params.organizationId); if (!organization) { throw new Error(t("common.organization_not_found")); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/layout.tsx new file mode 100644 index 0000000000..91ee9ae579 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/layout.tsx @@ -0,0 +1,20 @@ +import { getServerSession } from "next-auth"; +import { notFound, redirect } from "next/navigation"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; + +const OnboardingLayout = async ({ children, params }) => { + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return redirect(`/auth/login`); + } + + const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId); + const { isMember, isBilling } = getAccessFlags(membership?.role); + if (isMember || isBilling) return notFound(); + + return <>{children}; +}; + +export default OnboardingLayout; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/channel/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/channel/page.tsx index a7d7999d75..66b427f2db 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/channel/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/channel/page.tsx @@ -1,7 +1,10 @@ import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { GlobeIcon, GlobeLockIcon, LinkIcon, XIcon } from "lucide-react"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; -import { getProducts } from "@formbricks/lib/product/service"; +import { redirect } from "next/navigation"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getUserProducts } from "@formbricks/lib/product/service"; import { Button } from "@formbricks/ui/components/Button"; import { Header } from "@formbricks/ui/components/Header"; @@ -12,6 +15,11 @@ interface ChannelPageProps { } const Page = async ({ params }: ChannelPageProps) => { + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return redirect(`/auth/login`); + } + const t = await getTranslations(); const channelOptions = [ { @@ -38,7 +46,7 @@ const Page = async ({ params }: ChannelPageProps) => { }, ]; - const products = await getProducts(params.organizationId); + const products = await getUserProducts(session.user.id, params.organizationId); return (
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/mode/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/mode/page.tsx index 239e932d09..22bd385d62 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/mode/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/mode/page.tsx @@ -1,7 +1,10 @@ import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; -import { getProducts } from "@formbricks/lib/product/service"; +import { redirect } from "next/navigation"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getUserProducts } from "@formbricks/lib/product/service"; import { Button } from "@formbricks/ui/components/Button"; import { Header } from "@formbricks/ui/components/Header"; @@ -12,6 +15,11 @@ interface ModePageProps { } const Page = async ({ params }: ModePageProps) => { + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return redirect(`/auth/login`); + } + const t = await getTranslations(); const channelOptions = [ { @@ -28,7 +36,7 @@ const Page = async ({ params }: ModePageProps) => { }, ]; - const products = await getProducts(params.organizationId); + const products = await getUserProducts(session.user.id, params.organizationId); return (
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx index a2300bf46d..948cf8be14 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx @@ -1,13 +1,16 @@ "use client"; import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { TOrganizationTeam } from "@/modules/ee/teams/product-teams/types/teams"; +import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage"; import { getPreviewSurvey } from "@formbricks/lib/styling/constants"; import { @@ -29,6 +32,7 @@ import { FormProvider, } from "@formbricks/ui/components/Form"; import { Input } from "@formbricks/ui/components/Input"; +import { MultiSelect } from "@formbricks/ui/components/MultiSelect"; import { SurveyInline } from "@formbricks/ui/components/Survey"; interface ProductSettingsProps { @@ -37,6 +41,8 @@ interface ProductSettingsProps { channel: TProductConfigChannel; industry: TProductConfigIndustry; defaultBrandColor: string; + organizationTeams: TOrganizationTeam[]; + canDoRoleManagement: boolean; locale: string; } @@ -46,8 +52,12 @@ export const ProductSettings = ({ channel, industry, defaultBrandColor, + organizationTeams, + canDoRoleManagement = false, locale, }: ProductSettingsProps) => { + const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); + const router = useRouter(); const t = useTranslations(); const addProduct = async (data: TProductUpdateInput) => { @@ -57,6 +67,7 @@ export const ProductSettings = ({ data: { ...data, config: { channel, industry }, + teamIds: data.teamIds, }, }); @@ -92,6 +103,7 @@ export const ProductSettings = ({ defaultValues: { name: "", styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } }, + teamIds: [], }, resolver: zodResolver(ZProductUpdateInput), }); @@ -99,6 +111,11 @@ export const ProductSettings = ({ const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor; const { isSubmitting } = form.formState; + const organizationTeamsOptions = organizationTeams.map((team) => ({ + label: team.name, + value: team.id, + })); + return (
@@ -155,8 +172,41 @@ export const ProductSettings = ({ )} /> + {canDoRoleManagement && ( + ( + +
+
+ Teams + Who all can access this product? +
+ +
+ +
+ field.onChange(teamIds)} + options={organizationTeamsOptions} + /> + {error?.message && {error.message}} +
+
+
+ )} + /> + )}
-
@@ -175,7 +225,7 @@ export const ProductSettings = ({ /> )}

{t("common.preview")}

-
+
+ { + form.setValue("teamIds", [...(form.getValues("teamIds") || []), teamId]); + }} + />
); }; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/page.tsx index cb9388754f..929d93d04c 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/page.tsx @@ -1,11 +1,15 @@ +import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils"; import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings"; import { XIcon } from "lucide-react"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { getRoleManagementPermission } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; import { DEFAULT_BRAND_COLOR, DEFAULT_LOCALE } from "@formbricks/lib/constants"; -import { getProducts } from "@formbricks/lib/product/service"; +import { getOrganization } from "@formbricks/lib/organization/service"; +import { getUserProducts } from "@formbricks/lib/product/service"; import { getUserLocale } from "@formbricks/lib/user/service"; import { TProductConfigChannel, TProductConfigIndustry, TProductMode } from "@formbricks/types/product"; import { Button } from "@formbricks/ui/components/Button"; @@ -25,12 +29,31 @@ interface ProductSettingsPageProps { const Page = async ({ params, searchParams }: ProductSettingsPageProps) => { const t = await getTranslations(); const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return redirect(`/auth/login`); + } + const channel = searchParams.channel || null; const industry = searchParams.industry || null; const mode = searchParams.mode || "surveys"; const locale = session?.user.id ? await getUserLocale(session.user.id) : undefined; const customHeadline = getCustomHeadline(channel); - const products = await getProducts(params.organizationId); + const products = await getUserProducts(session.user.id, params.organizationId); + + const organizationTeams = await getTeamsByOrganizationId(params.organizationId); + + const organization = await getOrganization(params.organizationId); + + if (!organization) { + throw new Error(t("common.organization_not_found")); + } + + const canDoRoleManagement = await getRoleManagementPermission(organization); + + if (!organizationTeams) { + throw new Error(t("common.organization_teams_not_found")); + } return (
@@ -51,6 +74,8 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => { channel={channel} industry={industry} defaultBrandColor={DEFAULT_BRAND_COLOR} + organizationTeams={organizationTeams} + canDoRoleManagement={canDoRoleManagement} locale={locale ?? DEFAULT_LOCALE} /> {products.length >= 1 && ( diff --git a/apps/web/app/(app)/(onboarding)/organizations/actions.ts b/apps/web/app/(app)/(onboarding)/organizations/actions.ts index ef445948c0..9d3c80439f 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/actions.ts +++ b/apps/web/app/(app)/(onboarding)/organizations/actions.ts @@ -1,19 +1,19 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { sendInviteMemberEmail } from "@/modules/email"; import { z } from "zod"; -import { sendInviteMemberEmail } from "@formbricks/email"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { INVITE_DISABLED } from "@formbricks/lib/constants"; import { inviteUser } from "@formbricks/lib/invite/service"; import { ZId } from "@formbricks/types/common"; import { AuthenticationError } from "@formbricks/types/errors"; -import { ZMembershipRole } from "@formbricks/types/memberships"; +import { ZOrganizationRole } from "@formbricks/types/memberships"; const ZInviteOrganizationMemberAction = z.object({ organizationId: ZId, email: z.string(), - role: ZMembershipRole, + role: ZOrganizationRole, inviteMessage: z.string(), }); @@ -24,10 +24,15 @@ export const inviteOrganizationMemberAction = authenticatedActionClient throw new AuthenticationError("Invite disabled"); } - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["membership", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], }); const invite = await inviteUser({ diff --git a/apps/web/app/(app)/(onboarding)/types/onboarding.ts b/apps/web/app/(app)/(onboarding)/types/onboarding.ts new file mode 100644 index 0000000000..ead024f3ba --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/types/onboarding.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZOrganizationTeam = z.object({ + id: z.string().cuid2(), + name: z.string(), +}); + +export type TOrganizationTeam = z.infer; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts index 4e26ebd800..26032f64bf 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts @@ -1,16 +1,20 @@ "use server"; -import { z } from "zod"; -import { createActionClass } from "@formbricks/lib/actionClass/service"; -import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants"; +import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getOrganizationIdFromEnvironmentId, getOrganizationIdFromProductId, getOrganizationIdFromSegmentId, getOrganizationIdFromSurveyId, -} from "@formbricks/lib/organization/utils"; + getProductIdFromEnvironmentId, + getProductIdFromSegmentId, + getProductIdFromSurveyId, +} from "@/lib/utils/helper"; +import { getSegment, getSurvey } from "@/lib/utils/services"; +import { z } from "zod"; +import { createActionClass } from "@formbricks/lib/actionClass/service"; +import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants"; import { getProduct } from "@formbricks/lib/product/service"; import { cloneSegment, @@ -28,11 +32,22 @@ import { ZSurvey } from "@formbricks/types/surveys/types"; export const updateSurveyAction = authenticatedActionClient .schema(ZSurvey) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.id), - rules: ["survey", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + productId: await getProductIdFromSurveyId(parsedInput.id), + minPermission: "readWrite", + }, + ], }); + return await updateSurvey(parsedInput); }); @@ -43,10 +58,20 @@ const ZRefetchProductAction = z.object({ export const refetchProductAction = authenticatedActionClient .schema(ZRefetchProductAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromProductId(parsedInput.productId), - rules: ["product", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: parsedInput.productId, + }, + ], }); return await getProduct(parsedInput.productId); @@ -64,10 +89,30 @@ const ZCreateBasicSegmentAction = z.object({ export const createBasicSegmentAction = authenticatedActionClient .schema(ZCreateBasicSegmentAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + const surveyEnvironment = await getSurvey(parsedInput.surveyId); + + if (!surveyEnvironment) { + throw new Error("Survey not found"); + } + + if (surveyEnvironment.environmentId !== parsedInput.environmentId) { + throw new Error("Survey and segment are not in the same environment"); + } + + await checkAuthorizationUpdated({ userId: ctx.user.id, - organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["segment", "create"], + organizationId: await getOrganizationIdFromEnvironmentId(surveyEnvironment.environmentId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); const parsedFilters = ZSegmentFilters.safeParse(parsedInput.filters); @@ -99,10 +144,22 @@ const ZUpdateBasicSegmentAction = z.object({ export const updateBasicSegmentAction = authenticatedActionClient .schema(ZUpdateBasicSegmentAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId), - rules: ["segment", "update"], + access: [ + { + schema: ZSegmentUpdateInput, + data: parsedInput.data, + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSegmentId(parsedInput.segmentId), + }, + ], }); const { filters } = parsedInput.data; @@ -127,16 +184,36 @@ const ZLoadNewBasicSegmentAction = z.object({ export const loadNewBasicSegmentAction = authenticatedActionClient .schema(ZLoadNewBasicSegmentAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSegmentId(parsedInput.surveyId), - rules: ["segment", "read"], - }); + const surveyEnvironment = await getSurvey(parsedInput.surveyId); + const segmentEnvironment = await getSegment(parsedInput.segmentId); - await checkAuthorization({ + if (!surveyEnvironment || !segmentEnvironment) { + if (!surveyEnvironment) { + throw new Error("Survey not found"); + } + if (!segmentEnvironment) { + throw new Error("Segment not found"); + } + } + + if (surveyEnvironment.environmentId !== segmentEnvironment.environmentId) { + throw new Error("Segment and survey are not in the same environment"); + } + + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); return await loadNewSegmentInSurvey(parsedInput.surveyId, parsedInput.segmentId); @@ -150,16 +227,36 @@ const ZCloneBasicSegmentAction = z.object({ export const cloneBasicSegmentAction = authenticatedActionClient .schema(ZCloneBasicSegmentAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId), - rules: ["segment", "create"], - }); + const surveyEnvironment = await getSurvey(parsedInput.surveyId); + const segmentEnvironment = await getSegment(parsedInput.segmentId); - await checkAuthorization({ + if (!surveyEnvironment || !segmentEnvironment) { + if (!surveyEnvironment) { + throw new Error("Survey not found"); + } + if (!segmentEnvironment) { + throw new Error("Segment not found"); + } + } + + if (surveyEnvironment.environmentId !== segmentEnvironment.environmentId) { + throw new Error("Segment and survey are not in the same environment"); + } + + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); return await cloneSegment(parsedInput.segmentId, parsedInput.surveyId); @@ -172,10 +269,20 @@ const ZResetBasicSegmentFiltersAction = z.object({ export const resetBasicSegmentFiltersAction = authenticatedActionClient .schema(ZResetBasicSegmentFiltersAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["segment", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); return await resetSegmentInSurvey(parsedInput.surveyId); @@ -267,10 +374,20 @@ const ZCreateActionClassAction = z.object({ export const createActionClassAction = authenticatedActionClient .schema(ZCreateActionClassAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.action.environmentId), - rules: ["actionClass", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromEnvironmentId(parsedInput.action.environmentId), + }, + ], }); return await createActionClass(parsedInput.action.environmentId, parsedInput.action); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx index 8e9bbf0037..fe04c7b3f9 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddActionModal.tsx @@ -13,7 +13,7 @@ interface AddActionModalProps { environmentId: string; actionClasses: TActionClass[]; setActionClasses: React.Dispatch>; - isViewer: boolean; + isReadOnly: boolean; localSurvey: TSurvey; setLocalSurvey: React.Dispatch>; } @@ -25,7 +25,7 @@ export const AddActionModal = ({ setActionClasses, localSurvey, setLocalSurvey, - isViewer, + isReadOnly, environmentId, }: AddActionModalProps) => { const t = useTranslations(); @@ -48,7 +48,7 @@ export const AddActionModal = ({ actionClasses={actionClasses} setActionClasses={setActionClasses} setOpen={setOpen} - isViewer={isViewer} + isReadOnly={isReadOnly} setLocalSurvey={setLocalSurvey} environmentId={environmentId} /> diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm.tsx index f0824a7d47..a13787af49 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -9,7 +10,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable"; interface AddressQuestionFormProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx index 8bb1f9fdc2..59a116acd7 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx @@ -1,16 +1,16 @@ "use client"; +import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useTranslations } from "next-intl"; import { useState } from "react"; -import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { Input } from "@formbricks/ui/components/Input"; import { Label } from "@formbricks/ui/components/Label"; import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; const options = [ { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx index 3b93846eda..e5c6d0db5e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx @@ -1,3 +1,4 @@ +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; @@ -9,7 +10,6 @@ import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionTo import { Button } from "@formbricks/ui/components/Button"; import { Input } from "@formbricks/ui/components/Input"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; interface CalQuestionFormProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx index 66074f61f9..a126b29f26 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx @@ -1,13 +1,13 @@ "use client"; +import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useTranslations } from "next-intl"; import { useState } from "react"; -import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; interface ConsentQuestionFormProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm.tsx index 58f0bf9f04..0b37f18da9 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -9,7 +10,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable"; interface ContactInfoQuestionFormProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx index 37b864d7d6..2ed652adb6 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CreateNewActionTab.tsx @@ -24,7 +24,7 @@ import { createActionClassAction } from "../actions"; interface CreateNewActionTabProps { actionClasses: TActionClass[]; setActionClasses: React.Dispatch>; - isViewer: boolean; + isReadOnly: boolean; setLocalSurvey?: React.Dispatch>; setOpen: React.Dispatch>; environmentId: string; @@ -34,7 +34,7 @@ export const CreateNewActionTab = ({ actionClasses, setActionClasses, setOpen, - isViewer, + isReadOnly, setLocalSurvey, environmentId, }: CreateNewActionTabProps) => { @@ -87,7 +87,7 @@ export const CreateNewActionTab = ({ const submitHandler = async (data: TActionClassInput) => { const { type } = data; try { - if (isViewer) { + if (isReadOnly) { throw new Error(t("common.you_are_not_authorised_to_perform_this_action")); } @@ -247,9 +247,9 @@ export const CreateNewActionTab = ({
{watch("type") === "code" ? ( - + ) : ( - + )}
diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx index bef9433032..15c1a1433d 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx @@ -1,3 +1,4 @@ +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -8,7 +9,6 @@ import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; import { Label } from "@formbricks/ui/components/Label"; import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; interface IDateQuestionFormProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx index 96c0edfef6..6efa36edfe 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx @@ -1,18 +1,18 @@ "use client"; +import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import * as Collapsible from "@radix-ui/react-collapsible"; import { Hand } from "lucide-react"; import { useTranslations } from "next-intl"; import { usePathname } from "next/navigation"; import { useState } from "react"; -import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor"; import { cn } from "@formbricks/lib/cn"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { FileInput } from "@formbricks/ui/components/FileInput"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { Switch } from "@formbricks/ui/components/Switch"; interface EditWelcomeCardProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm.tsx index d705ced5fb..9c42f77d32 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; @@ -8,7 +9,6 @@ import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { Input } from "@formbricks/ui/components/Input"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { Switch } from "@formbricks/ui/components/Switch"; interface EndScreenFormProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx index 804d13fda3..184c49c388 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx @@ -1,5 +1,7 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; +import { useGetBillingInfo } from "@/modules/utils/hooks/useGetBillingInfo"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon, XCircleIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -8,7 +10,6 @@ import { useMemo, useState } from "react"; import { toast } from "react-hot-toast"; import { extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { createI18nString } from "@formbricks/lib/i18n/utils"; -import { useGetBillingInfo } from "@formbricks/lib/organization/hooks/useGetBillingInfo"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common"; import { TProduct } from "@formbricks/types/product"; @@ -17,7 +18,6 @@ import { TUserLocale } from "@formbricks/types/user"; import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle"; import { Button } from "@formbricks/ui/components/Button"; import { Input } from "@formbricks/ui/components/Input"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; interface FileUploadFormProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm.tsx index 11872f1832..33cf6a13f2 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon, TrashIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -9,7 +10,6 @@ import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/s import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect"; import { isLabelValidForAllLanguages } from "../lib/validation"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx index eab1d700e6..ec35f363e6 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx @@ -1,6 +1,7 @@ "use client"; import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { DndContext } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useAutoAnimate } from "@formkit/auto-animate/react"; @@ -21,7 +22,6 @@ import { import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect"; import { QuestionOptionChoice } from "./QuestionOptionChoice"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx index 47b257e5da..08152c120f 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -9,7 +10,6 @@ import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle"; import { Button } from "@formbricks/ui/components/Button"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; interface NPSQuestionFormProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx index 8d08b3d4d9..71789f69be 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -14,7 +15,6 @@ import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; import { Label } from "@formbricks/ui/components/Label"; import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; const questionTypes = [ { value: "text", label: "common.text", icon: }, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx index eeac9c7817..1e1d8e7f09 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx @@ -1,3 +1,4 @@ +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { createId } from "@paralleldrive/cuid2"; import { PlusIcon } from "lucide-react"; @@ -10,7 +11,6 @@ import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; import { FileInput } from "@formbricks/ui/components/FileInput"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { Switch } from "@formbricks/ui/components/Switch"; interface PictureSelectionFormProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx index 8ec5de23c0..c358fc95b1 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx @@ -3,6 +3,7 @@ import { ContactInfoQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm"; import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm"; import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useAutoAnimate } from "@formkit/auto-animate/react"; @@ -24,7 +25,6 @@ import { } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { Switch } from "@formbricks/ui/components/Switch"; import { AddressQuestionForm } from "./AddressQuestionForm"; import { AdvancedSettings } from "./AdvancedSettings"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionOptionChoice.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionOptionChoice.tsx index 54f9c7d344..fcf2f93ee7 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionOptionChoice.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionOptionChoice.tsx @@ -1,3 +1,4 @@ +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react"; @@ -14,7 +15,6 @@ import { TSurveyRankingQuestion, } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { isLabelValidForAllLanguages } from "../lib/validation"; interface ChoiceProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index 20bf96310e..187fb42e93 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -3,6 +3,7 @@ import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton"; import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard"; import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils"; +import { MultiLanguageCard } from "@/modules/ee/multi-language-surveys/components/multi-language-card"; import { DndContext, DragEndEvent, @@ -17,7 +18,6 @@ import { createId } from "@paralleldrive/cuid2"; import { useTranslations } from "next-intl"; import React, { SetStateAction, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; -import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card"; import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx index d27111c731..69c539e748 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { DndContext } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useAutoAnimate } from "@formkit/auto-animate/react"; @@ -13,7 +14,6 @@ import { TI18nString, TSurvey, TSurveyRankingQuestion } from "@formbricks/types/ import { TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect"; import { QuestionOptionChoice } from "./QuestionOptionChoice"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingQuestionForm.tsx index 164952a63a..5d69a93a91 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingQuestionForm.tsx @@ -1,3 +1,4 @@ +import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -8,7 +9,6 @@ import { TUserLocale } from "@formbricks/types/user"; import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle"; import { Button } from "@formbricks/ui/components/Button"; import { Label } from "@formbricks/ui/components/Label"; -import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput"; import { Dropdown } from "./RatingTypeDropdown"; interface RatingQuestionFormProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView.tsx index c1da9a5707..ac06994bd5 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView.tsx @@ -1,8 +1,9 @@ -import { AdvancedTargetingCard } from "@formbricks/ee/advanced-targeting/components/advanced-targeting-card"; +import { AdvancedTargetingCard } from "@/modules/ee/advanced-targeting/components/advanced-targeting-card"; +import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; import { TActionClass } from "@formbricks/types/action-classes"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TEnvironment } from "@formbricks/types/environment"; -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { TSegment } from "@formbricks/types/segment"; import { TSurvey } from "@formbricks/types/surveys/types"; import { HowToSendCard } from "./HowToSendCard"; @@ -20,10 +21,11 @@ interface SettingsViewProps { attributeClasses: TAttributeClass[]; segments: TSegment[]; responseCount: number; - membershipRole?: TMembershipRole; + membershipRole?: TOrganizationRole; isUserTargetingAllowed?: boolean; isFormbricksCloud: boolean; locale: string; + productPermission: TTeamPermission | null; } export const SettingsView = ({ @@ -38,6 +40,7 @@ export const SettingsView = ({ isUserTargetingAllowed = false, isFormbricksCloud, locale, + productPermission, }: SettingsViewProps) => { const isAppSurvey = localSurvey.type === "app"; @@ -83,6 +86,7 @@ export const SettingsView = ({ environmentId={environment.id} propActionClasses={actionClasses} membershipRole={membershipRole} + productPermission={productPermission} /> { const [activeView, setActiveView] = useState("questions"); const [activeQuestionId, setActiveQuestionId] = useState(null); @@ -209,6 +212,7 @@ export const SurveyEditor = ({ isUserTargetingAllowed={isUserTargetingAllowed} isFormbricksCloud={isFormbricksCloud} locale={locale} + productPermission={productPermission} /> )} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx index fe4bb9b485..04685a5ea2 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx @@ -1,14 +1,14 @@ "use client"; import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { createSegmentAction } from "@/modules/ee/advanced-targeting/lib/actions"; import { isEqual } from "lodash"; import { AlertTriangleIcon, ArrowLeftIcon, SettingsIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { createSegmentAction } from "@formbricks/ee/advanced-targeting/lib/actions"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { getLanguageLabel } from "@formbricks/lib/i18n/utils"; import { TEnvironment } from "@formbricks/types/environment"; import { TProduct } from "@formbricks/types/product"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingCard.tsx index 3d326dc2f9..a2cce7e507 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingCard.tsx @@ -1,5 +1,6 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import * as Collapsible from "@radix-ui/react-collapsible"; import { AlertCircle, CheckIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon } from "lucide-react"; @@ -8,7 +9,6 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { toast } from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { cn } from "@formbricks/lib/cn"; import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; import { isAdvancedSegment } from "@formbricks/lib/segment/utils"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx index c81f5032c3..0a69c4944d 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx @@ -1,5 +1,7 @@ "use client"; +import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import * as Collapsible from "@radix-ui/react-collapsible"; import { @@ -14,7 +16,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TActionClass } from "@formbricks/types/action-classes"; -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { TSurvey } from "@formbricks/types/surveys/types"; import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle"; import { Button } from "@formbricks/ui/components/Button"; @@ -26,7 +28,8 @@ interface WhenToSendCardProps { setLocalSurvey: React.Dispatch>; environmentId: string; propActionClasses: TActionClass[]; - membershipRole?: TMembershipRole; + membershipRole?: TOrganizationRole; + productPermission: TTeamPermission | null; } export const WhenToSendCard = ({ @@ -35,6 +38,7 @@ export const WhenToSendCard = ({ setLocalSurvey, propActionClasses, membershipRole, + productPermission, }: WhenToSendCardProps) => { const t = useTranslations(); const [open, setOpen] = useState(localSurvey.type === "app" ? true : false); @@ -42,7 +46,10 @@ export const WhenToSendCard = ({ const [actionClasses, setActionClasses] = useState(propActionClasses); const [randomizerToggle, setRandomizerToggle] = useState(localSurvey.displayPercentage ? true : false); - const { isViewer } = getAccessFlags(membershipRole); + const { isMember } = getAccessFlags(membershipRole); + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; const autoClose = localSurvey.autoClose !== null; const delay = localSurvey.delay !== 0; @@ -367,7 +374,7 @@ export const WhenToSendCard = ({ setOpen={setAddActionModalOpen} actionClasses={actionClasses} setActionClasses={setActionClasses} - isViewer={isViewer} + isReadOnly={isReadOnly} localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} /> diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx index 065835dd5e..f3f573d0eb 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx @@ -1,3 +1,5 @@ +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { getAdvancedTargetingPermission, getMultiLanguagePermission } from "@formbricks/ee/lib/service"; @@ -60,10 +62,20 @@ const Page = async ({ params, searchParams }) => { throw new Error(t("common.organization_not_found")); } + if (!product) { + throw new Error(t("common.product_not_found")); + } + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); - const isSurveyCreationDeletionDisabled = isViewer; + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isSurveyCreationDeletionDisabled = isMember && hasReadAccess; const locale = session.user.id ? await getUserLocale(session.user.id) : undefined; + const isUserTargetingAllowed = await getAdvancedTargetingPermission(organization); const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); @@ -89,6 +101,7 @@ const Page = async ({ params, searchParams }) => { attributeClasses={attributeClasses} responseCount={responseCount} membershipRole={currentUserMembership?.role} + productPermission={productPermission} colors={SURVEY_BG_COLORS} segments={segments} isUserTargetingAllowed={isUserTargetingAllowed} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts index 2ddb015d13..bd4cb89f12 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions.ts @@ -1,13 +1,13 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper"; import { createId } from "@paralleldrive/cuid2"; import { generateObject } from "ai"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { llmModel } from "@formbricks/lib/aiModels"; import { getOrganization } from "@formbricks/lib/organization/service"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; import { createSurvey } from "@formbricks/lib/survey/service"; import { getIsAIEnabled } from "@formbricks/lib/utils/ai"; import { ZId, ZString } from "@formbricks/types/common"; @@ -23,10 +23,20 @@ export const createAISurveyAction = authenticatedActionClient .action(async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId, - rules: ["survey", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + minPermission: "readWrite", + }, + ], }); const organization = await getOrganization(organizationId); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard.tsx index 20fb860277..3e0747898a 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard.tsx @@ -1,12 +1,12 @@ "use client"; import { createAISurveyAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Sparkles } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { Button } from "@formbricks/ui/components/Button"; import { Card, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/TemplateContainer.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/TemplateContainer.tsx index 623b83f2a3..17d4118cca 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/TemplateContainer.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/TemplateContainer.tsx @@ -2,6 +2,7 @@ import { FormbricksAICard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard"; import { MenuBar } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/MenuBar"; +import { TemplateList } from "@/modules/surveys/components/TemplateList"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { getCustomSurveyTemplate } from "@formbricks/lib/templates"; @@ -12,7 +13,6 @@ import { TUser } from "@formbricks/types/user"; import { PreviewSurvey } from "@formbricks/ui/components/PreviewSurvey"; import { SearchBar } from "@formbricks/ui/components/SearchBar"; import { Separator } from "@formbricks/ui/components/Separator"; -import { TemplateList } from "@formbricks/ui/components/TemplateList"; import { getMinimalSurvey } from "../../lib/minimalSurvey"; type TemplateContainerWithPreviewProps = { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx index 6b284ac13e..36759c0ad0 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/page.tsx @@ -1,3 +1,5 @@ +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; @@ -52,9 +54,13 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => { session?.user.id, product.organizationId ); - const { isViewer } = getAccessFlags(currentUserMembership?.role); + const { isMember } = getAccessFlags(currentUserMembership?.role); - if (isViewer) { + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + if (isReadOnly) { return redirect(`/environments/${environment.id}/surveys`); } diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/actions.ts b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/actions.ts index 54ae660c1b..6c44944024 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/actions.ts @@ -1,9 +1,9 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; import { getSegmentsByAttributeClassName } from "@formbricks/lib/segment/service"; import { ZAttributeClass } from "@formbricks/types/attribute-classes"; import { ZId } from "@formbricks/types/common"; @@ -16,10 +16,15 @@ const ZGetSegmentsByAttributeClassAction = z.object({ export const getSegmentsByAttributeClassAction = authenticatedActionClient .schema(ZGetSegmentsByAttributeClassAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["environment", "read"], + access: [ + { + type: "productTeam", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + }, + ], }); const segments = await getSegmentsByAttributeClassName( diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeActivityTab.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeActivityTab.tsx index 4131c00053..d76e6be6a9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeActivityTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeActivityTab.tsx @@ -1,9 +1,9 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { TagIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { convertDateTimeStringShort } from "@formbricks/lib/time"; import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeClassesTable.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeClassesTable.tsx index da979ff4f3..e1ff46aac3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeClassesTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeClassesTable.tsx @@ -11,9 +11,14 @@ import { AttributeClassDataRow } from "./AttributeRowData"; interface AttributeClassesTableProps { attributeClasses: TAttributeClass[]; locale: TUserLocale; + isReadOnly: boolean; } -export const AttributeClassesTable = ({ attributeClasses, locale }: AttributeClassesTableProps) => { +export const AttributeClassesTable = ({ + attributeClasses, + locale, + isReadOnly, +}: AttributeClassesTableProps) => { const [isAttributeDetailModalOpen, setAttributeDetailModalOpen] = useState(false); const [activeAttributeClass, setActiveAttributeClass] = useState(null); const [showArchived, setShowArchived] = useState(false); @@ -70,6 +75,7 @@ export const AttributeClassesTable = ({ attributeClasses, locale }: AttributeCla open={isAttributeDetailModalOpen} setOpen={setAttributeDetailModalOpen} attributeClass={activeAttributeClass} + isReadOnly={isReadOnly} /> )}
diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeDetailModal.tsx index 1599b2fb53..450c4ad4f3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeDetailModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeDetailModal.tsx @@ -11,9 +11,15 @@ interface AttributeDetailModalProps { open: boolean; setOpen: (v: boolean) => void; attributeClass: TAttributeClass; + isReadOnly: boolean; } -export const AttributeDetailModal = ({ open, setOpen, attributeClass }: AttributeDetailModalProps) => { +export const AttributeDetailModal = ({ + open, + setOpen, + attributeClass, + isReadOnly, +}: AttributeDetailModalProps) => { const t = useTranslations(); const tabs = [ { @@ -22,7 +28,9 @@ export const AttributeDetailModal = ({ open, setOpen, attributeClass }: Attribut }, { title: t("common.settings"), - children: , + children: ( + + ), }, ]; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeSettingsTab.tsx index bbb9e0a846..3e6d6eb02f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeSettingsTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/components/AttributeSettingsTab.tsx @@ -14,9 +14,14 @@ import { Label } from "@formbricks/ui/components/Label"; interface AttributeSettingsTabProps { attributeClass: AttributeClass; setOpen: (v: boolean) => void; + isReadOnly: boolean; } -export const AttributeSettingsTab = async ({ attributeClass, setOpen }: AttributeSettingsTabProps) => { +export const AttributeSettingsTab = async ({ + attributeClass, + setOpen, + isReadOnly, +}: AttributeSettingsTabProps) => { const router = useRouter(); const t = useTranslations(); const { register, handleSubmit } = useForm({ @@ -58,7 +63,7 @@ export const AttributeSettingsTab = async ({ attributeClass, setOpen }: Attribut type="text" placeholder={t("environments.attributes.ex_user_property")} {...register("description", { - disabled: attributeClass.type === "automatic" ? true : false, + disabled: attributeClass.type === "automatic" || isReadOnly ? true : false, })} />
@@ -85,24 +90,22 @@ export const AttributeSettingsTab = async ({ attributeClass, setOpen }: Attribut {t("common.read_docs")} {attributeClass.type !== "automatic" && ( - )}
- {attributeClass.type !== "automatic" && ( + {!isReadOnly && attributeClass.type !== "automatic" && (
); diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponsesFeed.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponsesFeed.tsx index c095e3a65a..34ac03a7b7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponsesFeed.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponsesFeed.tsx @@ -1,5 +1,8 @@ "use client"; +import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; +import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { useEffect, useState } from "react"; import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; @@ -11,7 +14,6 @@ import { TSurvey } from "@formbricks/types/surveys/types"; import { TTag } from "@formbricks/types/tags"; import { TUser, TUserLocale } from "@formbricks/types/user"; import { EmptySpaceFiller } from "@formbricks/ui/components/EmptySpaceFiller"; -import { SingleResponseCard } from "@formbricks/ui/components/SingleResponseCard"; interface ResponseTimelineProps { surveys: TSurvey[]; @@ -21,6 +23,7 @@ interface ResponseTimelineProps { environmentTags: TTag[]; attributeClasses: TAttributeClass[]; locale: TUserLocale; + productPermission: TTeamPermission | null; } export const ResponseFeed = ({ @@ -31,6 +34,7 @@ export const ResponseFeed = ({ environmentTags, attributeClasses, locale, + productPermission, }: ResponseTimelineProps) => { const [fetchedResponses, setFetchedResponses] = useState(responses); @@ -65,6 +69,7 @@ export const ResponseFeed = ({ updateResponse={updateResponse} attributeClasses={attributeClasses} locale={locale} + productPermission={productPermission} /> )) )} @@ -82,6 +87,7 @@ const ResponseSurveyCard = ({ updateResponse, attributeClasses, locale, + productPermission, }: { response: TResponse; surveys: TSurvey[]; @@ -92,13 +98,18 @@ const ResponseSurveyCard = ({ updateResponse: (responseId: string, response: TResponse) => void; attributeClasses: TAttributeClass[]; locale: TUserLocale; + productPermission: TTeamPermission | null; }) => { const survey = surveys.find((survey) => { return survey.id === response.surveyId; }); const { membershipRole } = useMembershipRole(survey?.environmentId || ""); - const { isViewer } = getAccessFlags(membershipRole); + const { isMember } = getAccessFlags(membershipRole); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; return (
@@ -112,7 +123,7 @@ const ResponseSurveyCard = ({ environment={environment} deleteResponses={deleteResponses} updateResponse={updateResponse} - isViewer={isViewer} + isReadOnly={isReadOnly} locale={locale} /> )} diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/page.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/page.tsx index c47de38033..5306a1faeb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/page.tsx @@ -1,6 +1,8 @@ import { AttributesSection } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/AttributesSection"; import { DeletePersonButton } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton"; import { ResponseSection } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponseSection"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { getAttributes } from "@formbricks/lib/attribute/service"; @@ -52,11 +54,16 @@ const Page = async ({ params }) => { } const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; const getDeletePersonButton = () => { return ( - + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts b/apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts index eb0930938e..eeaa757419 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts @@ -1,11 +1,15 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { + getOrganizationIdFromEnvironmentId, + getOrganizationIdFromPersonId, + getProductIdFromEnvironmentId, + getProductIdFromPersonId, +} from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getAttributes } from "@formbricks/lib/attribute/service"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; -import { getPeople } from "@formbricks/lib/person/service"; +import { deletePerson, getPeople } from "@formbricks/lib/person/service"; import { ZId } from "@formbricks/types/common"; const ZGetPersonsAction = z.object({ @@ -17,28 +21,47 @@ const ZGetPersonsAction = z.object({ export const getPersonsAction = authenticatedActionClient .schema(ZGetPersonsAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["environment", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "read", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + }, + ], }); return getPeople(parsedInput.environmentId, parsedInput.offset, parsedInput.searchValue); }); -const ZGetPersonAttributesAction = z.object({ - environmentId: ZId, +const ZPersonDeleteAction = z.object({ personId: ZId, }); -export const getPersonAttributesAction = authenticatedActionClient - .schema(ZGetPersonAttributesAction) +export const deletePersonAction = authenticatedActionClient + .schema(ZPersonDeleteAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, - organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["environment", "read"], + organizationId: await getOrganizationIdFromPersonId(parsedInput.personId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromPersonId(parsedInput.personId), + }, + ], }); - return getAttributes(parsedInput.personId); + return await deletePerson(parsedInput.personId); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx index f13efb6f0c..5d657499a6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx @@ -12,9 +12,10 @@ import { TPersonWithAttributes } from "@formbricks/types/people"; interface PersonDataViewProps { environment: TEnvironment; itemsPerPage: number; + isReadOnly: boolean; } -export const PersonDataView = ({ environment, itemsPerPage }: PersonDataViewProps) => { +export const PersonDataView = ({ environment, itemsPerPage, isReadOnly }: PersonDataViewProps) => { const t = useTranslations(); const [persons, setPersons] = useState([]); const [isDataLoaded, setIsDataLoaded] = useState(false); @@ -101,6 +102,7 @@ export const PersonDataView = ({ environment, itemsPerPage }: PersonDataViewProp environmentId={environment.id} searchValue={searchValue} setSearchValue={setSearchValue} + isReadOnly={isReadOnly} /> ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx index d88f74c9b9..05f58ffc63 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx @@ -1,5 +1,6 @@ "use client"; +import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions"; import { generatePersonTableColumns } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn"; import { DndContext, @@ -40,6 +41,7 @@ interface PersonTableProps { environmentId: string; searchValue: string; setSearchValue: (value: string) => void; + isReadOnly: boolean; } export const PersonTable = ({ @@ -51,6 +53,7 @@ export const PersonTable = ({ environmentId, searchValue, setSearchValue, + isReadOnly, }: PersonTableProps) => { const [columnVisibility, setColumnVisibility] = useState({}); const [columnOrder, setColumnOrder] = useState([]); @@ -63,7 +66,7 @@ export const PersonTable = ({ const [parent] = useAutoAnimate(); // Generate columns const columns = useMemo( - () => generatePersonTableColumns(isExpanded ?? false, searchValue, t), + () => generatePersonTableColumns(isExpanded ?? false, searchValue, t, isReadOnly), [isExpanded, searchValue] ); @@ -162,6 +165,10 @@ export const PersonTable = ({ } }; + const deletePerson = async (personId: string) => { + await deletePersonAction({ personId }); + }; + return (
diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx index a70b887f11..59a244439e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx @@ -9,7 +9,8 @@ import { HighlightedText } from "@formbricks/ui/components/HighlightedText"; export const generatePersonTableColumns = ( isExpanded: boolean, searchValue: string, - t: (key: string) => string + t: (key: string) => string, + isReadOnly: boolean ): ColumnDef[] => { const dateColumn: ColumnDef = { accessorKey: "createdAt", @@ -90,5 +91,7 @@ export const generatePersonTableColumns = ( }, }; - return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn]; + const columns = [dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn]; + + return isReadOnly ? columns : [getSelectionColumn(), ...columns]; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx index 9a8a34e1f8..90add6f080 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx @@ -1,20 +1,53 @@ import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView"; import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { CircleHelpIcon } from "lucide-react"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { authOptions } from "@formbricks/lib/authOptions"; import { ITEMS_PER_PAGE } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { Button } from "@formbricks/ui/components/Button"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; const Page = async ({ params }: { params: { environmentId: string } }) => { - const environment = await getEnvironment(params.environmentId); const t = await getTranslations(); + const session = await getServerSession(authOptions); + + if (!session) { + throw new Error(t("common.session_not_found")); + } + + const environment = await getEnvironment(params.environmentId); if (!environment) { throw new Error(t("common.environment_not_found")); } + const organization = await getOrganizationByEnvironmentId(params.environmentId); + if (!organization) { + throw new Error(t("common.organization_not_found")); + } + + const product = await getProductByEnvironmentId(params.environmentId); + if (!product) { + throw new Error(t("common.product_not_found")); + } + + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, product.id); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + const HowToAddPeopleButton = ( @@ -229,28 +238,30 @@ export const BasicSegmentSettings = ({ /> )} -
- - -
+ {!isReadOnly && ( +
+ + +
+ )} {isDeleteSegmentModalOpen && ( { const t = useTranslations(); const SettingsTab = () => { - if (isAdvancedTargetingAllowed) { + if (isAdvancedTargetingAllowed || false) { return ( ); } @@ -51,6 +54,7 @@ export const EditSegmentModal = ({ initialSegment={currentSegment} setOpen={setOpen} isFormbricksCloud={isFormbricksCloud} + isReadOnly={isReadOnly} /> ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable.tsx index 65ce8c2217..7ce017260e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable.tsx @@ -7,12 +7,14 @@ type TSegmentTableProps = { segments: TSegment[]; attributeClasses: TAttributeClass[]; isAdvancedTargetingAllowed: boolean; + isReadOnly: boolean; }; export const SegmentTable = ({ segments, attributeClasses, isAdvancedTargetingAllowed, + isReadOnly, }: TSegmentTableProps) => { const t = useTranslations(); return ( @@ -35,6 +37,7 @@ export const SegmentTable = ({ segments={segments} attributeClasses={attributeClasses} isAdvancedTargetingAllowed={isAdvancedTargetingAllowed} + isReadOnly={isReadOnly} /> ))} diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRow.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRow.tsx index ecf22e6f92..f4efcc05ae 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRow.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRow.tsx @@ -13,6 +13,7 @@ type TSegmentTableDataRowProps = { attributeClasses: TAttributeClass[]; isAdvancedTargetingAllowed: boolean; isFormbricksCloud: boolean; + isReadOnly: boolean; }; export const SegmentTableDataRow = ({ @@ -21,6 +22,7 @@ export const SegmentTableDataRow = ({ segments, isAdvancedTargetingAllowed, isFormbricksCloud, + isReadOnly, }: TSegmentTableDataRowProps) => { const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment; const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false); @@ -66,6 +68,7 @@ export const SegmentTableDataRow = ({ segments={segments} isAdvancedTargetingAllowed={isAdvancedTargetingAllowed} isFormbricksCloud={isFormbricksCloud} + isReadOnly={isReadOnly} /> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRowContainer.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRowContainer.tsx index 229c64b039..fa3db9361c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRowContainer.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTableDataRowContainer.tsx @@ -9,6 +9,7 @@ type TSegmentTableDataRowProps = { segments: TSegment[]; attributeClasses: TAttributeClass[]; isAdvancedTargetingAllowed: boolean; + isReadOnly: boolean; }; export const SegmentTableDataRowContainer = async ({ @@ -16,6 +17,7 @@ export const SegmentTableDataRowContainer = async ({ segments, attributeClasses, isAdvancedTargetingAllowed, + isReadOnly, }: TSegmentTableDataRowProps) => { const surveys = await getSurveysBySegmentId(currentSegment.id); @@ -38,6 +40,7 @@ export const SegmentTableDataRowContainer = async ({ attributeClasses={attributeClasses} isAdvancedTargetingAllowed={isAdvancedTargetingAllowed} isFormbricksCloud={IS_FORMBRICKS_CLOUD} + isReadOnly={isReadOnly} /> ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/page.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/page.tsx index e7ce833cc7..29b1ed80f7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/segments/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/segments/page.tsx @@ -1,30 +1,47 @@ import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation"; import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal"; import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable"; +import { CreateSegmentModal } from "@/modules/ee/advanced-targeting/components/create-segment-modal"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; -import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal"; import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service"; import { getAttributeClasses } from "@formbricks/lib/attributeClass/service"; +import { authOptions } from "@formbricks/lib/authOptions"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSegments } from "@formbricks/lib/segment/service"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; const Page = async ({ params }) => { const t = await getTranslations(); - const [environment, segments, attributeClasses, organization] = await Promise.all([ + const [environment, segments, attributeClasses, organization, product] = await Promise.all([ getEnvironment(params.environmentId), getSegments(params.environmentId), getAttributeClasses(params.environmentId), getOrganizationByEnvironmentId(params.environmentId), + getProductByEnvironmentId(params.environmentId), ]); + const session = await getServerSession(authOptions); + + if (!session) { + throw new Error(t("common.session_not_found")); + } if (!environment) { throw new Error(t("common.environment_not_found")); } + if (!product) { + throw new Error(t("common.product_not_found")); + } + if (!organization) { throw new Error(t("common.organization_not_found")); } @@ -35,6 +52,15 @@ const Page = async ({ params }) => { throw new Error(t("environments.segments.failed_to_fetch_segments")); } + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, product.id); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + const filteredSegments = segments.filter((segment) => !segment.isPrivate); const renderCreateSegmentButton = () => @@ -54,13 +80,14 @@ const Page = async ({ params }) => { return ( - + ); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 76e7b33e6c..1b2f0c5c89 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -1,9 +1,9 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { z } from "zod"; import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { createMembership } from "@formbricks/lib/membership/service"; import { createOrganization } from "@formbricks/lib/organization/service"; import { createProduct } from "@formbricks/lib/product/service"; @@ -70,12 +70,17 @@ export const createProductAction = authenticatedActionClient .action(async ({ parsedInput, ctx }) => { const { user } = ctx; - await checkAuthorization({ - schema: ZProductUpdateInput, - data: parsedInput.data, + await checkAuthorizationUpdated({ userId: user.id, organizationId: parsedInput.organizationId, - rules: ["product", "create"], + access: [ + { + data: parsedInput.data, + schema: ZProductUpdateInput, + type: "organization", + roles: ["owner", "manager"], + }, + ], }); const product = await createProduct(parsedInput.organizationId, parsedInput.data); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts index 3c35933638..b1c186b15e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts @@ -1,11 +1,11 @@ "use server"; +import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromActionClassId, getProductIdFromActionClassId } from "@/lib/utils/helper"; import { z } from "zod"; import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service"; -import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { cache } from "@formbricks/lib/cache"; -import { getOrganizationIdFromActionClassId } from "@formbricks/lib/organization/utils"; import { getSurveysByActionClassId } from "@formbricks/lib/survey/service"; import { ZActionClassInput } from "@formbricks/types/action-classes"; import { ZId } from "@formbricks/types/common"; @@ -18,10 +18,20 @@ const ZDeleteActionClassAction = z.object({ export const deleteActionClassAction = authenticatedActionClient .schema(ZDeleteActionClassAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId), - rules: ["actionClass", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromActionClassId(parsedInput.actionClassId), + }, + ], }); await deleteActionClass(parsedInput.actionClassId); @@ -40,10 +50,20 @@ export const updateActionClassAction = authenticatedActionClient throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId); } - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId), - rules: ["actionClass", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromActionClassId(parsedInput.actionClassId), + }, + ], }); return await updateActionClass( @@ -60,10 +80,20 @@ const ZGetActiveInactiveSurveysAction = z.object({ export const getActiveInactiveSurveysAction = authenticatedActionClient .schema(ZGetActiveInactiveSurveysAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId), - rules: ["survey", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "read", + productId: await getProductIdFromActionClassId(parsedInput.actionClassId), + }, + ], }); const surveys = await getSurveysByActionClassId(parsedInput.actionClassId); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx index f59e8cb7f8..a1f6feef52 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx @@ -1,9 +1,9 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { convertDateTimeStringShort } from "@formbricks/lib/time"; import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TActionClass } from "@formbricks/types/action-classes"; @@ -32,6 +32,7 @@ export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabPro const getActiveInactiveSurveysResponse = await getActiveInactiveSurveysAction({ actionClassId: actionClass.id, }); + console.log(getActiveInactiveSurveysResponse, "randike"); if (getActiveInactiveSurveysResponse?.data) { setActiveSurveys(getActiveInactiveSurveysResponse.data.activeSurveys); setInactiveSurveys(getActiveInactiveSurveysResponse.data.inactiveSurveys); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx index ef3ca4ca33..bd3e328557 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx @@ -1,24 +1,23 @@ "use client"; import { useState } from "react"; -import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole"; import { TActionClass } from "@formbricks/types/action-classes"; -import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent"; import { ActionDetailModal } from "./ActionDetailModal"; interface ActionClassesTableProps { environmentId: string; actionClasses: TActionClass[]; children: [JSX.Element, JSX.Element[]]; + isReadOnly: boolean; } export const ActionClassesTable = ({ environmentId, actionClasses, children: [TableHeading, actionRows], + isReadOnly, }: ActionClassesTableProps) => { const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false); - const { membershipRole, error } = useMembershipRole(environmentId); const [activeActionClass, setActiveActionClass] = useState(); @@ -28,9 +27,6 @@ export const ActionClassesTable = ({ setActionDetailModalOpen(true); }; - if (error) { - return ; - } return ( <>
@@ -56,7 +52,7 @@ export const ActionClassesTable = ({ setOpen={setActionDetailModalOpen} actionClasses={actionClasses} actionClass={activeActionClass} - membershipRole={membershipRole} + isReadOnly={isReadOnly} /> )} diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx index c158290311..9ed437b20c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx @@ -1,7 +1,6 @@ import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { TActionClass } from "@formbricks/types/action-classes"; -import { TMembershipRole } from "@formbricks/types/memberships"; import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs"; import { ActionActivityTab } from "./ActionActivityTab"; import { ActionSettingsTab } from "./ActionSettingsTab"; @@ -12,7 +11,7 @@ interface ActionDetailModalProps { setOpen: (v: boolean) => void; actionClass: TActionClass; actionClasses: TActionClass[]; - membershipRole?: TMembershipRole; + isReadOnly: boolean; } export const ActionDetailModal = ({ @@ -21,7 +20,7 @@ export const ActionDetailModal = ({ setOpen, actionClass, actionClasses, - membershipRole, + isReadOnly, }: ActionDetailModalProps) => { const t = useTranslations(); const tabs = [ @@ -36,7 +35,7 @@ export const ActionDetailModal = ({ actionClass={actionClass} actionClasses={actionClasses} setOpen={setOpen} - membershipRole={membershipRole} + isReadOnly={isReadOnly} /> ), }, diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx index ad84dcf5e5..536c4e022f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx @@ -13,9 +13,7 @@ import { useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; import { z } from "zod"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes"; -import { TMembershipRole } from "@formbricks/types/memberships"; import { Button } from "@formbricks/ui/components/Button"; import { DeleteDialog } from "@formbricks/ui/components/DeleteDialog"; import { FormControl, FormError, FormField, FormItem, FormLabel } from "@formbricks/ui/components/Form"; @@ -27,14 +25,14 @@ interface ActionSettingsTabProps { actionClass: TActionClass; actionClasses: TActionClass[]; setOpen: (v: boolean) => void; - membershipRole?: TMembershipRole; + isReadOnly: boolean; } export const ActionSettingsTab = ({ actionClass, actionClasses, setOpen, - membershipRole, + isReadOnly, }: ActionSettingsTabProps) => { const { createdAt, updatedAt, id, ...restActionClass } = actionClass; const router = useRouter(); @@ -42,7 +40,7 @@ export const ActionSettingsTab = ({ const t = useTranslations(); const [isUpdatingAction, setIsUpdatingAction] = useState(false); const [isDeletingAction, setIsDeletingAction] = useState(false); - const { isViewer } = getAccessFlags(membershipRole); + const actionClassNames = useMemo( () => actionClasses.filter((action) => action.id !== actionClass.id).map((actionClass) => actionClass.name), @@ -72,7 +70,7 @@ export const ActionSettingsTab = ({ const onSubmit = async (data: TActionClassInput) => { try { - if (isViewer) { + if (isReadOnly) { throw new Error(t("common.you_are_not_authorised_to_perform_this_action")); } setIsUpdatingAction(true); @@ -141,6 +139,7 @@ export const ActionSettingsTab = ({ ( @@ -156,7 +155,7 @@ export const ActionSettingsTab = ({ {...field} placeholder={t("environments.actions.eg_clicked_download")} isInvalid={!!error?.message} - disabled={actionClass.type === "automatic" ? true : false} + disabled={actionClass.type === "automatic" || isReadOnly ? true : false} /> @@ -165,43 +164,42 @@ export const ActionSettingsTab = ({ )} />
- {!isViewer && ( -
- ( - - - {t("common.description")} - - - - - - )} - /> -
- )} +
+ ( + + + {t("common.description")} + + + + + + + )} + /> +
{actionClass.type === "code" ? ( <> - +

{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}

) : actionClass.type === "noCode" ? ( - + ) : (

{t( @@ -213,7 +211,7 @@ export const ActionSettingsTab = ({

- {!isViewer && actionClass.type !== "automatic" && ( + {!isReadOnly && actionClass.type !== "automatic" && (
- {actionClass.type !== "automatic" && ( + {!isReadOnly && actionClass.type !== "automatic" && (
@@ -56,7 +49,7 @@ export const AddActionModal = ({ environmentId, actionClasses }: AddActionModalP diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx index 4a3662176a..30c21a0945 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx @@ -2,10 +2,18 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData"; import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading"; import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { Metadata } from "next"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; import { getActionClasses } from "@formbricks/lib/actionClass/service"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; @@ -15,25 +23,55 @@ export const metadata: Metadata = { }; const Page = async ({ params }) => { + const session = await getServerSession(authOptions); const t = await getTranslations(); - const [actionClasses, organization] = await Promise.all([ + const [actionClasses, organization, product] = await Promise.all([ getActionClasses(params.environmentId), getOrganizationByEnvironmentId(params.environmentId), + getProductByEnvironmentId(params.environmentId), ]); - const locale = await findMatchingLocale(); + const locale = findMatchingLocale(); + + if (!session) { + throw new Error(t("common.session_not_found")); + } if (!organization) { throw new Error(t("common.organization_not_found")); } + if (!product) { + throw new Error(t("common.product_not_found")); + } + + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, product.id); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + if (isBilling) { + return redirect(`/environments/${params.environmentId}/settings/billing`); + } + + const isReadOnly = isMember && hasReadAccess; + const renderAddActionButton = () => ( - + ); return ( - - + + {actionClasses.map((actionClass) => ( diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx index 3238ab0c59..51e5bd13e4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx @@ -1,19 +1,21 @@ import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; import { getIsAIEnabled } from "@/app/lib/utils"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import type { Session } from "next-auth"; import { getTranslations } from "next-intl/server"; import { getEnterpriseLicense } from "@formbricks/ee/lib/service"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getMonthlyActiveOrganizationPeopleCount, getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId, getOrganizationsByUserId, } from "@formbricks/lib/organization/service"; -import { getProducts } from "@formbricks/lib/product/service"; +import { getUserProducts } from "@formbricks/lib/product/service"; import { getUser } from "@formbricks/lib/user/service"; import { DevEnvironmentBanner } from "@formbricks/ui/components/DevEnvironmentBanner"; import { LimitsReachedBanner } from "@formbricks/ui/components/LimitsReachedBanner"; @@ -47,7 +49,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En } const [products, environments] = await Promise.all([ - getProducts(organization.id), + getUserProducts(user.id, organization.id), getEnvironments(environment.productId), ]); @@ -56,8 +58,19 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En } const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const membershipRole = currentUserMembership?.role; + const { isOwner, isManager } = getAccessFlags(membershipRole); + + const isOwnerOrManager = isOwner || isManager; + const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense(); + const productPermission = await getProductPermissionByUserId(session.user.id, environment.productId); + + if (!isOwnerOrManager && !productPermission) { + throw new Error(t("common.product_permission_not_found")); + } + const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; let peopleCount = 0; @@ -100,7 +113,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En products={products} user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} - membershipRole={currentUserMembership?.role} + membershipRole={membershipRole} isMultiOrgEnabled={isMultiOrgEnabled} isAIEnabled={isAIEnabled} /> @@ -108,7 +121,8 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
{children}
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index 669bd90a93..b168321af5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { formbricksLogout } from "@/app/lib/formbricks"; import FBLogo from "@/images/formbricks-wordmark.svg"; +import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { ArrowUpRightIcon, BlendIcon, @@ -38,13 +39,12 @@ import { cn } from "@formbricks/lib/cn"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TEnvironment } from "@formbricks/types/environment"; -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; import { TProduct } from "@formbricks/types/product"; import { TUser } from "@formbricks/types/user"; import { ProfileAvatar } from "@formbricks/ui/components/Avatars"; import { Button } from "@formbricks/ui/components/Button"; -import { CreateOrganizationModal } from "@formbricks/ui/components/CreateOrganizationModal"; import { DropdownMenu, DropdownMenuContent, @@ -68,7 +68,7 @@ interface NavigationProps { products: TProduct[]; isMultiOrgEnabled: boolean; isFormbricksCloud?: boolean; - membershipRole?: TMembershipRole; + membershipRole?: TOrganizationRole; isAIEnabled?: boolean; } @@ -94,9 +94,10 @@ export const MainNavigation = ({ const [latestVersion, setLatestVersion] = useState(""); const product = products.find((product) => product.id === environment.productId); - const { isAdmin, isOwner, isViewer } = getAccessFlags(membershipRole); - const isOwnerOrAdmin = isAdmin || isOwner; - const isPricingDisabled = !isOwner && !isAdmin; + const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole); + + const isOwnerOrManager = isManager || isOwner; + const isPricingDisabled = isMember; const toggleSidebar = () => { setIsCollapsed(!isCollapsed); @@ -196,17 +197,15 @@ export const MainNavigation = ({ href: `/environments/${environment.id}/integrations`, icon: BlocksIcon, isActive: pathname?.includes("/integrations"), - isHidden: isViewer, }, { name: t("common.configuration"), href: `/environments/${environment.id}/product/general`, icon: Cog, isActive: pathname?.includes("/product"), - isHidden: isViewer, }, ], - [environment.id, pathname, isViewer] + [environment.id, pathname, isMember] ); const dropdownNavigation = [ @@ -258,8 +257,10 @@ export const MainNavigation = ({ } } } - if (isOwnerOrAdmin) loadReleases(); - }, [isOwnerOrAdmin]); + if (isOwnerOrManager) loadReleases(); + }, [isOwnerOrManager]); + + const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`; return ( <> @@ -276,7 +277,7 @@ export const MainNavigation = ({
{!isCollapsed && ( {/* Main Nav Switch */} -
    - {mainNavigation.map( - (item) => - !item.isHidden && ( - - - - ) - )} -
+ {!isBilling && ( +
    + {mainNavigation.map( + (item) => + !item.isHidden && ( + + + + ) + )} +
+ )}
- {/* Product Switch */}
{/* New Version Available */} - {!isCollapsed && isOwnerOrAdmin && latestVersion && !isFormbricksCloud && ( + {!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && ( )} - - -
-
- {product.config.channel === "website" ? ( - - ) : product.config.channel === "app" ? ( - - ) : product.config.channel === "link" ? ( - - ) : ( - + + {/* Product Switch */} + {!isBilling && ( + + +
+
+ {product.config.channel === "website" ? ( + + ) : product.config.channel === "app" ? ( + + ) : product.config.channel === "link" ? ( + + ) : ( + + )} +
+ {!isCollapsed && !isTextVisible && ( + <> +
+

+ {product.name} +

+

+ {product.config.channel === "link" + ? "Link & Email" + : capitalizeFirstLetter(product.config.channel)} +

+
+ + )}
- {!isCollapsed && !isTextVisible && ( +
+ + handleEnvironmentChangeByProduct(v)}> + {sortedProducts.map((product) => ( + +
+ {product.config.channel === "website" ? ( + + ) : product.config.channel === "app" ? ( + + ) : product.config.channel === "link" ? ( + + ) : ( + + )} +
+
{product?.name}
+
+ ))} +
+ {isOwnerOrManager && ( <> -
-

- {product.name} -

-

- {product.config.channel && product.config.channel === "link" - ? t("common.link_and_email") - : product.config.channel - ? t(`common.${product.config.channel}`) - : null} -

-
- + + handleAddProduct(organization.id)} + icon={}> + {t("common.add_product")} + )} -
- - - handleEnvironmentChangeByProduct(v)}> - {sortedProducts.map((product) => ( - -
- {product.config.channel === "website" ? ( - - ) : product.config.channel === "app" ? ( - - ) : product.config.channel === "link" ? ( - - ) : ( - - )} -
-
{product?.name}
-
- ))} -
- - {isOwnerOrAdmin && ( - handleAddProduct(organization.id)} - icon={}> - {t("common.add_product")} - - )} -
- + + + )} {/* User Switch */}
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx index 2c3746f223..75ce438821 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx @@ -1,15 +1,22 @@ import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons"; +import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { TEnvironment } from "@formbricks/types/environment"; -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; interface SideBarProps { environment: TEnvironment; environments: TEnvironment[]; - membershipRole?: TMembershipRole; + membershipRole?: TOrganizationRole; + productPermission: TTeamPermission | null; } -export const TopControlBar = ({ environment, environments, membershipRole }: SideBarProps) => { +export const TopControlBar = ({ + environment, + environments, + membershipRole, + productPermission, +}: SideBarProps) => { return (
@@ -19,6 +26,7 @@ export const TopControlBar = ({ environment, environments, membershipRole }: Sid environments={environments} isFormbricksCloud={IS_FORMBRICKS_CLOUD} membershipRole={membershipRole} + productPermission={productPermission} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx index 2c8321ef74..54f600a9f4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx @@ -1,19 +1,23 @@ "use client"; import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch"; +import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import formbricks from "@formbricks/js"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TEnvironment } from "@formbricks/types/environment"; -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { Button } from "@formbricks/ui/components/Button"; interface TopControlButtonsProps { environment: TEnvironment; environments: TEnvironment[]; isFormbricksCloud: boolean; - membershipRole?: TMembershipRole; + membershipRole?: TOrganizationRole; + productPermission: TTeamPermission | null; } export const TopControlButtons = ({ @@ -21,12 +25,18 @@ export const TopControlButtons = ({ environments, isFormbricksCloud, membershipRole, + productPermission, }: TopControlButtonsProps) => { const t = useTranslations(); const router = useRouter(); + + const { isMember, isBilling } = getAccessFlags(membershipRole); + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + const isReadOnly = isMember && hasReadAccess; + return (
- + {!isBilling && } {isFormbricksCloud && ( - {membershipRole && membershipRole !== "viewer" ? ( + {isBilling || isReadOnly ? ( + <> + ) : ( - ) : null} + )}
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts index beeba66026..5a6eee5eed 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts @@ -1,10 +1,10 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; import { ZId } from "@formbricks/types/common"; import { ZIntegrationInput } from "@formbricks/types/integration"; @@ -16,10 +16,20 @@ const ZCreateOrUpdateIntegrationAction = z.object({ export const createOrUpdateIntegrationAction = authenticatedActionClient .schema(ZCreateOrUpdateIntegrationAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["integration", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + }, + ], }); return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData); @@ -32,10 +42,20 @@ const ZDeleteIntegrationAction = z.object({ export const deleteIntegrationAction = authenticatedActionClient .schema(ZDeleteIntegrationAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.integrationId), - rules: ["integration", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + productId: await getProductIdFromEnvironmentId(parsedInput.integrationId), + minPermission: "readWrite", + }, + ], }); return await deleteIntegration(parsedInput.integrationId); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx index c5cc563fd0..c2bbdbe421 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx @@ -1,10 +1,17 @@ import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; import { getAirtableTables } from "@formbricks/lib/airtable/service"; import { getAttributeClasses } from "@formbricks/lib/attributeClass/service"; +import { authOptions } from "@formbricks/lib/authOptions"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getIntegrations } from "@formbricks/lib/integration/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurveys } from "@formbricks/lib/survey/service"; import { findMatchingLocale } from "@formbricks/lib/utils/locale"; @@ -17,12 +24,18 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader"; const Page = async ({ params }) => { const t = await getTranslations(); const isEnabled = !!AIRTABLE_CLIENT_ID; - const [surveys, integrations, environment, attributeClasses] = await Promise.all([ + const [session, surveys, integrations, environment, attributeClasses] = await Promise.all([ + getServerSession(authOptions), getSurveys(params.environmentId), getIntegrations(params.environmentId), getEnvironment(params.environmentId), getAttributeClasses(params.environmentId), ]); + + if (!session) { + throw new Error(t("common.session_not_found")); + } + if (!environment) { throw new Error(t("common.environment_not_found")); } @@ -42,6 +55,22 @@ const Page = async ({ params }) => { const locale = findMatchingLocale(); + const currentUserMembership = await getMembershipByUserIdOrganizationId( + session?.user.id, + product.organizationId + ); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + + if (isReadOnly) { + redirect("./"); + } + return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx index 8fe1c245db..57f899f398 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx @@ -1,6 +1,11 @@ import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; import { getAttributeClasses } from "@formbricks/lib/attributeClass/service"; +import { authOptions } from "@formbricks/lib/authOptions"; import { GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_SECRET, @@ -9,6 +14,8 @@ import { } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getIntegrations } from "@formbricks/lib/integration/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurveys } from "@formbricks/lib/survey/service"; import { findMatchingLocale } from "@formbricks/lib/utils/locale"; @@ -20,12 +27,18 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader"; const Page = async ({ params }) => { const t = await getTranslations(); const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL); - const [surveys, integrations, environment, attributeClasses] = await Promise.all([ + const [session, surveys, integrations, environment, attributeClasses] = await Promise.all([ + getServerSession(authOptions), getSurveys(params.environmentId), getIntegrations(params.environmentId), getEnvironment(params.environmentId), getAttributeClasses(params.environmentId), ]); + + if (!session) { + throw new Error(t("common.session_not_found")); + } + if (!environment) { throw new Error(t("common.environment_not_found")); } @@ -40,6 +53,22 @@ const Page = async ({ params }) => { const locale = findMatchingLocale(); + const currentUserMembership = await getMembershipByUserIdOrganizationId( + session?.user.id, + product.organizationId + ); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + + if (isReadOnly) { + redirect("./"); + } + return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx index e0c4aed04f..cd0ea384ba 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx @@ -1,6 +1,11 @@ import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; import { getAttributeClasses } from "@formbricks/lib/attributeClass/service"; +import { authOptions } from "@formbricks/lib/authOptions"; import { NOTION_AUTH_URL, NOTION_OAUTH_CLIENT_ID, @@ -10,7 +15,10 @@ import { } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getIntegrationByType } from "@formbricks/lib/integration/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getNotionDatabases } from "@formbricks/lib/notion/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurveys } from "@formbricks/lib/survey/service"; import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; @@ -26,23 +34,49 @@ const Page = async ({ params }) => { NOTION_AUTH_URL && NOTION_REDIRECT_URI ); - const [surveys, notionIntegration, environment, attributeClasses] = await Promise.all([ + const [session, surveys, notionIntegration, environment, attributeClasses] = await Promise.all([ + getServerSession(authOptions), getSurveys(params.environmentId), getIntegrationByType(params.environmentId, "notion"), getEnvironment(params.environmentId), getAttributeClasses(params.environmentId), ]); + if (!session) { + throw new Error(t("common.session_not_found")); + } + if (!environment) { throw new Error(t("common.environment_not_found")); } + const product = await getProductByEnvironmentId(params.environmentId); + if (!product) { + throw new Error(t("common.product_not_found")); + } + let databasesArray: TIntegrationNotionDatabase[] = []; if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) { databasesArray = await getNotionDatabases(environment.id); } const locale = await findMatchingLocale(); + const currentUserMembership = await getMembershipByUserIdOrganizationId( + session?.user.id, + product.organizationId + ); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + + if (isReadOnly) { + redirect("./"); + } + return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx index b01c2d6dd4..1d505f7c93 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx @@ -7,9 +7,12 @@ import notionLogo from "@/images/notion.png"; import SlackLogo from "@/images/slacklogo.png"; import WebhookLogo from "@/images/webhook.png"; import ZapierLogo from "@/images/zapier-small.png"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; +import { redirect } from "next/navigation"; import { authOptions } from "@formbricks/lib/authOptions"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getIntegrations } from "@formbricks/lib/integration/service"; @@ -18,7 +21,6 @@ import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getWebhookCountBySource } from "@formbricks/lib/webhook/service"; import { TIntegrationType } from "@formbricks/types/integration"; -import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent"; import { Card } from "@formbricks/ui/components/IntegrationCard"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; @@ -56,8 +58,22 @@ const Page = async ({ params }) => { throw new Error(t("common.organization_not_found")); } + if (!environment) { + throw new Error(t("common.environment_not_found")); + } + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); + const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + + if (isBilling) { + return redirect(`/environments/${params.environmentId}/settings/billing`); + } const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets"); const isNotionIntegrationConnected = isIntegrationConnected("notion"); @@ -84,6 +100,7 @@ const Page = async ({ params }) => { : zapierWebhookCount === 0 ? t("common.not_connected") : `${zapierWebhookCount} zaps`, + disabled: isReadOnly, }, { connectHref: `/environments/${params.environmentId}/integrations/webhooks`, @@ -102,6 +119,7 @@ const Page = async ({ params }) => { : userWebhookCount === 0 ? t("common.not_connected") : `${userWebhookCount} webhooks`, + disabled: false, }, { connectHref: `/environments/${params.environmentId}/integrations/google-sheets`, @@ -115,6 +133,7 @@ const Page = async ({ params }) => { icon: , connected: isGoogleSheetsIntegrationConnected, statusText: isGoogleSheetsIntegrationConnected ? t("common.connected") : t("common.not_connected"), + disabled: isReadOnly, }, { connectHref: `/environments/${params.environmentId}/integrations/airtable`, @@ -128,6 +147,7 @@ const Page = async ({ params }) => { icon: , connected: isAirtableIntegrationConnected, statusText: isAirtableIntegrationConnected ? t("common.connected") : t("common.not_connected"), + disabled: isReadOnly, }, { connectHref: `/environments/${params.environmentId}/integrations/slack`, @@ -141,6 +161,7 @@ const Page = async ({ params }) => { icon: , connected: isSlackIntegrationConnected, statusText: isSlackIntegrationConnected ? t("common.connected") : t("common.not_connected"), + disabled: isReadOnly, }, { docsHref: "https://formbricks.com/docs/integrations/n8n", @@ -159,6 +180,7 @@ const Page = async ({ params }) => { : n8nwebhookCount === 0 ? t("common.not_connected") : `${n8nwebhookCount} ${t("common.integrations")}`, + disabled: isReadOnly, }, { docsHref: "https://formbricks.com/docs/integrations/make", @@ -177,6 +199,7 @@ const Page = async ({ params }) => { : makeWebhookCount === 0 ? t("common.not_connected") : `${makeWebhookCount} ${t("common.integrations")}`, + disabled: isReadOnly, }, { connectHref: `/environments/${params.environmentId}/integrations/notion`, @@ -190,6 +213,7 @@ const Page = async ({ params }) => { icon: , connected: isNotionIntegrationConnected, statusText: isNotionIntegrationConnected ? t("common.connected") : t("common.not_connected"), + disabled: isReadOnly, }, ]; @@ -205,10 +229,9 @@ const Page = async ({ params }) => { icon: , connected: widgetSetupCompleted, statusText: widgetSetupCompleted ? t("common.connected") : t("common.not_connected"), + disabled: false, }); - if (isViewer) return ; - return ( @@ -227,6 +250,7 @@ const Page = async ({ params }) => { icon={card.icon} connected={card.connected} statusText={card.statusText} + disabled={card.disabled} /> ))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts index b96fcb68e0..69a53a2bb0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts @@ -1,9 +1,9 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; import { getSlackChannels } from "@formbricks/lib/slack/service"; import { ZId } from "@formbricks/types/common"; @@ -14,10 +14,20 @@ const ZRefreshChannelsAction = z.object({ export const refreshChannelsAction = authenticatedActionClient .schema(ZRefreshChannelsAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["integration", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + minPermission: "readWrite", + }, + ], }); return await getSlackChannels(parsedInput.environmentId); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx index 71e0867b42..ad07476777 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx @@ -1,9 +1,17 @@ import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; import { getAttributeClasses } from "@formbricks/lib/attributeClass/service"; +import { authOptions } from "@formbricks/lib/authOptions"; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getIntegrationByType } from "@formbricks/lib/integration/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSlackChannels } from "@formbricks/lib/slack/service"; import { getSurveys } from "@formbricks/lib/survey/service"; import { findMatchingLocale } from "@formbricks/lib/utils/locale"; @@ -16,22 +24,49 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader"; const Page = async ({ params }) => { const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET); const t = await getTranslations(); - const [surveys, slackIntegration, environment, attributeClasses] = await Promise.all([ + const [session, surveys, slackIntegration, environment, attributeClasses] = await Promise.all([ + getServerSession(authOptions), getSurveys(params.environmentId), getIntegrationByType(params.environmentId, "slack"), getEnvironment(params.environmentId), getAttributeClasses(params.environmentId), ]); + if (!session) { + throw new Error(t("common.session_not_found")); + } + if (!environment) { throw new Error(t("common.environment_not_found")); } + const product = await getProductByEnvironmentId(params.environmentId); + if (!product) { + throw new Error(t("common.product_not_found")); + } + let channelsArray: TIntegrationItem[] = []; if (slackIntegration && slackIntegration.config.key) { channelsArray = await getSlackChannels(params.environmentId); } const locale = await findMatchingLocale(); + + const currentUserMembership = await getMembershipByUserIdOrganizationId( + session?.user.id, + product.organizationId + ); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + + if (isReadOnly) { + redirect("./"); + } + return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/actions.ts index 4d0d0f1c40..700652f9e2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/actions.ts @@ -1,12 +1,13 @@ "use server"; -import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getOrganizationIdFromEnvironmentId, getOrganizationIdFromWebhookId, -} from "@formbricks/lib/organization/utils"; + getProductIdFromEnvironmentId, +} from "@/lib/utils/helper"; +import { z } from "zod"; import { createWebhook, deleteWebhook, updateWebhook } from "@formbricks/lib/webhook/service"; import { testEndpoint } from "@formbricks/lib/webhook/utils"; import { ZId } from "@formbricks/types/common"; @@ -20,10 +21,20 @@ const ZCreateWebhookAction = z.object({ export const createWebhookAction = authenticatedActionClient .schema(ZCreateWebhookAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["webhook", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "read", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + }, + ], }); return await createWebhook(parsedInput.environmentId, parsedInput.webhookInput); @@ -36,10 +47,20 @@ const ZDeleteWebhookAction = z.object({ export const deleteWebhookAction = authenticatedActionClient .schema(ZDeleteWebhookAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromWebhookId(parsedInput.id), - rules: ["webhook", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromEnvironmentId(parsedInput.id), + }, + ], }); return await deleteWebhook(parsedInput.id); @@ -53,10 +74,20 @@ const ZUpdateWebhookAction = z.object({ export const updateWebhookAction = authenticatedActionClient .schema(ZUpdateWebhookAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromWebhookId(parsedInput.webhookId), - rules: ["webhook", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromEnvironmentId(parsedInput.webhookId), + }, + ], }); return await updateWebhook(parsedInput.webhookId, parsedInput.webhookInput); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup.tsx index 850d2829ef..4254cd205d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup.tsx @@ -25,44 +25,46 @@ export const SurveyCheckboxGroup: React.FC = ({
-
{surveys.map((survey) => (
- { - if (allowChanges) { - onSelectedSurveyChange(survey.id); - } - }} - />
))} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookDetailModal.tsx index a93d337750..4bbcc66527 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookDetailModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookDetailModal.tsx @@ -11,9 +11,10 @@ interface WebhookModalProps { setOpen: (v: boolean) => void; webhook: TWebhook; surveys: TSurvey[]; + isReadOnly: boolean; } -export const WebhookModal = ({ open, setOpen, webhook, surveys }: WebhookModalProps) => { +export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => { const t = useTranslations(); const tabs = [ { @@ -22,7 +23,9 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys }: WebhookModalPr }, { title: t("common.settings"), - children: , + children: ( + + ), }, ]; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookSettingsTab.tsx index 3d007f861e..7635e8f210 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookSettingsTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookSettingsTab.tsx @@ -23,9 +23,10 @@ interface ActionSettingsTabProps { webhook: TWebhook; surveys: TSurvey[]; setOpen: (v: boolean) => void; + isReadOnly: boolean; } -export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettingsTabProps) => { +export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: ActionSettingsTabProps) => { const t = useTranslations(); const router = useRouter(); const { register, handleSubmit } = useForm({ @@ -136,6 +137,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings type="text" id="name" {...register("name")} + disabled={isReadOnly} defaultValue={webhook.name ?? ""} placeholder={t("environments.integrations.webhooks.webhook_name_placeholder")} /> @@ -185,7 +187,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings
@@ -197,13 +199,13 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings selectedAllSurveys={selectedAllSurveys} onSelectAllSurveys={handleSelectAllSurveys} onSelectedSurveyChange={handleSelectedSurveyChange} - allowChanges={webhook.source === "user"} + allowChanges={webhook.source === "user" && !isReadOnly} />
- {webhook.source === "user" && ( + {webhook.source === "user" && !isReadOnly && (
-
- -
+ {!isReadOnly && ( +
+ +
+ )}
{ const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false); const t = useTranslations(); @@ -72,6 +74,7 @@ export const WebhookTable = ({ setOpen={setWebhookDetailModalOpen} webhook={activeWebhook} surveys={surveys} + isReadOnly={isReadOnly} /> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx index ee9de5aec8..2c3b8d8bbf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.tsx @@ -2,8 +2,15 @@ import { AddWebhookButton } from "@/app/(app)/environments/[environmentId]/integ import { WebhookRowData } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookRowData"; import { WebhookTable } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookTable"; import { WebhookTableHeading } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookTableHeading"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { authOptions } from "@formbricks/lib/authOptions"; import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getSurveys } from "@formbricks/lib/survey/service"; import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { getWebhooks } from "@formbricks/lib/webhook/service"; @@ -13,16 +20,35 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader"; const Page = async ({ params }) => { const t = await getTranslations(); - const [webhooksUnsorted, surveys, environment] = await Promise.all([ + const [session, organization, webhooksUnsorted, surveys, environment] = await Promise.all([ + getServerSession(authOptions), + getOrganizationByEnvironmentId(params.environmentId), getWebhooks(params.environmentId), getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit getEnvironment(params.environmentId), ]); + if (!session) { + throw new Error(t("common.session_not_found")); + } + if (!environment) { throw new Error(t("common.environment_not_found")); } + if (!organization) { + throw new Error(t("common.organization_not_found")); + } + + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId); + + const { hasReadAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && hasReadAccess; + const webhooks = webhooksUnsorted.sort((a, b) => { if (a.createdAt > b.createdAt) return -1; if (a.createdAt < b.createdAt) return 1; @@ -35,8 +61,8 @@ const Page = async ({ params }) => { return ( - - + } /> + {webhooks.map((webhook) => ( diff --git a/apps/web/app/(app)/environments/[environmentId]/page.tsx b/apps/web/app/(app)/environments/[environmentId]/page.tsx index a4b7c9d339..2d2cbc382b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/page.tsx @@ -1,16 +1,32 @@ import { getIsAIEnabled } from "@/app/lib/utils"; +import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; const Page = async ({ params }) => { + const session = await getServerSession(authOptions); const t = await getTranslations(); const organization = await getOrganizationByEnvironmentId(params.environmentId); + if (!session) { + return redirect(`/auth/login`); + } + if (!organization) { throw new Error(t("common.organization_not_found")); } + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isBilling } = getAccessFlags(currentUserMembership?.role); + + if (isBilling) { + return redirect(`/environments/${params.environmentId}/settings/billing`); + } + const isAIEnabled = await getIsAIEnabled(organization); if (isAIEnabled) { diff --git a/apps/web/app/(app)/environments/[environmentId]/product/(setup)/app-connection/page.tsx b/apps/web/app/(app)/environments/[environmentId]/product/(setup)/app-connection/page.tsx index 07666a7c80..3ab489c10d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/(setup)/app-connection/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/(setup)/app-connection/page.tsx @@ -3,7 +3,7 @@ import { EnvironmentIdField } from "@/app/(app)/environments/[environmentId]/pro import { SetupInstructions } from "@/app/(app)/environments/[environmentId]/product/(setup)/components/SetupInstructions"; import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation"; import { getTranslations } from "next-intl/server"; -import { getMultiLanguagePermission } from "@formbricks/ee/lib/service"; +import { getMultiLanguagePermission, getRoleManagementPermission } from "@formbricks/ee/lib/service"; import { WEBAPP_URL } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; @@ -28,6 +28,7 @@ const Page = async ({ params }) => { } const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization); return ( @@ -36,6 +37,7 @@ const Page = async ({ params }) => { environmentId={params.environmentId} activeId="app-connection" isMultiLanguageAllowed={isMultiLanguageAllowed} + canDoRoleManagement={canDoRoleManagement} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/product/actions.ts b/apps/web/app/(app)/environments/[environmentId]/product/actions.ts index d610f773a7..e6d2248791 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/product/actions.ts @@ -1,9 +1,9 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromProductId } from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromProductId } from "@formbricks/lib/organization/utils"; import { updateProduct } from "@formbricks/lib/product/service"; import { ZId } from "@formbricks/types/common"; import { ZProductUpdateInput } from "@formbricks/types/product"; @@ -16,12 +16,22 @@ const ZUpdateProductAction = z.object({ export const updateProductAction = authenticatedActionClient .schema(ZUpdateProductAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - schema: ZProductUpdateInput, - data: parsedInput.data, + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromProductId(parsedInput.productId), - rules: ["product", "update"], + access: [ + { + schema: ZProductUpdateInput, + data: parsedInput.data, + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + productId: parsedInput.productId, + minPermission: "manage", + }, + ], }); return await updateProduct(parsedInput.productId, parsedInput.data); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/api-keys/actions.ts b/apps/web/app/(app)/environments/[environmentId]/product/api-keys/actions.ts index 225f5785ec..124fcf446b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/api-keys/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/product/api-keys/actions.ts @@ -1,13 +1,15 @@ "use server"; -import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { createApiKey, deleteApiKey } from "@formbricks/lib/apiKey/service"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getOrganizationIdFromApiKeyId, getOrganizationIdFromEnvironmentId, -} from "@formbricks/lib/organization/utils"; + getProductIdFromApiKeyId, + getProductIdFromEnvironmentId, +} from "@/lib/utils/helper"; +import { z } from "zod"; +import { createApiKey, deleteApiKey } from "@formbricks/lib/apiKey/service"; import { ZApiKeyCreateInput } from "@formbricks/types/api-keys"; import { ZId } from "@formbricks/types/common"; @@ -18,10 +20,20 @@ const ZDeleteApiKeyAction = z.object({ export const deleteApiKeyAction = authenticatedActionClient .schema(ZDeleteApiKeyAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromApiKeyId(parsedInput.id), - rules: ["apiKey", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "manage", + productId: await getProductIdFromApiKeyId(parsedInput.id), + }, + ], }); return await deleteApiKey(parsedInput.id); @@ -35,10 +47,20 @@ const ZCreateApiKeyAction = z.object({ export const createApiKeyAction = authenticatedActionClient .schema(ZCreateApiKeyAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["apiKey", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "manage", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + }, + ], }); return await createApiKey(parsedInput.environmentId, parsedInput.apiKeyData); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/ApiKeyList.tsx b/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/ApiKeyList.tsx index 8ae7adec7c..1e5525efc9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/ApiKeyList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/ApiKeyList.tsx @@ -9,10 +9,12 @@ export const ApiKeyList = async ({ environmentId, environmentType, locale, + isReadOnly, }: { environmentId: string; environmentType: string; locale: TUserLocale; + isReadOnly: boolean; }) => { const t = await getTranslations(); const findEnvironmentByType = (environments, targetType) => { @@ -40,6 +42,7 @@ export const ApiKeyList = async ({ apiKeys={apiKeys} environmentId={environmentId} locale={locale} + isReadOnly={isReadOnly} /> ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/EditApiKeys.tsx b/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/EditApiKeys.tsx index fccc53cec6..a766600850 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/EditApiKeys.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/api-keys/components/EditApiKeys.tsx @@ -1,10 +1,11 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { FilesIcon, TrashIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; +import { cn } from "@formbricks/lib/cn"; import { timeSince } from "@formbricks/lib/time"; import { TApiKey } from "@formbricks/types/api-keys"; import { TUserLocale } from "@formbricks/types/user"; @@ -19,12 +20,14 @@ export const EditAPIKeys = ({ apiKeys, environmentId, locale, + isReadOnly, }: { environmentTypeId: string; environmentType: string; apiKeys: TApiKey[]; environmentId: string; locale: TUserLocale; + isReadOnly: boolean; }) => { const t = useTranslations(); const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false); @@ -108,7 +111,7 @@ export const EditAPIKeys = ({ apiKeysLocal && apiKeysLocal.map((apiKey) => (
{apiKey.label}
@@ -117,28 +120,35 @@ export const EditAPIKeys = ({
{timeSince(apiKey.createdAt.toString(), locale)}
-
- -
+ {!isReadOnly && ( +
+
+ )}
)) )}
-
- -
- + {!isReadOnly && ( +
+ +
+ )} { const t = await getTranslations(); - const [session, environment, organization] = await Promise.all([ + const [session, environment, organization, product] = await Promise.all([ getServerSession(authOptions), getEnvironment(params.environmentId), getOrganizationByEnvironmentId(params.environmentId), + getProductByEnvironmentId(params.environmentId), ]); if (!environment) { @@ -34,17 +37,29 @@ const Page = async ({ params }) => { } const locale = await findMatchingLocale(); - const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); - const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + if (!product) { + throw new Error(t("common.product_not_found")); + } - return !isViewer ? ( + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); + const { hasManageAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && !hasManageAccess; + + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization); + + return ( @@ -52,18 +67,26 @@ const Page = async ({ params }) => { - + ) : ( - + )} - ) : ( - ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation.tsx index 1e4a8ee105..ad7f7f1375 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation.tsx @@ -10,6 +10,7 @@ interface ProductConfigNavigationProps { environmentId?: string; isMultiLanguageAllowed?: boolean; loading?: boolean; + canDoRoleManagement?: boolean; } export const ProductConfigNavigation = ({ @@ -17,9 +18,11 @@ export const ProductConfigNavigation = ({ environmentId, isMultiLanguageAllowed, loading, + canDoRoleManagement, }: ProductConfigNavigationProps) => { const t = useTranslations(); const pathname = usePathname(); + let navigation = [ { id: "general", @@ -64,6 +67,13 @@ export const ProductConfigNavigation = ({ href: `/environments/${environmentId}/product/app-connection`, current: pathname?.includes("/app-connection"), }, + { + id: "teams", + label: t("common.team_access"), + href: `/environments/${environmentId}/product/teams`, + hidden: !canDoRoleManagement, + current: pathname?.includes("/teams"), + }, ]; return ; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts b/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts index 712518245d..9c2e82dbf7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/actions.ts @@ -1,10 +1,10 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromProductId } from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromProductId } from "@formbricks/lib/organization/utils"; -import { deleteProduct, getProducts } from "@formbricks/lib/product/service"; +import { deleteProduct, getUserProducts } from "@formbricks/lib/product/service"; import { ZId } from "@formbricks/types/common"; const ZProductDeleteAction = z.object({ @@ -14,16 +14,20 @@ const ZProductDeleteAction = z.object({ export const deleteProductAction = authenticatedActionClient .schema(ZProductDeleteAction) .action(async ({ ctx, parsedInput }) => { - // get organizationId from productId const organizationId = await getOrganizationIdFromProductId(parsedInput.productId); - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: organizationId, - rules: ["product", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], }); - const availableProducts = (await getProducts(organizationId)) ?? null; + const availableProducts = (await getUserProducts(ctx.user.id, organizationId)) ?? null; if (!!availableProducts && availableProducts?.length <= 1) { throw new Error("You can't delete the last product in the environment."); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx index 669352480d..7ac0f5127c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProduct.tsx @@ -2,17 +2,17 @@ import { DeleteProductRender } from "@/app/(app)/environments/[environmentId]/pr import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { authOptions } from "@formbricks/lib/authOptions"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getProducts } from "@formbricks/lib/product/service"; +import { getUserProducts } from "@formbricks/lib/product/service"; import { TProduct } from "@formbricks/types/product"; type DeleteProductProps = { environmentId: string; product: TProduct; + isOwnerOrManager: boolean; }; -export const DeleteProduct = async ({ environmentId, product }: DeleteProductProps) => { +export const DeleteProduct = async ({ environmentId, product, isOwnerOrManager }: DeleteProductProps) => { const t = await getTranslations(); const session = await getServerSession(authOptions); if (!session) { @@ -22,21 +22,15 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro if (!organization) { throw new Error(t("common.organization_not_found")); } - const availableProducts = organization ? await getProducts(organization.id) : null; + const availableProducts = organization ? await getUserProducts(session.user.id, organization.id) : null; - const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); - if (!membership) { - throw new Error(t("common.membership_not_found")); - } - const role = membership.role; const availableProductsLength = availableProducts ? availableProducts.length : 0; - const isUserAdminOrOwner = role === "admin" || role === "owner"; - const isDeleteDisabled = availableProductsLength <= 1 || !isUserAdminOrOwner; + const isDeleteDisabled = availableProductsLength <= 1 || !isOwnerOrManager; return ( ); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx index 89a69aff8f..19a0995651 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/DeleteProductRender.tsx @@ -1,25 +1,26 @@ "use client"; import { deleteProductAction } from "@/app/(app)/environments/[environmentId]/product/general/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { useState } from "react"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { truncate } from "@formbricks/lib/utils/strings"; import { TProduct } from "@formbricks/types/product"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Button } from "@formbricks/ui/components/Button"; import { DeleteDialog } from "@formbricks/ui/components/DeleteDialog"; type DeleteProductRenderProps = { isDeleteDisabled: boolean; - isUserAdminOrOwner: boolean; + isOwnerOrManager: boolean; product: TProduct; }; export const DeleteProductRender = ({ isDeleteDisabled, - isUserAdminOrOwner, + isOwnerOrManager, product, }: DeleteProductRenderProps) => { const t = useTranslations(); @@ -64,11 +65,13 @@ export const DeleteProductRender = ({ )} {isDeleteDisabled && ( -

- {!isUserAdminOrOwner - ? t("environments.product.general.only_admin_or_owners_can_delete_products") - : t("environments.product.general.cannot_delete_only_product")} -

+ + + {!isOwnerOrManager + ? t("environments.product.general.only_owners_or_managers_can_delete_products") + : t("environments.product.general.cannot_delete_only_product")} + + )} ; -export const EditProductNameForm: React.FC = ({ - product, - isProductNameEditDisabled, -}) => { +export const EditProductNameForm: React.FC = ({ product, isReadOnly }) => { const t = useTranslations(); const form = useForm({ defaultValues: { @@ -74,41 +72,51 @@ export const EditProductNameForm: React.FC = ({ } }; - return !isProductNameEditDisabled ? ( - -
- ( - - - {t("environments.product.general.whats_your_product_called")} - - - - - - - )} - /> + return ( + <> + + + ( + + + {t("environments.product.general.whats_your_product_called")} + + + + + + + )} + /> - - - - ) : ( -

- {t("common.only_owners_admins_and_editors_can_perform_this_action")} -

+ + +
+ {isReadOnly && ( + + + {t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")} + + + )} + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx index 8f9bbab108..a9905bdd2d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/components/EditWaitingTimeForm.tsx @@ -1,12 +1,13 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import { SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { TProduct, ZProduct } from "@formbricks/types/product"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Button } from "@formbricks/ui/components/Button"; import { FormControl, @@ -21,13 +22,14 @@ import { updateProductAction } from "../../actions"; type EditWaitingTimeProps = { product: TProduct; + isReadOnly: boolean; }; const ZProductRecontactDaysInput = ZProduct.pick({ recontactDays: true }); type EditWaitingTimeFormValues = z.infer; -export const EditWaitingTimeForm: React.FC = ({ product }) => { +export const EditWaitingTimeForm: React.FC = ({ product, isReadOnly }) => { const t = useTranslations(); const form = useForm({ defaultValues: { @@ -55,47 +57,57 @@ export const EditWaitingTimeForm: React.FC = ({ product }) }; return ( - -
- ( - - - {t("environments.product.general.wait_x_days_before_showing_next_survey")} - - - { - const value = e.target.value; - if (value === "") { - field.onChange(""); - } + <> + + + ( + + + {t("environments.product.general.wait_x_days_before_showing_next_survey")} + + + { + const value = e.target.value; + if (value === "") { + field.onChange(""); + } - field.onChange(parseInt(value, 10)); - }} - /> - - - - )} - /> + field.onChange(parseInt(value, 10)); + }} + disabled={isReadOnly} + /> + + + + )} + /> - - -
+ + + + {isReadOnly && ( + + + {t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")} + + + )} + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx b/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx index 388884153b..d9c454a865 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/general/page.tsx @@ -1,15 +1,16 @@ import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import packageJson from "@/package.json"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; -import { getMultiLanguagePermission } from "@formbricks/ee/lib/service"; +import { getMultiLanguagePermission, getRoleManagementPermission } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; import { SettingsId } from "@formbricks/ui/components/SettingsId"; @@ -37,14 +38,17 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { } const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isDeveloper, isViewer } = getAccessFlags(currentUserMembership?.role); - const isProductNameEditDisabled = isDeveloper ? true : isViewer; + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); - if (isViewer) { - return ; - } + const { isMember, isOwner, isManager } = getAccessFlags(currentUserMembership?.role); + const { hasManageAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && !hasManageAccess; const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization); + + const isOwnerOrManager = isOwner || isManager; return ( @@ -53,23 +57,27 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { environmentId={params.environmentId} activeId="general" isMultiLanguageAllowed={isMultiLanguageAllowed} + canDoRoleManagement={canDoRoleManagement} />
- - + - + - +
diff --git a/apps/web/app/(app)/environments/[environmentId]/product/languages/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/product/languages/loading.tsx index e7be4278ad..a902d94952 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/languages/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/languages/loading.tsx @@ -2,8 +2,8 @@ import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { LanguageLabels } from "@/modules/ee/multi-language-surveys/components/language-labels"; import { useTranslations } from "next-intl"; -import { LanguageLabels } from "@formbricks/ee/multi-language/components/language-labels"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/languages/page.tsx b/apps/web/app/(app)/environments/[environmentId]/product/languages/page.tsx index 8635432da5..769bae320a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/languages/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/languages/page.tsx @@ -1,11 +1,15 @@ import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { EditLanguage } from "@/modules/ee/multi-language-surveys/components/edit-language"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -import { getMultiLanguagePermission } from "@formbricks/ee/lib/service"; -import { EditLanguage } from "@formbricks/ee/multi-language/components/edit-language"; +import { getMultiLanguagePermission, getRoleManagementPermission } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganization } from "@formbricks/lib/organization/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getUser } from "@formbricks/lib/user/service"; @@ -31,6 +35,8 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { notFound(); } + const canDoRoleManagement = await getRoleManagementPermission(organization); + const session = await getServerSession(authOptions); if (!session) { @@ -43,6 +49,14 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { throw new Error("User not found"); } + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); + const { hasManageAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && !hasManageAccess; + return ( @@ -50,12 +64,13 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { environmentId={params.environmentId} activeId="languages" isMultiLanguageAllowed={isMultiLanguageAllowed} + canDoRoleManagement={canDoRoleManagement} /> - + ); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/product/layout.tsx index 38e5baeb56..a2fec058d4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/layout.tsx @@ -1,7 +1,10 @@ import { Metadata } from "next"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; import { authOptions } from "@formbricks/lib/authOptions"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; @@ -11,9 +14,9 @@ export const metadata: Metadata = { const ConfigLayout = async ({ children, params }) => { const t = await getTranslations(); - const [organization, product, session] = await Promise.all([ + + const [organization, session] = await Promise.all([ getOrganizationByEnvironmentId(params.environmentId), - getProductByEnvironmentId(params.environmentId), getServerSession(authOptions), ]); @@ -21,14 +24,22 @@ const ConfigLayout = async ({ children, params }) => { throw new Error(t("common.organization_not_found")); } - if (!product) { - throw new Error(t("common.product_not_found")); - } - if (!session) { throw new Error(t("common.session_not_found")); } + const currentUserMembership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); + const { isBilling } = getAccessFlags(currentUserMembership?.role); + + if (isBilling) { + return redirect(`/environments/${params.environmentId}/settings/billing`); + } + + const product = await getProductByEnvironmentId(params.environmentId); + if (!product) { + throw new Error("Product not found"); + } + return children; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditBranding.tsx b/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditBranding.tsx index e9df4df3c3..977a6035ee 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditBranding.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditBranding.tsx @@ -14,6 +14,7 @@ interface EditFormbricksBrandingProps { product: TProduct; canRemoveBranding: boolean; environmentId: string; + isReadOnly?: boolean; } export const EditFormbricksBranding = ({ @@ -21,6 +22,7 @@ export const EditFormbricksBranding = ({ product, canRemoveBranding, environmentId, + isReadOnly, }: EditFormbricksBrandingProps) => { const t = useTranslations(); const [isBrandingEnabled, setIsBrandingEnabled] = useState( @@ -56,7 +58,7 @@ export const EditFormbricksBranding = ({ id={`branding-${type}`} checked={isBrandingEnabled} onCheckedChange={toggleBranding} - disabled={!canRemoveBranding || updatingBranding} + disabled={!canRemoveBranding || updatingBranding || isReadOnly} />
+ ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditPlacementForm.tsx b/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditPlacementForm.tsx index 29618e2299..8234429b9c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditPlacementForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/look/components/EditPlacementForm.tsx @@ -7,6 +7,7 @@ import toast from "react-hot-toast"; import { z } from "zod"; import { cn } from "@formbricks/lib/cn"; import { TProduct } from "@formbricks/types/product"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Button } from "@formbricks/ui/components/Button"; import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/components/Form"; import { Label } from "@formbricks/ui/components/Label"; @@ -25,6 +26,7 @@ const placements = [ interface EditPlacementProps { product: TProduct; environmentId: string; + isReadOnly: boolean; } const ZProductPlacementInput = z.object({ @@ -35,7 +37,7 @@ const ZProductPlacementInput = z.object({ type EditPlacementFormValues = z.infer; -export const EditPlacementForm = ({ product }: EditPlacementProps) => { +export const EditPlacementForm = ({ product, isReadOnly }: EditPlacementProps) => { const t = useTranslations(); const form = useForm({ defaultValues: { @@ -71,128 +73,150 @@ export const EditPlacementForm = ({ product }: EditPlacementProps) => { }; return ( - -
-
- ( - - - { - field.onChange(value); - }} - className="h-full"> - {placements.map((placement) => ( -
- - -
- ))} -
-
-
- )} - /> -
+ <> + + +
+ ( + + + { + field.onChange(value); + }} + disabled={isReadOnly} + className="h-full"> + {placements.map((placement) => ( +
+ + +
+ ))} +
+
+
+ )} + />
+ clickOutsideClose ? "" : "cursor-not-allowed", + "relative ml-8 h-40 w-full rounded", + overlayStyle + )}> +
+
-
- {currentPlacement === "center" && ( - <> -
- ( - - - {t("environments.product.look.centered_modal_overlay_color")} - - - { - field.onChange(value === "darkOverlay"); - }} - className="flex space-x-4"> -
- - -
-
- - -
-
-
-
- )} - /> -
-
- ( - - - {t("common.allow_users_to_exit_by_clicking_outside_the_survey")} - - - { - field.onChange(value === "allow"); - }} - className="flex space-x-4"> -
- - -
-
- - -
-
-
-
- )} - /> -
- - )} + {currentPlacement === "center" && ( + <> +
+ ( + + + {t("environments.product.look.centered_modal_overlay_color")} + + + { + field.onChange(value === "darkOverlay"); + }} + disabled={isReadOnly} + className="flex space-x-4"> +
+ + +
+
+ + +
+
+
+
+ )} + /> +
+
+ ( + + + {t("common.allow_users_to_exit_by_clicking_outside_the_survey")} + + + { + field.onChange(value === "allow"); + }} + className="flex space-x-4"> +
+ + +
+
+ + +
+
+
+
+ )} + /> +
+ + )} - - -
+ + + + {isReadOnly && ( + + + {t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")} + + + )} + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/look/components/ThemeStyling.tsx b/apps/web/app/(app)/environments/[environmentId]/product/look/components/ThemeStyling.tsx index c4a9c56b8d..adb99fa869 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/look/components/ThemeStyling.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/look/components/ThemeStyling.tsx @@ -4,6 +4,7 @@ import { BackgroundStylingCard } from "@/app/(app)/(survey-editor)/environments/ import { CardStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings"; import { FormStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings"; import { ThemeStylingPreviewSurvey } from "@/app/(app)/environments/[environmentId]/product/look/components/ThemeStylingPreviewSurvey"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { zodResolver } from "@hookform/resolvers/zod"; import { RotateCcwIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -11,10 +12,10 @@ import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; import { SubmitHandler, UseFormReturn, useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { COLOR_DEFAULTS, getPreviewSurvey } from "@formbricks/lib/styling/constants"; import { TProduct, TProductStyling, ZProductStyling } from "@formbricks/types/product"; import { TSurvey, TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { AlertDialog } from "@formbricks/ui/components/AlertDialog"; import { Button } from "@formbricks/ui/components/Button"; import { @@ -34,6 +35,7 @@ type ThemeStylingProps = { colors: string[]; isUnsplashConfigured: boolean; locale: string; + isReadOnly: boolean; }; export const ThemeStyling = ({ @@ -42,6 +44,7 @@ export const ThemeStyling = ({ colors, isUnsplashConfigured, locale, + isReadOnly, }: ThemeStylingProps) => { const t = useTranslations(); const router = useRouter(); @@ -151,6 +154,15 @@ export const ThemeStyling = ({ } }; + if (isReadOnly) { + return ( + + + {t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")} + + + ); + } return (
diff --git a/apps/web/app/(app)/environments/[environmentId]/product/look/page.tsx b/apps/web/app/(app)/environments/[environmentId]/product/look/page.tsx index d34f7e1a0f..c408231976 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/look/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/look/page.tsx @@ -1,20 +1,24 @@ import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation"; import { EditLogo } from "@/app/(app)/environments/[environmentId]/product/look/components/EditLogo"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { getMultiLanguagePermission, getRemoveInAppBrandingPermission, getRemoveLinkBrandingPermission, + getRoleManagementPermission, } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; +import { cn } from "@formbricks/lib/cn"; import { DEFAULT_LOCALE, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getUserLocale } from "@formbricks/lib/user/service"; -import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; import { SettingsCard } from "../../settings/components/SettingsCard"; @@ -44,13 +48,15 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { const canRemoveLinkBranding = getRemoveLinkBrandingPermission(organization); const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); + const { isMember } = getAccessFlags(currentUserMembership?.role); - if (isViewer) { - return ; - } + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); + const { hasManageAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && !hasManageAccess; const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization); return ( @@ -59,11 +65,12 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { environmentId={params.environmentId} activeId="look" isMultiLanguageAllowed={isMultiLanguageAllowed} + canDoRoleManagement={canDoRoleManagement} /> { colors={SURVEY_BG_COLORS} isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false} locale={locale ?? DEFAULT_LOCALE} + isReadOnly={isReadOnly} /> - + - + { product={product} canRemoveBranding={canRemoveLinkBranding} environmentId={params.environmentId} + isReadOnly={isReadOnly} />
+ + {isReadOnly && ( + + + {t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")} + + + )} ); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/tags/actions.ts b/apps/web/app/(app)/environments/[environmentId]/product/tags/actions.ts index cb37fa90da..b457fe78b2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/tags/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/product/tags/actions.ts @@ -1,9 +1,15 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { + getEnvironmentIdFromTagId, + getOrganizationIdFromEnvironmentId, + getOrganizationIdFromTagId, + getProductIdFromEnvironmentId, + getProductIdFromTagId, +} from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromTagId } from "@formbricks/lib/organization/utils"; import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/tag/service"; import { ZId } from "@formbricks/types/common"; @@ -14,10 +20,20 @@ const ZDeleteTagAction = z.object({ export const deleteTagAction = authenticatedActionClient .schema(ZDeleteTagAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromTagId(parsedInput.tagId), - rules: ["tag", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromTagId(parsedInput.tagId), + }, + ], }); return await deleteTag(parsedInput.tagId); @@ -31,10 +47,20 @@ const ZUpdateTagNameAction = z.object({ export const updateTagNameAction = authenticatedActionClient .schema(ZUpdateTagNameAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromTagId(parsedInput.tagId), - rules: ["tag", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromTagId(parsedInput.tagId), + }, + ], }); return await updateTagName(parsedInput.tagId, parsedInput.name); @@ -48,16 +74,27 @@ const ZMergeTagsAction = z.object({ export const mergeTagsAction = authenticatedActionClient .schema(ZMergeTagsAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromTagId(parsedInput.originalTagId), - rules: ["tag", "update"], - }); + const originalTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.originalTagId); + const newTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.newTagId); - await checkAuthorization({ + if (originalTagEnvironmentId !== newTagEnvironmentId) { + throw new Error("Tags must be in the same environment"); + } + + await checkAuthorizationUpdated({ userId: ctx.user.id, - organizationId: await getOrganizationIdFromTagId(parsedInput.newTagId), - rules: ["tag", "update"], + organizationId: await getOrganizationIdFromEnvironmentId(newTagEnvironmentId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromEnvironmentId(newTagEnvironmentId), + }, + ], }); return await mergeTags(parsedInput.originalTagId, parsedInput.newTagId); diff --git a/apps/web/app/(app)/environments/[environmentId]/product/tags/components/EditTagsWrapper.tsx b/apps/web/app/(app)/environments/[environmentId]/product/tags/components/EditTagsWrapper.tsx index a964d9fca6..5f7633fbb5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/tags/components/EditTagsWrapper.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/tags/components/EditTagsWrapper.tsx @@ -6,12 +6,12 @@ import { updateTagNameAction, } from "@/app/(app)/environments/[environmentId]/product/tags/actions"; import { MergeTagsCombobox } from "@/app/(app)/environments/[environmentId]/product/tags/components/MergeTagsCombobox"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { AlertCircleIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { toast } from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { cn } from "@formbricks/lib/cn"; import { TEnvironment } from "@formbricks/types/environment"; import { TTag, TTagsCount } from "@formbricks/types/tags"; @@ -25,6 +25,7 @@ interface EditTagsWrapperProps { environment: TEnvironment; environmentTags: TTag[]; environmentTagsCount: TTagsCount; + isReadOnly: boolean; } const SingleTag: React.FC<{ @@ -34,6 +35,7 @@ const SingleTag: React.FC<{ tagCountLoading?: boolean; updateTagsCount?: () => void; environmentTags: TTag[]; + isReadOnly?: boolean; }> = ({ tagId, tagName, @@ -41,6 +43,7 @@ const SingleTag: React.FC<{ tagCountLoading = false, updateTagsCount = () => {}, environmentTags, + isReadOnly = false, }) => { const t = useTranslations(); const router = useRouter(); @@ -66,6 +69,7 @@ const SingleTag: React.FC<{
{tagCountLoading ? :

{tagCount}

}
-
-
- {isMergingTags ? ( -
- -
- ) : ( - tag.id !== tagId) - ?.map((tag) => ({ label: tag.name, value: tag.id })) ?? [] - } - onSelect={(newTagId) => { - setIsMergingTags(true); - mergeTagsAction({ originalTagId: tagId, newTagId }).then((mergeTagsResponse) => { - if (mergeTagsResponse?.data) { - toast.success(t("environments.product.tags.tags_merged")); - updateTagsCount(); - router.refresh(); - } else { - const errorMessage = getFormattedErrorMessage(mergeTagsResponse); - toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again")); - } - setIsMergingTags(false); - }); - }} - /> - )} -
+ {!isReadOnly && ( +
+
+ {isMergingTags ? ( +
+ +
+ ) : ( + tag.id !== tagId) + ?.map((tag) => ({ label: tag.name, value: tag.id })) ?? [] + } + onSelect={(newTagId) => { + setIsMergingTags(true); + mergeTagsAction({ originalTagId: tagId, newTagId }).then((mergeTagsResponse) => { + if (mergeTagsResponse?.data) { + toast.success(t("environments.product.tags.tags_merged")); + updateTagsCount(); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(mergeTagsResponse); + toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again")); + } + setIsMergingTags(false); + }); + }} + /> + )} +
-
- - +
+ + +
-
+ )}
); @@ -162,13 +168,15 @@ const SingleTag: React.FC<{ export const EditTagsWrapper: React.FC = (props) => { const t = useTranslations(); - const { environment, environmentTags, environmentTagsCount } = props; + const { environment, environmentTags, environmentTagsCount, isReadOnly } = props; return (
{t("environments.product.tags.tag")}
{t("environments.product.tags.count")}
-
{t("common.actions")}
+ {!isReadOnly && ( +
{t("common.actions")}
+ )}
{!environmentTags?.length ? ( @@ -182,6 +190,7 @@ export const EditTagsWrapper: React.FC = (props) => { tagName={tag.name} tagCount={environmentTagsCount?.find((count) => count.tagId === tag.id)?.count ?? 0} environmentTags={environmentTags} + isReadOnly={isReadOnly} /> ))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/product/tags/page.tsx b/apps/web/app/(app)/environments/[environmentId]/product/tags/page.tsx index 31f223bc45..f83739fbf0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/product/tags/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/product/tags/page.tsx @@ -1,16 +1,18 @@ import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; -import { getMultiLanguagePermission } from "@formbricks/ee/lib/service"; +import { getMultiLanguagePermission, getRoleManagementPermission } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; import { getTagsOnResponsesCount } from "@formbricks/lib/tagOnResponse/service"; -import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; import { EditTagsWrapper } from "./components/EditTagsWrapper"; @@ -19,14 +21,15 @@ const Page = async ({ params }) => { const t = await getTranslations(); const environment = await getEnvironment(params.environmentId); if (!environment) { - throw new Error("Environment not found"); + throw new Error(t("common.environment_not_found")); } - const [tags, environmentTagsCount, organization, session] = await Promise.all([ + const [tags, environmentTagsCount, organization, session, product] = await Promise.all([ getTagsByEnvironmentId(params.environmentId), getTagsOnResponsesCount(params.environmentId), getOrganizationByEnvironmentId(params.environmentId), getServerSession(authOptions), + getProductByEnvironmentId(params.environmentId), ]); if (!environment) { @@ -40,19 +43,29 @@ const Page = async ({ params }) => { throw new Error(t("common.session_not_found")); } + if (!product) { + throw new Error(t("common.product_not_found")); + } + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); - const isTagSettingDisabled = isViewer; + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const productPermission = await getProductPermissionByUserId(session.user.id, product.id); + const { hasManageAccess } = getTeamPermissionFlags(productPermission); + + const isReadOnly = isMember && !hasManageAccess; const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization); - return !isTagSettingDisabled ? ( + return ( { environment={environment} environmentTags={tags} environmentTagsCount={environmentTagsCount} + isReadOnly={isReadOnly} /> - ) : ( - ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/product/teams/page.tsx b/apps/web/app/(app)/environments/[environmentId]/product/teams/page.tsx new file mode 100644 index 0000000000..4d8358c1e3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/product/teams/page.tsx @@ -0,0 +1,3 @@ +import { ProductTeams } from "@/modules/ee/teams/product-teams/page"; + +export default ProductTeams; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar.tsx index ec7e7500a3..ee2b40185b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar.tsx @@ -1,19 +1,16 @@ "use client"; -import { BellRingIcon, UserCircleIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { usePathname } from "next/navigation"; import { SecondaryNavigation } from "@formbricks/ui/components/SecondaryNavigation"; -export const AccountSettingsNavbar = ({ - environmentId, - activeId, - loading, -}: { - activeId: string; +interface AccountSettingsNavbarProps { environmentId?: string; + activeId: string; loading?: boolean; -}) => { +} + +export const AccountSettingsNavbar = ({ environmentId, activeId, loading }: AccountSettingsNavbarProps) => { const pathname = usePathname(); const t = useTranslations(); const navigation = [ @@ -21,14 +18,12 @@ export const AccountSettingsNavbar = ({ id: "profile", label: t("common.profile"), href: `/environments/${environmentId}/settings/profile`, - icon: , current: pathname?.includes("/profile"), }, { id: "notifications", label: t("common.notifications"), href: `/environments/${environmentId}/settings/notifications`, - icon: , current: pathname?.includes("/notifications"), }, ]; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts index 972cc60319..87730fe663 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts @@ -1,7 +1,7 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; import { updateUser } from "@formbricks/lib/user/service"; import { ZUserNotificationSettings } from "@formbricks/types/user"; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx index f9cd847353..7f85aea6fa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx @@ -45,6 +45,37 @@ const getMemberships = async (userId: string): Promise => { const memberships = await prisma.membership.findMany({ where: { userId, + role: { + not: "billing", + }, + OR: [ + { + // Fetch all products if user role is owner or manager + role: { + in: ["owner", "manager"], + }, + }, + { + // Filter products based on team membership if user is not owner or manager + organization: { + products: { + some: { + productTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], }, select: { organization: { @@ -52,6 +83,38 @@ const getMemberships = async (userId: string): Promise => { id: true, name: true, products: { + // Apply conditional filtering based on user's role + where: { + OR: [ + { + // Fetch all products if user is owner or manager + organization: { + memberships: { + some: { + userId, + role: { + in: ["owner", "manager"], + }, + }, + }, + }, + }, + { + // Only include products accessible through teams if user is not owner or manager + productTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }, + ], + }, select: { id: true, name: true, @@ -95,7 +158,6 @@ const Page = async ({ params, searchParams }) => { if (user?.notificationSettings) { user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships); } - return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts index 9fe04d1d0f..87ae62ecf1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts @@ -1,10 +1,8 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; import { disableTwoFactorAuth, enableTwoFactorAuth, setupTwoFactorAuth } from "@formbricks/lib/auth/service"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; import { deleteFile } from "@formbricks/lib/storage/service"; import { getFileNameWithIdFromUrl } from "@formbricks/lib/storage/utils"; import { updateUser } from "@formbricks/lib/user/service"; @@ -66,12 +64,6 @@ const ZRemoveAvatarAction = z.object({ export const removeAvatarAction = authenticatedActionClient .schema(ZRemoveAvatarAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["environment", "read"], - }); - const imageUrl = ctx.user.imageUrl; if (!imageUrl) { throw new Error("Image not found"); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx index e4d3c3bb17..5a76908505 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx @@ -1,12 +1,12 @@ "use client"; import { formbricksLogout } from "@/app/lib/formbricks"; +import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal"; import type { Session } from "next-auth"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { TUser } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; -import { DeleteAccountModal } from "@formbricks/ui/components/DeleteAccountModal"; export const DeleteAccount = ({ session, diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DisableTwoFactorModal.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DisableTwoFactorModal.tsx index 777db8c6ea..e0e484ad3a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DisableTwoFactorModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DisableTwoFactorModal.tsx @@ -1,12 +1,12 @@ "use client"; import { disableTwoFactorAuthAction } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; import { Controller, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { Button } from "@formbricks/ui/components/Button"; import { Input } from "@formbricks/ui/components/Input"; import { Modal } from "@formbricks/ui/components/Modal"; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EnableTwoFactorModal.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EnableTwoFactorModal.tsx index aaea267d3a..428d5f5bac 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EnableTwoFactorModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EnableTwoFactorModal.tsx @@ -4,13 +4,13 @@ import { enableTwoFactorAuthAction, setupTwoFactorAuthAction, } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { useTranslations } from "next-intl"; import Image from "next/image"; import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Controller, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { Button } from "@formbricks/ui/components/Button"; import { Modal } from "@formbricks/ui/components/Modal"; import { OTPInput } from "@formbricks/ui/components/OTPInput"; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx index 9b5efddb58..a4b6511480 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx @@ -4,6 +4,8 @@ import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { authOptions } from "@formbricks/lib/authOptions"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getUser } from "@formbricks/lib/user/service"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/components/PageHeader"; @@ -18,7 +20,19 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { const { environmentId } = params; const session = await getServerSession(authOptions); if (!session) { - throw new Error("Session not found"); + throw new Error(t("common.session_not_found")); + } + + const organization = await getOrganizationByEnvironmentId(environmentId); + + if (!organization) { + throw new Error(t("common.organization_not_found")); + } + + const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); + + if (!membership) { + throw new Error(t("common.membership_not_found")); } const user = session && session.user ? await getUser(session.user.id) : null; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx index d60cf45ab1..0163b6ded6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx @@ -1,35 +1,38 @@ "use client"; -import { BoltIcon, CreditCardIcon, UsersIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { usePathname } from "next/navigation"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { SecondaryNavigation } from "@formbricks/ui/components/SecondaryNavigation"; +interface OrganizationSettingsNavbarProps { + environmentId?: string; + isFormbricksCloud: boolean; + membershipRole?: TOrganizationRole; + activeId: string; + loading?: boolean; + canDoRoleManagement?: boolean; +} + export const OrganizationSettingsNavbar = ({ environmentId, isFormbricksCloud, membershipRole, activeId, loading, -}: { - environmentId?: string; - isFormbricksCloud: boolean; - membershipRole?: TMembershipRole; - activeId: string; - loading?: boolean; -}) => { + canDoRoleManagement = false, +}: OrganizationSettingsNavbarProps) => { const pathname = usePathname(); - const { isAdmin, isOwner } = getAccessFlags(membershipRole); - const isPricingDisabled = !isOwner && !isAdmin; + const { isBilling, isMember } = getAccessFlags(membershipRole); + const isPricingDisabled = isMember; const t = useTranslations(); + const navigation = [ { id: "general", label: t("common.general"), href: `/environments/${environmentId}/settings/general`, - icon: , current: pathname?.includes("/general"), hidden: false, }, @@ -37,15 +40,20 @@ export const OrganizationSettingsNavbar = ({ id: "billing", label: t("common.billing"), href: `/environments/${environmentId}/settings/billing`, - icon: , - hidden: !isFormbricksCloud || isPricingDisabled, + hidden: !isFormbricksCloud || isPricingDisabled || loading, current: pathname?.includes("/billing"), }, + { + id: "teams", + label: t("common.teams"), + href: `/environments/${environmentId}/settings/teams`, + hidden: !canDoRoleManagement || isBilling, + current: pathname?.includes("/teams"), + }, { id: "enterprise", label: t("common.enterprise_license"), href: `/environments/${environmentId}/settings/enterprise`, - icon: , hidden: isFormbricksCloud || isPricingDisabled, current: pathname?.includes("/enterprise"), }, diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx index 3e02784fdb..3aae574b66 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx @@ -3,7 +3,7 @@ import { CheckIcon } from "lucide-react"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -import { getEnterpriseLicense } from "@formbricks/ee/lib/service"; +import { getEnterpriseLicense, getRoleManagementPermission } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; @@ -32,8 +32,8 @@ const Page = async ({ params }) => { } const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isAdmin, isOwner } = getAccessFlags(currentUserMembership?.role); - const isPricingDisabled = !isOwner && !isAdmin; + const { isMember } = getAccessFlags(currentUserMembership?.role); + const isPricingDisabled = isMember; if (isPricingDisabled) { notFound(); @@ -41,6 +41,8 @@ const Page = async ({ params }) => { const { active: isEnterpriseEdition } = await getEnterpriseLicense(); + const canDoRoleManagement = await getRoleManagementPermission(organization); + const paidFeatures = [ { title: t("environments.product.languages.multi_language_surveys"), @@ -92,6 +94,7 @@ const Page = async ({ params }) => { isFormbricksCloud={IS_FORMBRICKS_CLOUD} membershipRole={currentUserMembership?.role} activeId="enterprise" + canDoRoleManagement={canDoRoleManagement} /> {isEnterpriseEdition ? ( diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts index 13e3874a71..227ab884ff 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts @@ -1,23 +1,24 @@ "use server"; -import { z } from "zod"; -import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service"; -import { sendInviteMemberEmail } from "@formbricks/email"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { INVITE_DISABLED } from "@formbricks/lib/constants"; -import { deleteInvite, getInvite, inviteUser, resendInvite } from "@formbricks/lib/invite/service"; -import { createInviteToken } from "@formbricks/lib/jwt"; import { deleteMembership, - getMembershipByUserIdOrganizationId, getMembershipsByUserId, -} from "@formbricks/lib/membership/service"; +} from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/lib/membership"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromInviteId } from "@/lib/utils/helper"; +import { sendInviteMemberEmail } from "@/modules/email"; +import { OrganizationRole } from "@prisma/client"; +import { z } from "zod"; +import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service"; +import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { deleteInvite, getInvite, inviteUser, resendInvite } from "@formbricks/lib/invite/service"; +import { createInviteToken } from "@formbricks/lib/jwt"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { deleteOrganization, updateOrganization } from "@formbricks/lib/organization/service"; -import { getOrganizationIdFromInviteId } from "@formbricks/lib/organization/utils"; import { ZId, ZUuid } from "@formbricks/types/common"; import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors"; -import { ZMembershipRole } from "@formbricks/types/memberships"; +import { ZOrganizationRole } from "@formbricks/types/memberships"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; const ZUpdateOrganizationNameAction = z.object({ @@ -28,13 +29,19 @@ const ZUpdateOrganizationNameAction = z.object({ export const updateOrganizationNameAction = authenticatedActionClient .schema(ZUpdateOrganizationNameAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorization({ - schema: ZOrganizationUpdateInput.pick({ name: true }), - data: parsedInput.data, + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["organization", "update"], + access: [ + { + type: "organization", + schema: ZOrganizationUpdateInput.pick({ name: true }), + data: parsedInput.data, + roles: ["owner"], + }, + ], }); + return await updateOrganization(parsedInput.organizationId, parsedInput.data); }); @@ -46,12 +53,17 @@ const ZUpdateOrganizationAIEnabledAction = z.object({ export const updateOrganizationAIEnabledAction = authenticatedActionClient .schema(ZUpdateOrganizationAIEnabledAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorization({ - schema: ZOrganizationUpdateInput.pick({ isAIEnabled: true }), - data: parsedInput.data, + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["organization", "update"], + access: [ + { + type: "organization", + schema: ZOrganizationUpdateInput.pick({ isAIEnabled: true }), + data: parsedInput.data, + roles: ["owner", "manager"], + }, + ], }); return await updateOrganization(parsedInput.organizationId, parsedInput.data); @@ -65,10 +77,15 @@ const ZDeleteInviteAction = z.object({ export const deleteInviteAction = authenticatedActionClient .schema(ZDeleteInviteAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["invite", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], }); return await deleteInvite(parsedInput.inviteId); }); @@ -81,15 +98,33 @@ const ZDeleteMembershipAction = z.object({ export const deleteMembershipAction = authenticatedActionClient .schema(ZDeleteMembershipAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["membership", "delete"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], }); if (parsedInput.userId === ctx.user.id) { throw new AuthenticationError("You cannot delete yourself from the organization"); } + + const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); + + if (!membership) { + throw new AuthenticationError("Not a member of this organization"); + } + + const memberships = await getMembershipsByUserId(ctx.user.id); + const isLastOwner = memberships?.filter((m) => m.role === "owner").length === 1; + if (membership.role === "owner" && isLastOwner) { + throw new ValidationError("You cannot delete the last owner of the organization"); + } + return await deleteMembership(parsedInput.userId, parsedInput.organizationId); }); @@ -100,10 +135,15 @@ const ZLeaveOrganizationAction = z.object({ export const leaveOrganizationAction = authenticatedActionClient .schema(ZLeaveOrganizationAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["organization", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager", "billing", "member"], + }, + ], }); const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); @@ -113,7 +153,7 @@ export const leaveOrganizationAction = authenticatedActionClient } if (membership.role === "owner") { - throw new ValidationError("You cannot leave a organization you own"); + throw new ValidationError("You cannot leave an organization you own"); } const memberships = await getMembershipsByUserId(ctx.user.id); @@ -131,10 +171,15 @@ const ZCreateInviteTokenAction = z.object({ export const createInviteTokenAction = authenticatedActionClient .schema(ZCreateInviteTokenAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId), - rules: ["invite", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], }); const invite = await getInvite(parsedInput.inviteId); @@ -160,16 +205,21 @@ export const resendInviteAction = authenticatedActionClient throw new AuthenticationError("Invite disabled"); } - await checkAuthorization({ + const inviteOrganizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId); + + if (inviteOrganizationId !== parsedInput.organizationId) { + throw new ValidationError("Invite does not belong to the organization"); + } + + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["invite", "update"], - }); - - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId), - rules: ["invite", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], }); const invite = await getInvite(parsedInput.inviteId); @@ -189,7 +239,7 @@ const ZInviteUserAction = z.object({ organizationId: ZId, email: z.string(), name: z.string(), - role: ZMembershipRole, + role: ZOrganizationRole, }); export const inviteUserAction = authenticatedActionClient @@ -199,10 +249,19 @@ export const inviteUserAction = authenticatedActionClient throw new AuthenticationError("Invite disabled"); } - await checkAuthorization({ + if (!IS_FORMBRICKS_CLOUD && parsedInput.role === OrganizationRole.billing) { + throw new ValidationError("Billing role is not allowed"); + } + + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["invite", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], }); const invite = await inviteUser({ @@ -239,10 +298,15 @@ export const deleteOrganizationAction = authenticatedActionClient const isMultiOrgEnabled = await getIsMultiOrgEnabled(); if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["organization", "delete"], + access: [ + { + type: "organization", + roles: ["owner"], + }, + ], }); return await deleteOrganization(parsedInput.organizationId); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle.tsx index 85b84fe04f..8394796484 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle.tsx @@ -1,22 +1,23 @@ "use client"; import { updateOrganizationAIEnabledAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useState } from "react"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { TOrganization } from "@formbricks/types/organizations"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Label } from "@formbricks/ui/components/Label"; import { Switch } from "@formbricks/ui/components/Switch"; interface AIToggleProps { environmentId: string; organization: TOrganization; - isAdminOrOwner: boolean; + isUserManagerOrOwner: boolean; } -export const AIToggle = ({ organization, isAdminOrOwner }: AIToggleProps) => { +export const AIToggle = ({ organization, isUserManagerOrOwner }: AIToggleProps) => { const t = useTranslations(); const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled); const [isSubmitting, setIsSubmitting] = useState(false); @@ -54,36 +55,43 @@ export const AIToggle = ({ organization, isAdminOrOwner }: AIToggleProps) => { } }; - return !isAdminOrOwner ? ( -

{t("common.you_are_not_authorised_to_perform_this_action")}

- ) : ( -
-
- - { - e.stopPropagation(); - handleUpdateOrganization({ enabled: !organization.isAIEnabled }); - }} - /> + return ( + <> +
+
+ + { + e.stopPropagation(); + handleUpdateOrganization({ enabled: !organization.isAIEnabled }); + }} + /> +
+
+ {t("environments.settings.general.formbricks_ai_privacy_policy_text")}{" "} + + {t("common.privacy_policy")} + + . +
-
- {t("environments.settings.general.formbricks_ai_privacy_policy_text")}{" "} - - {t("common.privacy_policy")} - - . -
-
+ {!isUserManagerOrOwner && ( + + + {t("environments.settings.general.only_org_owner_can_perform_action")} + + + )} + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AddMemberModal.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AddMemberModal.tsx index f9d1b6fff7..60d22b98c6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AddMemberModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AddMemberModal.tsx @@ -1,20 +1,15 @@ "use client"; import { useTranslations } from "next-intl"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs"; import { BulkInviteTab } from "./BulkInviteTab"; import { IndividualInviteTab } from "./IndividualInviteTab"; -export enum MembershipRole { - Admin = "admin", - Editor = "editor", - Developer = "developer", - Viewer = "viewer", -} interface AddMemberModalProps { open: boolean; setOpen: (v: boolean) => void; - onSubmit: (data: { name: string; email: string; role: MembershipRole }[]) => void; + onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void; canDoRoleManagement: boolean; isFormbricksCloud: boolean; environmentId: string; @@ -45,7 +40,12 @@ export const AddMemberModal = ({ { title: t("environments.settings.general.bulk_invite"), children: ( - + ), }, ]; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/BulkInviteTab.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/BulkInviteTab.tsx index 1cbc7aae01..421a9fcfa4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/BulkInviteTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/BulkInviteTab.tsx @@ -7,17 +7,23 @@ import Papa, { type ParseResult } from "papaparse"; import { useRef, useState } from "react"; import toast from "react-hot-toast"; import { ZInvitees } from "@formbricks/types/invites"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Button } from "@formbricks/ui/components/Button"; -import { MembershipRole } from "./AddMemberModal"; interface BulkInviteTabProps { setOpen: (v: boolean) => void; - onSubmit: (data: { name: string; email: string; role: MembershipRole }[]) => void; + onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void; canDoRoleManagement: boolean; + isFormbricksCloud: boolean; } -export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkInviteTabProps) => { +export const BulkInviteTab = ({ + setOpen, + onSubmit, + canDoRoleManagement, + isFormbricksCloud, +}: BulkInviteTabProps) => { const t = useTranslations(); const fileInputRef = useRef(null); const [csvFile, setCSVFile] = useState(); @@ -41,10 +47,15 @@ export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkIn const members = results.data.map((csv) => { const [name, email, role] = csv; + let orgRole = canDoRoleManagement ? role.trim().toLowerCase() : "owner"; + if (!isFormbricksCloud) { + orgRole = orgRole === "billing" ? "owner" : orgRole; + } + return { name: name.trim(), email: email.trim(), - role: canDoRoleManagement ? (role.trim().toLowerCase() as MembershipRole) : MembershipRole.Admin, + role: orgRole as TOrganizationRole, }; }); try { @@ -88,7 +99,7 @@ export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkIn
{!canDoRoleManagement && ( - +

{t("common.warning")}: diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx index 5e408fa52f..a1320b8a96 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, useMemo, useState } from "react"; import toast from "react-hot-toast"; import { TOrganization } from "@formbricks/types/organizations"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Button } from "@formbricks/ui/components/Button"; import { DeleteDialog } from "@formbricks/ui/components/DeleteDialog"; import { Input } from "@formbricks/ui/components/Input"; @@ -45,7 +46,7 @@ export const DeleteOrganization = ({ const deleteDisabledWarning = useMemo(() => { if (isUserOwner) return t("environments.settings.general.cannot_delete_only_organization"); - return t("environments.settings.general.only_owner_can_delete_organization"); + return t("environments.settings.general.only_org_owner_can_perform_action"); }, [isUserOwner]); return ( @@ -65,7 +66,11 @@ export const DeleteOrganization = ({

)} - {isDeleteDisabled &&

{deleteDisabledWarning}

} + {isDeleteDisabled && ( + + {deleteDisabledWarning} + + )} )}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MemberActions.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MemberActions.tsx index e1179fdd48..7ca0e05c83 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MemberActions.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MemberActions.tsx @@ -7,12 +7,12 @@ import { resendInviteAction, } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; import { ShareInviteModal } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/ShareInviteModal"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { SendHorizonalIcon, ShareIcon, TrashIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { TInvite } from "@formbricks/types/invites"; import { TMember } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; @@ -23,7 +23,6 @@ type MemberActionsProps = { organization: TOrganization; member?: TMember; invite?: TInvite; - isAdminOrOwner: boolean; showDeleteButton?: boolean; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MembersInfo.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MembersInfo.tsx index 9980a97f04..375262d902 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MembersInfo.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MembersInfo.tsx @@ -1,8 +1,8 @@ import { MemberActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MemberActions"; import { isInviteExpired } from "@/app/lib/utils"; -import { EditMembershipRole } from "@formbricks/ee/role-management/components/edit-membership-role"; +import { EditMembershipRole } from "@/modules/ee/role-management/components/edit-membership-role"; import { TInvite } from "@formbricks/types/invites"; -import { TMember, TMembershipRole } from "@formbricks/types/memberships"; +import { TMember } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; import { Badge } from "@formbricks/ui/components/Badge"; @@ -10,10 +10,10 @@ type MembersInfoProps = { organization: TOrganization; members: TMember[]; invites: TInvite[]; - isUserAdminOrOwner: boolean; + isUserManagerOrOwner: boolean; currentUserId: string; - currentUserRole: TMembershipRole; canDoRoleManagement: boolean; + isFormbricksCloud: boolean; }; // Type guard to check if member is an invitee @@ -24,14 +24,16 @@ const isInvitee = (member: TMember | TInvite): member is TInvite => { export const MembersInfo = async ({ organization, invites, - isUserAdminOrOwner, + isUserManagerOrOwner, members, currentUserId, - currentUserRole, canDoRoleManagement, + isFormbricksCloud, }: MembersInfoProps) => { const allMembers = [...members, ...invites]; + const doesOrgHaveMoreThanOneOwner = allMembers.filter((member) => member.role === "owner").length > 1; + return (
{allMembers.map((member) => ( @@ -48,22 +50,21 @@ export const MembersInfo = async ({
{canDoRoleManagement && allMembers?.length > 0 && ( )}
- {!member.accepted && - isInvitee(member) && + {isInvitee(member) && (isInviteExpired(member) ? ( ) : ( @@ -74,9 +75,11 @@ export const MembersInfo = async ({ organization={organization} member={!isInvitee(member) ? member : undefined} invite={isInvitee(member) ? member : undefined} - isAdminOrOwner={isUserAdminOrOwner} showDeleteButton={ - isUserAdminOrOwner && member.role !== "owner" && (member as TMember).userId !== currentUserId + isUserManagerOrOwner && + (member as TMember).userId !== currentUserId && + ((member as TMember).role !== "owner" || + ((member as TMember).role === "owner" && doesOrgHaveMoreThanOneOwner)) } />
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/OrganizationActions.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/OrganizationActions.tsx index d775c079a1..3a9248953c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/OrganizationActions.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/OrganizationActions.tsx @@ -5,6 +5,7 @@ import { leaveOrganizationAction, } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; import { AddMemberModal } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AddMemberModal"; +import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -13,12 +14,11 @@ import toast from "react-hot-toast"; import { TInvitee } from "@formbricks/types/invites"; import { TOrganization } from "@formbricks/types/organizations"; import { Button } from "@formbricks/ui/components/Button"; -import { CreateOrganizationModal } from "@formbricks/ui/components/CreateOrganizationModal"; import { CustomDialog } from "@formbricks/ui/components/CustomDialog"; type OrganizationActionsProps = { role: string; - isAdminOrOwner: boolean; + isUserManagerOrOwner: boolean; isLeaveOrganizationDisabled: boolean; organization: TOrganization; isInviteDisabled: boolean; @@ -29,7 +29,7 @@ type OrganizationActionsProps = { }; export const OrganizationActions = ({ - isAdminOrOwner, + isUserManagerOrOwner, role, organization, isLeaveOrganizationDisabled, @@ -95,7 +95,7 @@ export const OrganizationActions = ({ {t("environments.settings.general.create_new_organization")} )} - {!isInviteDisabled && isAdminOrOwner && ( + {!isInviteDisabled && isUserManagerOrOwner && ( - - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx index 1aa5538186..84878e85b9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx @@ -1,15 +1,16 @@ "use client"; import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import { SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganization, ZOrganization } from "@formbricks/types/organizations"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Button } from "@formbricks/ui/components/Button"; import { FormControl, @@ -24,7 +25,7 @@ import { Input } from "@formbricks/ui/components/Input"; interface EditOrganizationNameProps { environmentId: string; organization: TOrganization; - membershipRole?: TMembershipRole; + membershipRole?: TOrganizationRole; } const ZEditOrganizationNameFormSchema = ZOrganization.pick({ name: true }); @@ -40,7 +41,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO resolver: zodResolver(ZEditOrganizationNameFormSchema), }); - const { isViewer } = getAccessFlags(membershipRole); + const { isOwner } = getAccessFlags(membershipRole); const { isSubmitting, isDirty } = form.formState; @@ -64,43 +65,51 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO } }; - return isViewer ? ( -

{t("common.not_authorized")}

- ) : ( - -
- ( - - {t("environments.settings.general.organization_name")} - - - + return ( + <> + + + ( + + {t("environments.settings.general.organization_name")} + + + - - - )} - /> + + + )} + /> - - -
+ + + + {!isOwner && ( + + + {t("environments.settings.general.only_org_owner_can_perform_action")} + + + )} + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/IndividualInviteTab.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/IndividualInviteTab.tsx index 5aefdfd93e..10adb8d21b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/IndividualInviteTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/IndividualInviteTab.tsx @@ -1,17 +1,19 @@ "use client"; +import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role"; +import { OrganizationRole } from "@prisma/client"; import { useTranslations } from "next-intl"; import { useForm } from "react-hook-form"; -import { AddMemberRole } from "@formbricks/ee/role-management/components/add-member-role"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { Alert, AlertDescription } from "@formbricks/ui/components/Alert"; import { Button } from "@formbricks/ui/components/Button"; import { Input } from "@formbricks/ui/components/Input"; import { Label } from "@formbricks/ui/components/Label"; import { UpgradePlanNotice } from "@formbricks/ui/components/UpgradePlanNotice"; -import { MembershipRole } from "./AddMemberModal"; interface IndividualInviteTabProps { setOpen: (v: boolean) => void; - onSubmit: (data: { name: string; email: string; role: MembershipRole }[]) => void; + onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void; canDoRoleManagement: boolean; isFormbricksCloud: boolean; environmentId: string; @@ -30,16 +32,17 @@ export const IndividualInviteTab = ({ handleSubmit, reset, control, + watch, formState: { isSubmitting }, } = useForm<{ name: string; email: string; - role: MembershipRole; + role: TOrganizationRole; }>(); const submitEventClass = async () => { const data = getValues(); - data.role = data.role || MembershipRole.Admin; + data.role = data.role || OrganizationRole.owner; await onSubmit([data]); setOpen(false); reset(); @@ -66,7 +69,18 @@ export const IndividualInviteTab = ({ />
- + + {watch("role") === "member" && ( + + + {t("environments.settings.general.member_role_info_message")} + + + )} {!canDoRoleManagement && (isFormbricksCloud ? ( => + cache( + async () => { + validateInputs([organizationId, ZString], [page, ZOptionalNumber]); + + try { + const membersData = await prisma.membership.findMany({ + where: { organizationId }, + select: { + user: { + select: { + name: true, + email: true, + }, + }, + userId: true, + accepted: true, + role: true, + }, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + + const members = membersData.map((member) => { + return { + name: member.user?.name || "", + email: member.user?.email || "", + userId: member.userId, + accepted: member.accepted, + role: member.role, + }; + }); + + return members; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw new UnknownError("Error while fetching members"); + } + }, + [`getMembersByOrganizationId-${organizationId}-${page}`], + { + tags: [membershipCache.tag.byOrganizationId(organizationId)], + } + )() +); + +export const deleteMembership = async ( + userId: string, + organizationId: string +): Promise< + { + userId: string; + role: "admin" | "contributor"; + teamId: string; + }[] +> => { + validateInputs([userId, ZString], [organizationId, ZString]); + + try { + const deletedTeamMemberships = await prisma.teamUser.findMany({ + where: { + userId, + team: { + organizationId, + }, + }, + }); + + await prisma.$transaction([ + prisma.teamUser.deleteMany({ + where: { + userId, + team: { + organizationId, + }, + }, + }), + prisma.membership.delete({ + where: { + userId_organizationId: { + organizationId, + userId, + }, + }, + }), + ]); + + teamCache.revalidate({ + userId, + organizationId, + }); + + deletedTeamMemberships.forEach((teamMembership) => { + teamCache.revalidate({ + id: teamMembership.teamId, + }); + }); + + organizationCache.revalidate({ + userId, + }); + + membershipCache.revalidate({ + userId, + organizationId, + }); + + return deletedTeamMemberships; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getMembershipsByUserId = reactCache( + (userId: string, page?: number): Promise => + cache( + async () => { + validateInputs([userId, ZString], [page, ZOptionalNumber]); + + try { + const memberships = await prisma.membership.findMany({ + where: { + userId, + }, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + + return memberships; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getMembershipsByUserId-${userId}-${page}`], + { + tags: [membershipCache.tag.byUserId(userId)], + } + )() +); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx index c8be2a8366..6297929b6f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx @@ -1,6 +1,7 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle"; import { OrganizationActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/OrganizationActions"; +import { getMembershipsByUserId } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/lib/membership"; import { getIsOrganizationAIReady } from "@/app/lib/utils"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; @@ -8,10 +9,7 @@ import { Suspense } from "react"; import { getIsMultiOrgEnabled, getRoleManagementPermission } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { - getMembershipByUserIdOrganizationId, - getMembershipsByUserId, -} from "@formbricks/lib/membership/service"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; @@ -46,7 +44,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { const canDoRoleManagement = await getRoleManagementPermission(organization); const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - const { isOwner, isAdmin } = getAccessFlags(currentUserMembership?.role); + const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role); const userMemberships = await getMembershipsByUserId(session.user.id); const isMultiOrgEnabled = await getIsMultiOrgEnabled(); @@ -54,7 +52,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { const currentUserRole = currentUserMembership?.role; const isLeaveOrganizationDisabled = userMemberships.length <= 1; - const isUserAdminOrOwner = isAdmin || isOwner; + const isUserManagerOrOwner = isManager || isOwner; const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan); @@ -66,6 +64,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => { isFormbricksCloud={IS_FORMBRICKS_CLOUD} membershipRole={currentUserMembership?.role} activeId="general" + canDoRoleManagement={canDoRoleManagement} /> { {currentUserRole && ( { )} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/[teamId]/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/[teamId]/page.tsx new file mode 100644 index 0000000000..11b312297d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/[teamId]/page.tsx @@ -0,0 +1,3 @@ +import { TeamDetails } from "@/modules/ee/teams/team-details/page"; + +export default TeamDetails; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.tsx new file mode 100644 index 0000000000..71fbbabb97 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.tsx @@ -0,0 +1,3 @@ +import { TeamsPage } from "@/modules/ee/teams/team-list/page"; + +export default TeamsPage; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts index 1502d6d668..f485ac7529 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts @@ -1,11 +1,11 @@ "use server"; import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromSurveyId, getProductIdFromSurveyId } from "@/lib/utils/helper"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromSurveyId } from "@formbricks/lib/organization/utils"; import { getResponseCountBySurveyId, getResponses } from "@formbricks/lib/response/service"; import { ZId } from "@formbricks/types/common"; import { ZResponseFilterCriteria } from "@formbricks/types/responses"; @@ -25,10 +25,22 @@ const ZGetResponsesAction = z.object({ export const getResponsesAction = authenticatedActionClient .schema(ZGetResponsesAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["response", "read"], + access: [ + { + type: "organization", + schema: ZResponseFilterCriteria, + data: parsedInput.filterCriteria, + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "read", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); return getResponses( @@ -47,10 +59,22 @@ const ZGetSurveySummaryAction = z.object({ export const getSurveySummaryAction = authenticatedActionClient .schema(ZGetSurveySummaryAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["response", "read"], + access: [ + { + type: "organization", + schema: ZResponseFilterCriteria, + data: parsedInput.filterCriteria, + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "read", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria); @@ -64,10 +88,22 @@ const ZGetResponseCountAction = z.object({ export const getResponseCountAction = authenticatedActionClient .schema(ZGetResponseCountAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["response", "read"], + access: [ + { + type: "organization", + schema: ZResponseFilterCriteria, + data: parsedInput.filterCriteria, + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "read", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria); @@ -80,10 +116,22 @@ const ZGenerateInsightsForSurveyAction = z.object({ export const generateInsightsForSurveyAction = authenticatedActionClient .schema(ZGenerateInsightsForSurveyAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "update"], + access: [ + { + type: "organization", + schema: ZGenerateInsightsForSurveyAction, + data: parsedInput, + roles: ["owner", "manager"], + }, + { + type: "productTeam", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + minPermission: "readWrite", + }, + ], }); generateInsightsForSurvey(parsedInput.surveyId); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx index cb66e9e842..78802e13b3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx @@ -1,3 +1,4 @@ +import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; import { ChevronLeft, ChevronRight, XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { TEnvironment } from "@formbricks/types/environment"; @@ -7,7 +8,6 @@ import { TTag } from "@formbricks/types/tags"; import { TUser, TUserLocale } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/components/Button"; import { Modal } from "@formbricks/ui/components/Modal"; -import { SingleResponseCard } from "@formbricks/ui/components/SingleResponseCard"; interface ResponseCardModalProps { responses: TResponse[]; @@ -19,7 +19,7 @@ interface ResponseCardModalProps { environmentTags: TTag[]; updateResponse: (responseId: string, updatedResponse: TResponse) => void; deleteResponses: (responseIds: string[]) => void; - isViewer: boolean; + isReadOnly: boolean; open: boolean; setOpen: React.Dispatch>; locale: TUserLocale; @@ -35,7 +35,7 @@ export const ResponseCardModal = ({ environmentTags, updateResponse, deleteResponses, - isViewer, + isReadOnly, open, setOpen, locale, @@ -107,7 +107,7 @@ export const ResponseCardModal = ({ pageType="response" environment={environment} environmentTags={environmentTags} - isViewer={isViewer} + isReadOnly={isReadOnly} updateResponse={updateResponse} deleteResponses={deleteResponses} setSelectedResponseId={setSelectedResponseId} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx index 94cb07c69f..5b7ccba4bb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx @@ -13,7 +13,7 @@ interface ResponseDataViewProps { user?: TUser; environment: TEnvironment; environmentTags: TTag[]; - isViewer: boolean; + isReadOnly: boolean; fetchNextPage: () => void; hasMore: boolean; deleteResponses: (responseIds: string[]) => void; @@ -104,7 +104,7 @@ export const ResponseDataView: React.FC = ({ user, environment, environmentTags, - isViewer, + isReadOnly, fetchNextPage, hasMore, deleteResponses, @@ -123,7 +123,7 @@ export const ResponseDataView: React.FC = ({ responses={responses} user={user} environmentTags={environmentTags} - isViewer={isViewer} + isReadOnly={isReadOnly} environment={environment} fetchNextPage={fetchNextPage} hasMore={hasMore} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index b38b7423a5..57214a1e3e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -30,6 +30,7 @@ interface ResponsePageProps { environmentTags: TTag[]; responsesPerPage: number; locale: TUserLocale; + isReadOnly: boolean; } export const ResponsePage = ({ @@ -41,6 +42,7 @@ export const ResponsePage = ({ environmentTags, responsesPerPage, locale, + isReadOnly, }: ResponsePageProps) => { const params = useParams(); const sharingKey = params.sharingKey as string; @@ -181,7 +183,7 @@ export const ResponsePage = ({ <>
- {!isSharingPage && } + {!isReadOnly && !isSharingPage && }
void; hasMore: boolean; deleteResponses: (responseIds: string[]) => void; @@ -55,7 +56,7 @@ export const ResponseTable = ({ user, environment, environmentTags, - isViewer, + isReadOnly, fetchNextPage, hasMore, deleteResponses, @@ -74,7 +75,7 @@ export const ResponseTable = ({ const [parent] = useAutoAnimate(); // Generate columns - const columns = generateResponseTableColumns(survey, isExpanded ?? false, isViewer, t); + const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t); // Load saved settings from localStorage useEffect(() => { @@ -171,6 +172,10 @@ export const ResponseTable = ({ } }; + const deleteResponse = async (responseId: string) => { + await deleteResponseAction({ responseId }); + }; + return (
@@ -260,7 +266,7 @@ export const ResponseTable = ({ user={user} environment={environment} environmentTags={environmentTags} - isViewer={isViewer} + isReadOnly={isReadOnly} updateResponse={updateResponse} deleteResponses={deleteResponses} setSelectedResponseId={setSelectedResponseId} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index c7fbe3e22e..1874fa22f0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -1,5 +1,6 @@ "use client"; +import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { ColumnDef } from "@tanstack/react-table"; import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react"; import Link from "next/link"; @@ -12,7 +13,6 @@ import { TResponseTableData } from "@formbricks/types/responses"; import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; import { getSelectionColumn } from "@formbricks/ui/components/DataTable"; import { ResponseBadges } from "@formbricks/ui/components/ResponseBadges"; -import { RenderResponse } from "@formbricks/ui/components/SingleResponseCard/components/RenderResponse"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/components/Tooltip"; const getAddressFieldLabel = (field: string, t: (key: string) => string) => { @@ -168,7 +168,7 @@ const getQuestionColumnsData = ( export const generateResponseTableColumns = ( survey: TSurvey, isExpanded: boolean, - isViewer: boolean, + isReadOnly: boolean, t: (key: string) => string ): ColumnDef[] => { const questionColumns = survey.questions.flatMap((question) => @@ -349,5 +349,5 @@ export const generateResponseTableColumns = ( notesColumn, ]; - return isViewer ? baseColumns : [getSelectionColumn(), ...baseColumns]; + return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns]; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx index c366e6219c..5f43ed17c7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx @@ -4,6 +4,8 @@ import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/s import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { getIsAIEnabled } from "@/app/lib/utils"; +import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getServerSession } from "next-auth"; import { getTranslations } from "next-intl/server"; import { authOptions } from "@formbricks/lib/authOptions"; @@ -61,7 +63,12 @@ const Page = async ({ params }) => { const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const totalResponseCount = await getResponseCountBySurveyId(params.surveyId); - const { isViewer } = getAccessFlags(currentUserMembership?.role); + const { isMember } = getAccessFlags(currentUserMembership?.role); + + const permission = await getProductPermissionByUserId(session.user.id, product.id); + const { hasReadAccess } = getTeamPermissionFlags(permission); + + const isReadOnly = isMember && hasReadAccess; const isAIEnabled = await getIsAIEnabled(organization); const shouldGenerateInsights = needsInsightsGeneration(survey); @@ -75,7 +82,7 @@ const Page = async ({ params }) => { @@ -104,6 +111,7 @@ const Page = async ({ params }) => { user={user} responsesPerPage={RESPONSES_PER_PAGE} locale={locale} + isReadOnly={isReadOnly} /> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts index 170a31a247..27c8651f91 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -1,12 +1,12 @@ "use server"; import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromSurveyId, getProductIdFromSurveyId } from "@/lib/utils/helper"; +import { sendEmbedSurveyPreviewEmail } from "@/modules/email"; import { customAlphabet } from "nanoid"; import { z } from "zod"; -import { sendEmbedSurveyPreviewEmail } from "@formbricks/email"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromSurveyId } from "@formbricks/lib/organization/utils"; import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; import { ZId } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; @@ -18,10 +18,20 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient .schema(ZSendEmbedSurveyPreviewEmailAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "read", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); const survey = await getSurvey(parsedInput.surveyId); @@ -51,16 +61,20 @@ const ZGenerateResultShareUrlAction = z.object({ export const generateResultShareUrlAction = authenticatedActionClient .schema(ZGenerateResultShareUrlAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["response", "update"], - }); - - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); const survey = await getSurvey(parsedInput.surveyId); @@ -85,10 +99,20 @@ const ZGetResultShareUrlAction = z.object({ export const getResultShareUrlAction = authenticatedActionClient .schema(ZGetResultShareUrlAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["response", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + minPermission: "readWrite", + }, + ], }); const survey = await getSurvey(parsedInput.surveyId); @@ -106,16 +130,20 @@ const ZDeleteResultShareUrlAction = z.object({ export const deleteResultShareUrlAction = authenticatedActionClient .schema(ZDeleteResultShareUrlAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["response", "update"], - }); - - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "update"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); const survey = await getSurvey(parsedInput.surveyId); @@ -133,10 +161,20 @@ const ZGetEmailHtmlAction = z.object({ export const getEmailHtmlAction = authenticatedActionClient .schema(ZGetEmailHtmlAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromSurveyId(parsedInput.surveyId), + }, + ], }); return await getEmailTemplateHtml(parsedInput.surveyId); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx index 38bbed9bd3..f1a7e8c425 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx @@ -1,5 +1,6 @@ "use client"; +import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; import { BellRing, BlocksIcon, @@ -17,7 +18,6 @@ import { TSurvey } from "@formbricks/types/surveys/types"; import { TUser } from "@formbricks/types/user"; import { Badge } from "@formbricks/ui/components/Badge"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@formbricks/ui/components/Dialog"; -import { ShareSurveyLink } from "@formbricks/ui/components/ShareSurveyLink"; import { EmbedView } from "./shareEmbedModal/EmbedView"; import { PanelInfoView } from "./shareEmbedModal/PanelInfoView"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx index 5449af1702..3312d5cc4f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx @@ -51,6 +51,7 @@ interface SummaryPageProps { isAIEnabled: boolean; documentsPerPage?: number; locale: TUserLocale; + isReadOnly: boolean; } export const SummaryPage = ({ @@ -63,6 +64,7 @@ export const SummaryPage = ({ isAIEnabled, documentsPerPage, locale, + isReadOnly, }: SummaryPageProps) => { const params = useParams(); const sharingKey = params.sharingKey as string; @@ -172,7 +174,9 @@ export const SummaryPage = ({ {showDropOffs && }
- {!isSharingPage && } + {!isReadOnly && !isSharingPage && ( + + )}
{ @@ -120,11 +120,11 @@ export const SurveyAnalysisCTA = ({ /> )} - {(widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && ( + {!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && ( )} - {!isViewer && ( + {!isReadOnly && ( + + {!disableRole && ( + + { + handleRoleChange(value.toLowerCase() as TOrganizationRole); + }} + value={capitalizeFirstLetter(memberRole)}> + {getMembershipRoles().map((role) => ( + + {role.toLowerCase()} + + ))} + + + )} + + ); + } + + return ; +} diff --git a/apps/web/modules/ee/role-management/lib/membership.ts b/apps/web/modules/ee/role-management/lib/membership.ts new file mode 100644 index 0000000000..e03533e0e3 --- /dev/null +++ b/apps/web/modules/ee/role-management/lib/membership.ts @@ -0,0 +1,89 @@ +import "server-only"; +import { membershipCache } from "@/lib/cache/membership"; +import { teamCache } from "@/lib/cache/team"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { organizationCache } from "@formbricks/lib/organization/cache"; +import { productCache } from "@formbricks/lib/product/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZString } from "@formbricks/types/common"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { TMembership, TMembershipUpdateInput, ZMembershipUpdateInput } from "@formbricks/types/memberships"; + +export const updateMembership = async ( + userId: string, + organizationId: string, + data: TMembershipUpdateInput +): Promise => { + validateInputs([userId, ZString], [organizationId, ZString], [data, ZMembershipUpdateInput]); + + try { + const membership = await prisma.membership.update({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + data, + }); + + const teamMemberships = await prisma.teamUser.findMany({ + where: { + userId, + team: { + organizationId, + }, + }, + select: { + teamId: true, + }, + }); + + if (data.role === "owner" || data.role === "manager") { + await prisma.teamUser.updateMany({ + where: { + userId, + team: { + organizationId, + }, + }, + data: { + role: "admin", + }, + }); + } + + teamCache.revalidate({ + userId, + organizationId, + }); + + teamMemberships.forEach((teamMembership) => { + teamCache.revalidate({ + id: teamMembership.teamId, + }); + }); + + organizationCache.revalidate({ + userId, + }); + + membershipCache.revalidate({ + userId, + organizationId, + }); + + productCache.revalidate({ + userId, + }); + + return membership; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") { + throw new ResourceNotFoundError("Membership", `userId: ${userId}, organizationId: ${organizationId}`); + } + + throw error; + } +}; diff --git a/apps/web/modules/ee/teams/lib/roles.ts b/apps/web/modules/ee/teams/lib/roles.ts new file mode 100644 index 0000000000..1b94c032fc --- /dev/null +++ b/apps/web/modules/ee/teams/lib/roles.ts @@ -0,0 +1,101 @@ +import "server-only"; +import { teamCache } from "@/lib/cache/team"; +import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { TTeamRole } from "@/modules/ee/teams/team-list/types/teams"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { membershipCache } from "@formbricks/lib/membership/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId, ZString } from "@formbricks/types/common"; +import { DatabaseError, UnknownError } from "@formbricks/types/errors"; + +export const getProductPermissionByUserId = reactCache( + (userId: string, productId: string): Promise => + cache( + async () => { + validateInputs([userId, ZString], [productId, ZString]); + + try { + const productMemberships = await prisma.productTeam.findMany({ + where: { + productId, + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }); + + if (!productMemberships) return null; + let highestPermission: TTeamPermission | null = null; + + for (const membership of productMemberships) { + if (membership.permission === "manage") { + highestPermission = "manage"; + } else if (membership.permission === "readWrite" && highestPermission !== "manage") { + highestPermission = "readWrite"; + } else if ( + membership.permission === "read" && + highestPermission !== "manage" && + highestPermission !== "readWrite" + ) { + highestPermission = "read"; + } + } + + return highestPermission; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw new UnknownError("Error while fetching membership"); + } + }, + [`getProductPermissionByUserId-${userId}-${productId}`], + { + tags: [teamCache.tag.byUserId(userId), teamCache.tag.byProductId(productId)], + } + )() +); + +export const getTeamRoleByTeamIdUserId = reactCache( + (teamId: string, userId: string): Promise => + cache( + async () => { + validateInputs([teamId, ZId], [userId, ZId]); + try { + const teamUser = await prisma.teamUser.findUnique({ + where: { + teamId_userId: { + teamId, + userId, + }, + }, + }); + + if (!teamUser) { + return null; + } + + return teamUser.role; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getTeamMembershipByTeamIdUserId-${teamId}-${userId}`], + { + tags: [teamCache.tag.byId(teamId), membershipCache.tag.byUserId(userId)], + } + )() +); diff --git a/apps/web/modules/ee/teams/product-teams/actions.ts b/apps/web/modules/ee/teams/product-teams/actions.ts new file mode 100644 index 0000000000..22557c1007 --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/actions.ts @@ -0,0 +1,98 @@ +"use server"; + +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromProductId, getOrganizationIdFromTeamId } from "@/lib/utils/helper"; +import { + addTeamAccess, + removeTeamAccess, + updateTeamAccessPermission, +} from "@/modules/ee/teams/product-teams/lib/teams"; +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; +import { ZTeamPermission } from "./types/teams"; + +const ZRemoveAccessAction = z.object({ + productId: z.string(), + teamId: z.string(), +}); + +export const removeAccessAction = authenticatedActionClient + .schema(ZRemoveAccessAction) + .action(async ({ ctx, parsedInput }) => { + const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId); + const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); + + if (productOrganizationId !== teamOrganizationId) { + throw new Error("Team and product are not in the same organization"); + } + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: productOrganizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await removeTeamAccess(parsedInput.productId, parsedInput.teamId); + }); + +const ZAddAccessAction = z.object({ + productId: z.string(), + teamIds: z.array(ZId), +}); + +export const addAccessAction = authenticatedActionClient + .schema(ZAddAccessAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromProductId(parsedInput.productId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await addTeamAccess(parsedInput.productId, parsedInput.teamIds); + }); + +const ZUpdateAccessPermissionAction = z.object({ + productId: z.string(), + teamId: z.string(), + permission: ZTeamPermission, +}); + +export const updateAccessPermissionAction = authenticatedActionClient + .schema(ZUpdateAccessPermissionAction) + .action(async ({ ctx, parsedInput }) => { + const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId); + const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); + + if (productOrganizationId !== teamOrganizationId) { + throw new Error("Team and product are not in the same organization"); + } + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: productOrganizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await updateTeamAccessPermission( + parsedInput.productId, + parsedInput.teamId, + parsedInput.permission + ); + }); diff --git a/apps/web/modules/ee/teams/product-teams/components/access-table.tsx b/apps/web/modules/ee/teams/product-teams/components/access-table.tsx new file mode 100644 index 0000000000..dc5cb36e9b --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/components/access-table.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { removeAccessAction, updateAccessPermissionAction } from "@/modules/ee/teams/product-teams/actions"; +import { TProductTeam, TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { AlertDialog } from "@formbricks/ui/components/AlertDialog"; +import { Button } from "@formbricks/ui/components/Button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@formbricks/ui/components/Select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@formbricks/ui/components/Table"; + +interface AccessTableProps { + teams: TProductTeam[]; + environmentId: string; + productId: string; + isOwnerOrManager: boolean; +} + +export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager }: AccessTableProps) => { + const t = useTranslations(); + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [removeAccessModalOpen, setRemoveAccessModalOpen] = useState(false); + + const router = useRouter(); + + const removeAccess = async (teamId: string) => { + const removeAccessActionResponse = await removeAccessAction({ productId, teamId }); + if (removeAccessActionResponse?.data) { + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(removeAccessActionResponse); + toast.error(errorMessage); + } + }; + + const handlePermissionChange = async (teamId: string, permission: TTeamPermission) => { + const updateAccessPermissionActionResponse = await updateAccessPermissionAction({ + productId, + teamId, + permission, + }); + if (updateAccessPermissionActionResponse?.data) { + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(updateAccessPermissionActionResponse); + toast.error(errorMessage); + } + }; + + const handleRemoveAccess = (teamId: string) => { + removeAccess(teamId); + setRemoveAccessModalOpen(false); + setSelectedTeamId(null); + }; + + return ( + <> +
+
+ + + {t("environments.product.teams.team_name")} + {t("environments.product.teams.permission")} + {isOwnerOrManager && Actions} + + + + {teams.length === 0 && ( + + + {t("environments.product.teams.no_teams_found")} + + + )} + {teams.map((team) => ( + + + {isOwnerOrManager ? ( + {team.name} + ) : ( + team.name + )} + ({team.memberCount} members) + + + {isOwnerOrManager ? ( + + ) : ( +

{TeamPermissionMapping[team.permission]}

+ )} +
+ {isOwnerOrManager && ( + + + + )} +
+ ))} +
+
+
+ + {removeAccessModalOpen && selectedTeamId && ( + { + setSelectedTeamId(null); + setRemoveAccessModalOpen(false); + }} + onConfirm={() => handleRemoveAccess(selectedTeamId)} + /> + )} + + ); +}; diff --git a/apps/web/modules/ee/teams/product-teams/components/access-view.tsx b/apps/web/modules/ee/teams/product-teams/components/access-view.tsx new file mode 100644 index 0000000000..cd841aa85d --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/components/access-view.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { AccessTable } from "@/modules/ee/teams/product-teams/components/access-table"; +import { AddTeam } from "@/modules/ee/teams/product-teams/components/add-team"; +import { TOrganizationTeam, TProductTeam } from "@/modules/ee/teams/product-teams/types/teams"; +import { useTranslations } from "next-intl"; +import { TProduct } from "@formbricks/types/product"; + +interface AccessViewProps { + product: TProduct; + teams: TProductTeam[]; + environmentId: string; + organizationTeams: TOrganizationTeam[]; + isOwnerOrManager: boolean; +} + +export const AccessView = ({ + product, + teams, + organizationTeams, + environmentId, + isOwnerOrManager, +}: AccessViewProps) => { + const t = useTranslations(); + return ( + <> + +
+ {isOwnerOrManager && ( + + )} +
+
+ +
+
+ + ); +}; diff --git a/apps/web/modules/ee/teams/product-teams/components/add-team-modal.tsx b/apps/web/modules/ee/teams/product-teams/components/add-team-modal.tsx new file mode 100644 index 0000000000..b7cbe7ebb6 --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/components/add-team-modal.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { addAccessAction } from "@/modules/ee/teams/product-teams/actions"; +import { UsersIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { Button } from "@formbricks/ui/components/Button"; +import { Label } from "@formbricks/ui/components/Label"; +import { Modal } from "@formbricks/ui/components/Modal"; +import { MultiSelect } from "@formbricks/ui/components/MultiSelect"; +import { H4 } from "@formbricks/ui/components/Typography"; + +interface AddTeamModalProps { + open: boolean; + setOpen: React.Dispatch>; + teamOptions: { label: string; value: string }[]; + productId: string; +} + +export const AddTeamModal = ({ open, setOpen, teamOptions, productId }: AddTeamModalProps) => { + const t = useTranslations(); + const [isLoading, setIsLoading] = useState(false); + const [selectedTeams, setSelectedTeams] = useState([]); + + const router = useRouter(); + + const handleAddTeam = async (e) => { + e.preventDefault(); + + setIsLoading(true); + const addTeamActionResponse = await addAccessAction({ productId, teamIds: selectedTeams }); + + if (addTeamActionResponse?.data) { + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(addTeamActionResponse); + toast.error(errorMessage); + } + setSelectedTeams([]); + setIsLoading(false); + setOpen(false); + }; + + return ( + +
+
+
+ +

{t("environments.product.teams.add_existing_team")}

+
+
+
+
+
+ + { + setSelectedTeams(value); + }} + /> +
+
+ + +
+
+
+ ); +}; diff --git a/apps/web/modules/ee/teams/product-teams/components/add-team.tsx b/apps/web/modules/ee/teams/product-teams/components/add-team.tsx new file mode 100644 index 0000000000..e76c85b944 --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/components/add-team.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { addAccessAction } from "@/modules/ee/teams/product-teams/actions"; +import { AddTeamModal } from "@/modules/ee/teams/product-teams/components/add-team-modal"; +import { TOrganizationTeam, TProductTeam } from "@/modules/ee/teams/product-teams/types/teams"; +import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Button } from "@formbricks/ui/components/Button"; + +interface AddTeamProps { + organizationTeams: TOrganizationTeam[]; + productTeams: TProductTeam[]; + productId: string; + organizationId: string; +} + +export const AddTeam = ({ organizationTeams, productTeams, productId, organizationId }: AddTeamProps) => { + const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); + const [addTeamModalOpen, setAddTeamModalOpen] = useState(false); + const t = useTranslations(); + + const teams = organizationTeams + .filter((team) => !productTeams.find((productTeam) => productTeam.id === team.id)) + .map((team) => ({ label: team.name, value: team.id })); + + const onCreate = async (teamId: string) => { + await addAccessAction({ productId, teamIds: [teamId] }); + }; + + return ( + <> + + + {addTeamModalOpen && ( + + )} + + {createTeamModalOpen && ( + + )} + + ); +}; diff --git a/apps/web/modules/ee/teams/product-teams/lib/teams.ts b/apps/web/modules/ee/teams/product-teams/lib/teams.ts new file mode 100644 index 0000000000..16363ff868 --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/lib/teams.ts @@ -0,0 +1,277 @@ +import "server-only"; +import { teamCache } from "@/lib/cache/team"; +import { + TOrganizationTeam, + TProductTeam, + TTeamPermission, + ZTeamPermission, +} from "@/modules/ee/teams/product-teams/types/teams"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { productCache } from "@formbricks/lib/product/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId } from "@formbricks/types/common"; +import { AuthorizationError, DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; + +export const getTeamsByProductId = reactCache( + (productId: string): Promise => + cache( + async () => { + validateInputs([productId, ZId]); + try { + const product = await prisma.product.findUnique({ + where: { + id: productId, + }, + }); + + if (!product) { + throw new ResourceNotFoundError("Product", productId); + } + + const teams = await prisma.team.findMany({ + where: { + productTeams: { + some: { + productId, + }, + }, + }, + select: { + id: true, + name: true, + productTeams: { + where: { + productId, + }, + select: { + permission: true, + }, + }, + _count: { + select: { + teamUsers: true, + }, + }, + }, + }); + + const productTeams = teams.map((team) => ({ + id: team.id, + name: team.name, + permission: team.productTeams[0].permission, + memberCount: team._count.teamUsers, + })); + + return productTeams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getTeamsByProductId-${productId}`], + { + tags: [teamCache.tag.byProductId(productId), productCache.tag.byId(productId)], + } + )() +); + +export const removeTeamAccess = async (productId: string, teamId: string): Promise => { + validateInputs([productId, ZId], [teamId, ZId]); + try { + const productMembership = await prisma.productTeam.findFirst({ + where: { + productId, + teamId, + }, + }); + + if (!productMembership) { + throw new AuthorizationError("Team does not have access to this product"); + } + + await prisma.productTeam.deleteMany({ + where: { + productId, + teamId, + }, + }); + + teamCache.revalidate({ + id: teamId, + productId, + }); + productCache.revalidate({ + id: productId, + }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const addTeamAccess = async (productId: string, teamIds: string[]): Promise => { + validateInputs([productId, ZId], [teamIds, z.array(ZId)]); + try { + const product = await prisma.product.findUnique({ + where: { + id: productId, + }, + }); + + if (!product) { + throw new ResourceNotFoundError("Product", productId); + } + + for (let teamId of teamIds) { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + organizationId: product.organizationId, + }, + }); + + if (!team) { + throw new ResourceNotFoundError("Team", teamId); + } + + const productTeam = await prisma.productTeam.findFirst({ + where: { + productId, + teamId, + }, + }); + + if (productTeam) { + throw new AuthorizationError("Teams already have access to this product"); + } + + await prisma.productTeam.create({ + data: { + productId, + teamId, + }, + }); + } + + teamCache.revalidate({ + productId, + }); + productCache.revalidate({ + id: productId, + }); + + teamIds.forEach((teamId) => { + teamCache.revalidate({ + id: teamId, + }); + }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getTeamsByOrganizationId = reactCache( + (organizationId: string): Promise => + cache( + async () => { + validateInputs([organizationId, ZId]); + try { + const teams = await prisma.team.findMany({ + where: { + organizationId, + }, + select: { + id: true, + name: true, + }, + }); + + const productTeams = teams.map((team) => ({ + id: team.id, + name: team.name, + })); + + return productTeams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getTeamsByOrganizationId-${organizationId}`], + { + tags: [teamCache.tag.byOrganizationId(organizationId)], + } + )() +); + +export const updateTeamAccessPermission = async ( + productId: string, + teamId: string, + permission: TTeamPermission +): Promise => { + validateInputs([productId, ZId], [teamId, ZId], [permission, ZTeamPermission]); + try { + const productMembership = await prisma.productTeam.findUniqueOrThrow({ + where: { + productId_teamId: { + productId, + teamId, + }, + }, + }); + + if (!productMembership) { + throw new AuthorizationError("Team does not have access to this product"); + } + + await prisma.productTeam.update({ + where: { + productId_teamId: { + productId, + teamId, + }, + }, + data: { + permission, + }, + }); + + teamCache.revalidate({ + id: teamId, + productId, + }); + + productCache.revalidate({ + id: productId, + }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/modules/ee/teams/product-teams/page.tsx b/apps/web/modules/ee/teams/product-teams/page.tsx new file mode 100644 index 0000000000..f46a918982 --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/page.tsx @@ -0,0 +1,70 @@ +import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation"; +import { AccessView } from "@/modules/ee/teams/product-teams/components/access-view"; +import { getServerSession } from "next-auth"; +import { getMultiLanguagePermission, getRoleManagementPermission } from "@formbricks/ee/lib/service"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; +import { PageHeader } from "@formbricks/ui/components/PageHeader"; +import { getTeamsByOrganizationId, getTeamsByProductId } from "./lib/teams"; + +export const ProductTeams = async ({ params }: { params: { environmentId: string } }) => { + const [product, session, organization] = await Promise.all([ + getProductByEnvironmentId(params.environmentId), + getServerSession(authOptions), + getOrganizationByEnvironmentId(params.environmentId), + ]); + + if (!product) { + throw new Error("Product not found"); + } + if (!session) { + throw new Error("Unauthorized"); + } + if (!organization) { + throw new Error("Organization not found"); + } + + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role); + + const isMultiLanguageAllowed = await getMultiLanguagePermission(organization); + const canDoRoleManagement = await getRoleManagementPermission(organization); + + const teams = await getTeamsByProductId(product.id); + + if (!teams) { + throw new Error("Teams not found"); + } + + const organizationTeams = await getTeamsByOrganizationId(organization.id); + + if (!organizationTeams) { + throw new Error("Organization Teams not found"); + } + + const isOwnerOrManager = isOwner || isManager; + + return ( + + + + + + + ); +}; diff --git a/apps/web/modules/ee/teams/product-teams/types/teams.ts b/apps/web/modules/ee/teams/product-teams/types/teams.ts new file mode 100644 index 0000000000..94127727b3 --- /dev/null +++ b/apps/web/modules/ee/teams/product-teams/types/teams.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; + +export const ZTeamPermission = z.enum(["read", "readWrite", "manage"]); +export type TTeamPermission = z.infer; + +export const ZProductTeam = z.object({ + id: ZId, + name: z.string(), + memberCount: z.number(), + permission: ZTeamPermission, +}); + +export type TProductTeam = z.infer; + +export const TOrganizationTeam = z.object({ + id: ZId, + name: z.string(), +}); + +export type TOrganizationTeam = z.infer; diff --git a/apps/web/modules/ee/teams/team-details/actions.ts b/apps/web/modules/ee/teams/team-details/actions.ts new file mode 100644 index 0000000000..f37d9bf0f6 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/actions.ts @@ -0,0 +1,243 @@ +"use server"; + +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromProductId, getOrganizationIdFromTeamId } from "@/lib/utils/helper"; +import { ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { + addTeamMembers, + addTeamProducts, + deleteTeam, + removeTeamMember, + removeTeamProduct, + updateTeamName, + updateTeamProductPermission, + updateUserTeamRole, +} from "@/modules/ee/teams/team-details/lib/teams"; +import { ZTeamRole } from "@/modules/ee/teams/team-list/types/teams"; +import { z } from "zod"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { ZId } from "@formbricks/types/common"; +import { ZTeam } from "./types/teams"; + +const ZUpdateTeamNameAction = z.object({ + name: ZTeam.shape.name, + teamId: z.string(), +}); + +export const updateTeamNameAction = authenticatedActionClient + .schema(ZUpdateTeamNameAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromTeamId(parsedInput.teamId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await updateTeamName(parsedInput.teamId, parsedInput.name); + }); + +const ZDeleteTeamAction = z.object({ + teamId: ZId, +}); + +export const deleteTeamAction = authenticatedActionClient + .schema(ZDeleteTeamAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromTeamId(parsedInput.teamId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await deleteTeam(parsedInput.teamId); + }); + +const ZUpdateUserTeamRoleAction = z.object({ + teamId: ZId, + userId: ZId, + role: ZTeamRole, +}); + +export const updateUserTeamRoleAction = authenticatedActionClient + .schema(ZUpdateUserTeamRoleAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromTeamId(parsedInput.teamId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "team", + teamId: parsedInput.teamId, + minPermission: "admin", + }, + ], + }); + + return await updateUserTeamRole(parsedInput.teamId, parsedInput.userId, parsedInput.role); + }); + +const ZRemoveTeamMemberAction = z.object({ + teamId: ZId, + userId: ZId, +}); + +export const removeTeamMemberAction = authenticatedActionClient + .schema(ZRemoveTeamMemberAction) + .action(async ({ ctx, parsedInput }) => { + const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); + const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, teamOrganizationId); + + const { isOwner, isManager } = getAccessFlags(membership?.role); + + const isOwnerOrManager = isOwner || isManager; + + if (!isOwnerOrManager && ctx.user.id === parsedInput.userId) { + throw new Error("You can not remove yourself from the team"); + } + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromTeamId(parsedInput.teamId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "team", + teamId: parsedInput.teamId, + minPermission: "admin", + }, + ], + }); + + return await removeTeamMember(parsedInput.teamId, parsedInput.userId); + }); + +const ZAddTeamMembersAction = z.object({ + teamId: ZId, + userIds: z.array(ZId), +}); + +export const addTeamMembersAction = authenticatedActionClient + .schema(ZAddTeamMembersAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromTeamId(parsedInput.teamId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "team", + teamId: parsedInput.teamId, + minPermission: "admin", + }, + ], + }); + + return await addTeamMembers(parsedInput.teamId, parsedInput.userIds); + }); + +const ZUpdateTeamProductPermissionAction = z.object({ + teamId: ZId, + productId: ZId, + permission: ZTeamPermission, +}); + +export const updateTeamProductPermissionAction = authenticatedActionClient + .schema(ZUpdateTeamProductPermissionAction) + .action(async ({ ctx, parsedInput }) => { + const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); + const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId); + + if (teamOrganizationId !== productOrganizationId) { + throw new Error("Team and Product must belong to the same organization"); + } + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: productOrganizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await updateTeamProductPermission( + parsedInput.teamId, + parsedInput.productId, + parsedInput.permission + ); + }); + +const ZRemoveTeamProductAction = z.object({ + teamId: ZId, + productId: ZId, +}); + +export const removeTeamProductAction = authenticatedActionClient + .schema(ZRemoveTeamProductAction) + .action(async ({ ctx, parsedInput }) => { + const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); + const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId); + + if (teamOrganizationId !== productOrganizationId) { + throw new Error("Team and Product must belong to the same organization"); + } + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: productOrganizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await removeTeamProduct(parsedInput.teamId, parsedInput.productId); + }); + +const ZAddTeamProductsAction = z.object({ + teamId: ZId, + productIds: z.array(ZId), +}); + +export const addTeamProductsAction = authenticatedActionClient + .schema(ZAddTeamProductsAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromTeamId(parsedInput.teamId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await addTeamProducts(parsedInput.teamId, parsedInput.productIds); + }); diff --git a/apps/web/modules/ee/teams/team-details/components/add-team-member-modal.tsx b/apps/web/modules/ee/teams/team-details/components/add-team-member-modal.tsx new file mode 100644 index 0000000000..8fcb45c19e --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/add-team-member-modal.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { addTeamMembersAction } from "@/modules/ee/teams/team-details/actions"; +import { UserIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { Button } from "@formbricks/ui/components/Button"; +import { Label } from "@formbricks/ui/components/Label"; +import { Modal } from "@formbricks/ui/components/Modal"; +import { MultiSelect } from "@formbricks/ui/components/MultiSelect"; +import { H4 } from "@formbricks/ui/components/Typography"; + +interface AddTeamMemberModalProps { + open: boolean; + setOpen: React.Dispatch>; + teamId: string; + organizationMemberOptions: { label: string; value: string }[]; +} + +export const AddTeamMemberModal = ({ + open, + setOpen, + teamId, + organizationMemberOptions, +}: AddTeamMemberModalProps) => { + const t = useTranslations(); + const [selectedUsers, setSelectedUsers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const router = useRouter(); + + const handleAddMembers = async (e) => { + e.preventDefault(); + + setIsLoading(true); + const addMembersActionResponse = await addTeamMembersAction({ + teamId, + userIds: selectedUsers, + }); + if (addMembersActionResponse?.data) { + toast.success(t("environments.settings.teams.members_added_successfully")); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(addMembersActionResponse); + toast.error(errorMessage); + } + + setSelectedUsers([]); + setIsLoading(false); + setOpen(false); + }; + + return ( + +
+
+
+ +

{t("environments.settings.teams.add_members")}

+
+
+
+
+
+ + { + setSelectedUsers(value); + }} + /> +
+
+ + +
+
+
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-details/components/add-team-product-modal.tsx b/apps/web/modules/ee/teams/team-details/components/add-team-product-modal.tsx new file mode 100644 index 0000000000..4449a7ad74 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/add-team-product-modal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { addTeamProductsAction } from "@/modules/ee/teams/team-details/actions"; +import { UserIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { Button } from "@formbricks/ui/components/Button"; +import { Label } from "@formbricks/ui/components/Label"; +import { Modal } from "@formbricks/ui/components/Modal"; +import { MultiSelect } from "@formbricks/ui/components/MultiSelect"; +import { H4 } from "@formbricks/ui/components/Typography"; + +interface AddTeamProductModalProps { + open: boolean; + setOpen: React.Dispatch>; + teamId: string; + productOptions: { label: string; value: string }[]; +} + +export const AddTeamProductModal = ({ open, setOpen, teamId, productOptions }: AddTeamProductModalProps) => { + const t = useTranslations(); + const [selectedProducts, setSelectedProducts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const router = useRouter(); + + const handleAddProducts = async (e) => { + e.preventDefault(); + + setIsLoading(true); + + const addMembersActionResponse = await addTeamProductsAction({ + teamId, + productIds: selectedProducts, + }); + if (addMembersActionResponse?.data) { + toast.success(t("environments.settings.teams.members_added_successfully")); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(addMembersActionResponse); + toast.error(errorMessage); + } + + setSelectedProducts([]); + setIsLoading(false); + setOpen(false); + }; + + return ( + +
+
+
+ +

{t("environments.settings.teams.add_products")}

+
+
+
+
+
+ + { + setSelectedProducts(value); + }} + /> +
+
+ + +
+
+
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-details/components/delete-team.tsx b/apps/web/modules/ee/teams/team-details/components/delete-team.tsx new file mode 100644 index 0000000000..7fc063bd89 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/delete-team.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { deleteTeamAction } from "@/modules/ee/teams/team-details/actions"; +import { TTeam } from "@/modules/ee/teams/team-details/types/teams"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { Button } from "@formbricks/ui/components/Button"; +import { DeleteDialog } from "@formbricks/ui/components/DeleteDialog"; + +interface DeleteTeamProps { + teamId: TTeam["id"]; + membershipRole?: TOrganizationRole; +} + +export const DeleteTeam = ({ teamId, membershipRole }: DeleteTeamProps) => { + const t = useTranslations(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const router = useRouter(); + + const handleDeleteTeam = async () => { + setIsDeleting(true); + + const deleteTeamActionResponse = await deleteTeamAction({ teamId }); + if (deleteTeamActionResponse?.data) { + toast.success(t("environments.settings.teams.team_deleted_successfully")); + router.push("./"); + } else { + toast.error(t("common.something_went_wrong_please_try_again")); + } + + setIsDeleteDialogOpen(false); + setIsDeleting(false); + }; + + const { isMember } = getAccessFlags(membershipRole); + + const isDeleteDisabled = isMember; + + return ( +
+ {isDeleteDisabled ? ( +

+ {t("common.only_organization_owners_and_managers_can_access_this_setting")} +

+ ) : ( +
+

+ {t("environments.settings.teams.this_action_cannot_be_undone_if_it_s_gone_it_s_gone")} +

+ +
+ )} + + {isDeleteDialogOpen && ( + + )} +
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-details/components/details-view.tsx b/apps/web/modules/ee/teams/team-details/components/details-view.tsx new file mode 100644 index 0000000000..606d9a1cb7 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/details-view.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { TeamMembers } from "@/modules/ee/teams/team-details/components/team-members"; +import { TeamProducts } from "@/modules/ee/teams/team-details/components/team-products"; +import { TeamSettings } from "@/modules/ee/teams/team-details/components/team-settings"; +import { + TOrganizationMember, + TOrganizationProduct, + TTeam, + TTeamProduct, +} from "@/modules/ee/teams/team-details/types/teams"; +import { TTeamRole } from "@/modules/ee/teams/team-list/types/teams"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { SecondaryNavigation } from "@formbricks/ui/components/SecondaryNavigation"; +import { H3 } from "@formbricks/ui/components/Typography"; + +interface DetailsViewProps { + team: TTeam; + userId: string; + membershipRole?: TOrganizationRole; + organizationMembers: TOrganizationMember[]; + teamRole: TTeamRole | null; + products: TTeamProduct[]; + organizationProducts: TOrganizationProduct[]; +} + +export const DetailsView = ({ + team, + userId, + membershipRole, + organizationMembers, + teamRole, + products, + organizationProducts, +}: DetailsViewProps) => { + const t = useTranslations(); + const [activeId, setActiveId] = useState<"members" | "settings" | "products">("members"); + + const navigation = [ + { + id: "members", + label: t("common.members"), + onClick: () => setActiveId("members"), + }, + { + id: "products", + label: t("common.products"), + onClick: () => setActiveId("products"), + }, + { + id: "settings", + label: t("common.settings"), + onClick: () => setActiveId("settings"), + }, + ]; + + return ( +
+

{team.name}

+ +
+ +
+ {activeId === "members" && ( + + )} + {activeId === "products" && ( + + )} + {activeId === "settings" && } +
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-details/components/edit-team-name-form.tsx b/apps/web/modules/ee/teams/team-details/components/edit-team-name-form.tsx new file mode 100644 index 0000000000..222ac2fe29 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/edit-team-name-form.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { updateTeamNameAction } from "@/modules/ee/teams/team-details/actions"; +import { TTeam, ZTeam } from "@/modules/ee/teams/team-details/types/teams"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { SubmitHandler, useForm } from "react-hook-form"; +import toast from "react-hot-toast"; +import { z } from "zod"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { Button } from "@formbricks/ui/components/Button"; +import { + FormControl, + FormError, + FormField, + FormItem, + FormLabel, + FormProvider, +} from "@formbricks/ui/components/Form"; +import { Input } from "@formbricks/ui/components/Input"; + +interface EditTeamNameProps { + team: TTeam; + membershipRole?: TOrganizationRole; +} + +const ZEditTeamNameFormSchema = ZTeam.pick({ name: true }); +type EditTeamNameForm = z.infer; + +export const EditTeamNameForm = ({ membershipRole, team }: EditTeamNameProps) => { + const t = useTranslations(); + const form = useForm({ + defaultValues: { + name: team.name, + }, + mode: "onChange", + resolver: zodResolver(ZEditTeamNameFormSchema), + }); + + const { isMember } = getAccessFlags(membershipRole); + + const { isSubmitting, isDirty } = form.formState; + + const handleUpdateTeamName: SubmitHandler = async (data) => { + try { + const name = data.name.trim(); + const updatedTeamResponse = await updateTeamNameAction({ + teamId: team.id, + name, + }); + + if (updatedTeamResponse?.data) { + toast.success("Team name updated successfully."); + form.reset({ name: updatedTeamResponse.data.name }); + } else { + const errorMessage = getFormattedErrorMessage(updatedTeamResponse); + toast.error(errorMessage); + } + } catch (err) { + toast.error(`Error: ${err.message}`); + } + }; + + return isMember ? ( +

+ {t("common.only_organization_owners_and_managers_can_access_this_setting")} +

+ ) : ( + +
+ ( + + {t("environments.settings.teams.team_name")} + + + + + + + )} + /> + + + +
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-details/components/team-members.tsx b/apps/web/modules/ee/teams/team-details/components/team-members.tsx new file mode 100644 index 0000000000..800b2b309f --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/team-members.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { removeTeamMemberAction, updateUserTeamRoleAction } from "@/modules/ee/teams/team-details/actions"; +import { AddTeamMemberModal } from "@/modules/ee/teams/team-details/components/add-team-member-modal"; +import { TOrganizationMember, TTeamMember } from "@/modules/ee/teams/team-details/types/teams"; +import { TTeamRole, ZTeamRole } from "@/modules/ee/teams/team-list/types/teams"; +import { TeamRoleMapping, getTeamAccessFlags } from "@/modules/ee/teams/utils/teams"; +import { InfoIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { AlertDialog } from "@formbricks/ui/components/AlertDialog"; +import { Button } from "@formbricks/ui/components/Button"; +import { Card, CardContent, CardHeader, CardTitle } from "@formbricks/ui/components/Card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@formbricks/ui/components/Select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@formbricks/ui/components/Table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/components/Tooltip"; + +interface TeamMembersProps { + members: TTeamMember[]; + currentUserId: string; + teamId: string; + organizationMembers: TOrganizationMember[]; + membershipRole?: TOrganizationRole; + teamRole: TTeamRole | null; +} + +export const TeamMembers = ({ + members, + currentUserId, + teamId, + organizationMembers, + membershipRole, + teamRole, +}: TeamMembersProps) => { + const [openAddMemberModal, setOpenAddMemberModal] = useState(false); + const [removeMemberModalOpen, setRemoveMemberModalOpen] = useState(false); + const [selectedTeamMemberId, setSelectedTeamMemberId] = useState(null); + + const router = useRouter(); + const t = useTranslations(); + const { isOwner, isManager } = getAccessFlags(membershipRole); + const { isAdmin: isTeamAdmin } = getTeamAccessFlags(teamRole); + + const isOwnerOrManager = isOwner || isManager; + + const canPerformRoleManagement = isOwnerOrManager || isTeamAdmin; + + const handleRoleChange = async (userId: string, role: TTeamRole) => { + const updateAccessPermissionActionResponse = await updateUserTeamRoleAction({ + teamId, + userId, + role, + }); + if (updateAccessPermissionActionResponse?.data) { + toast.success(t("environments.settings.teams.role_updated_successfully")); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(updateAccessPermissionActionResponse); + toast.error(errorMessage); + } + }; + + const handleRemoveMember = async (userId: string) => { + const removeMemberActionResponse = await removeTeamMemberAction({ teamId, userId }); + + if (removeMemberActionResponse?.data) { + toast.success(t("environments.settings.teams.member_removed_successfully")); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(removeMemberActionResponse); + toast.error(errorMessage); + } + + setRemoveMemberModalOpen(false); + }; + + const organizationMemberOptions = useMemo( + () => + organizationMembers + .filter((member) => !members.find((teamMember) => teamMember.id === member.id)) + .map((member) => ({ + label: member.name, + value: member.id, + })), + [members, organizationMembers] + ); + + return ( + <> + + + {t("environments.settings.teams.team_members")} +
+ {isOwnerOrManager && ( + + )} + {canPerformRoleManagement && ( + + )} +
+
+ +
+ + + + {t("common.member")} + {t("common.role")} + {canPerformRoleManagement && {t("common.actions")}} + + + + {members.length === 0 && ( + + + {t("environments.settings.teams.no_members_found")} + + + )} + {members.map((teamMember) => ( + + +
+
{teamMember.name}
+
{teamMember.email}
+
+
+ + {canPerformRoleManagement && + teamMember.isRoleEditable && + currentUserId !== teamMember.id ? ( + + ) : canPerformRoleManagement && currentUserId !== teamMember.id ? ( + + + +

{TeamRoleMapping[teamMember.role]}

+ +
+ + {t("environments.settings.teams.org_owner_and_managers_can_only_be_team_admin")} + +
+
+ ) : ( +

{TeamRoleMapping[teamMember.role]}

+ )} +
+ {canPerformRoleManagement && ( + + {(teamMember.id !== currentUserId || + (teamMember.id === currentUserId && isOwnerOrManager)) && ( + + )} + + )} +
+ ))} +
+
+
+
+
+ {openAddMemberModal && ( + + )} + {removeMemberModalOpen && selectedTeamMemberId && ( + { + setSelectedTeamMemberId(null); + setRemoveMemberModalOpen(false); + }} + onConfirm={() => handleRemoveMember(selectedTeamMemberId)} + /> + )} + + ); +}; diff --git a/apps/web/modules/ee/teams/team-details/components/team-navigation.tsx b/apps/web/modules/ee/teams/team-details/components/team-navigation.tsx new file mode 100644 index 0000000000..dfa872e43a --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/team-navigation.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@formbricks/ui/components/Breadcrumb"; + +interface TeamsNavigationBreadcrumbsProps { + teamName: string; +} + +export function TeamsNavigationBreadcrumbs({ teamName }: TeamsNavigationBreadcrumbsProps) { + const t = useTranslations(); + return ( + + + + {t("common.teams")} + + + + {teamName} + + + + ); +} diff --git a/apps/web/modules/ee/teams/team-details/components/team-products.tsx b/apps/web/modules/ee/teams/team-details/components/team-products.tsx new file mode 100644 index 0000000000..af922a4e33 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/team-products.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { updateTeamProductPermissionAction } from "@/modules/ee/teams/team-details/actions"; +import { AddTeamProductModal } from "@/modules/ee/teams/team-details/components/add-team-product-modal"; +import { TOrganizationProduct, TTeamProduct } from "@/modules/ee/teams/team-details/types/teams"; +import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { AlertDialog } from "@formbricks/ui/components/AlertDialog"; +import { Button } from "@formbricks/ui/components/Button"; +import { Card, CardContent, CardHeader, CardTitle } from "@formbricks/ui/components/Card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@formbricks/ui/components/Select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@formbricks/ui/components/Table"; +import { removeTeamProductAction } from "../actions"; + +interface TeamProductsProps { + membershipRole?: TOrganizationRole; + products: TTeamProduct[]; + teamId: string; + organizationProducts: TOrganizationProduct[]; +} + +export const TeamProducts = ({ + membershipRole, + products, + teamId, + organizationProducts, +}: TeamProductsProps) => { + const t = useTranslations(); + const [openAddProductModal, setOpenAddProductModal] = useState(false); + const [removeProductModalOpen, setRemoveProductModalOpen] = useState(false); + const [selectedProductId, setSelectedProductId] = useState(null); + + const router = useRouter(); + + const { isOwner, isManager } = getAccessFlags(membershipRole); + const isOwnerOrManager = isOwner || isManager; + + const handleRemoveProduct = async (productId: string) => { + const removeProductActionResponse = await removeTeamProductAction({ + teamId, + productId, + }); + + if (removeProductActionResponse?.data) { + toast.success(t("environments.settings.teams.product_removed_successfully")); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(removeProductActionResponse); + toast.error(errorMessage); + } + + setRemoveProductModalOpen(false); + }; + + const handlePermissionChange = async (productId: string, permission: TTeamPermission) => { + const updateTeamPermissionResponse = await updateTeamProductPermissionAction({ + teamId, + productId, + permission, + }); + if (updateTeamPermissionResponse?.data) { + toast.success(t("environments.settings.teams.permission_updated_successfully")); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(updateTeamPermissionResponse); + toast.error(errorMessage); + } + }; + + const productOptions = useMemo( + () => + organizationProducts + .filter((product) => !products.find((p) => p.id === product.id)) + .map((product) => ({ + label: product.name, + value: product.id, + })), + [organizationProducts, products] + ); + + return ( + <> + + + {t("environments.settings.teams.team_products")} +
+ {isOwnerOrManager && ( + + )} +
+
+ +
+ + + + {t("environments.settings.teams.product_name")} + {t("environments.settings.teams.permission")} + {isOwnerOrManager && {t("common.actions")}} + + + + {products.length === 0 && ( + + + {t("environments.settings.teams.empty_product_message")} + + + )} + {products.map((product) => ( + + {product.name} + + {isOwnerOrManager ? ( + + ) : ( +

{TeamPermissionMapping[product.permission]}

+ )} +
+ {isOwnerOrManager && ( + + + + )} +
+ ))} +
+
+
+
+
+ {openAddProductModal && ( + + )} + {removeProductModalOpen && selectedProductId && ( + { + setSelectedProductId(null); + setRemoveProductModalOpen(false); + }} + onConfirm={() => handleRemoveProduct(selectedProductId)} + /> + )} + + ); +}; diff --git a/apps/web/modules/ee/teams/team-details/components/team-settings.tsx b/apps/web/modules/ee/teams/team-details/components/team-settings.tsx new file mode 100644 index 0000000000..f0f9fb8265 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/components/team-settings.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { DeleteTeam } from "@/modules/ee/teams/team-details/components/delete-team"; +import { EditTeamNameForm } from "@/modules/ee/teams/team-details/components/edit-team-name-form"; +import { TTeam } from "@/modules/ee/teams/team-details/types/teams"; +import { useTranslations } from "next-intl"; +import { TOrganizationRole } from "@formbricks/types/memberships"; + +interface TeamSettingsProps { + team: TTeam; + membershipRole?: TOrganizationRole; +} + +export const TeamSettings = ({ team, membershipRole }: TeamSettingsProps) => { + const t = useTranslations(); + return ( +
+ + + + + + +
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-details/lib/teams.ts b/apps/web/modules/ee/teams/team-details/lib/teams.ts new file mode 100644 index 0000000000..e477d9db41 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/lib/teams.ts @@ -0,0 +1,697 @@ +import "server-only"; +import { membershipCache } from "@/lib/cache/membership"; +import { teamCache } from "@/lib/cache/team"; +import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { + TOrganizationMember, + TOrganizationProduct, + TTeam, + TTeamProduct, + ZTeam, +} from "@/modules/ee/teams/team-details/types/teams"; +import { TTeamRole, ZTeamRole } from "@/modules/ee/teams/team-list/types/teams"; +import { Prisma, TeamUserRole } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { organizationCache } from "@formbricks/lib/organization/cache"; +import { productCache } from "@formbricks/lib/product/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId, ZString } from "@formbricks/types/common"; +import { + AuthorizationError, + DatabaseError, + ResourceNotFoundError, + UnknownError, +} from "@formbricks/types/errors"; + +export const getTeam = reactCache( + (teamId: string): Promise => + cache( + async () => { + validateInputs([teamId, ZId]); + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + id: true, + name: true, + organizationId: true, + }, + }); + + if (!team) { + throw new ResourceNotFoundError("team", teamId); + } + + const teamMemberships = await prisma.teamUser.findMany({ + where: { + teamId, + }, + select: { + role: true, + user: { + select: { + id: true, + name: true, + email: true, + memberships: { + where: { + organizationId: team.organizationId, + }, + select: { + role: true, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new ResourceNotFoundError("team", teamId); + } + + const teamMembers = teamMemberships.map((teamMember) => ({ + role: teamMember.role, + id: teamMember.user.id, + name: teamMember.user.name, + email: teamMember.user.email, + isRoleEditable: + teamMember.user.memberships[0].role !== "owner" && + teamMember.user.memberships[0].role !== "manager", + })); + + return { + id: team.id, + name: team.name, + teamUsers: teamMembers, + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getTeam-${teamId}`], + { tags: [teamCache.tag.byId(teamId)] } + )() +); + +export const updateTeamName = async (teamId: string, name: string): Promise<{ name: string }> => { + validateInputs([teamId, ZId], [name, ZTeam.shape.name]); + try { + const updatedTeam = await prisma.team.update({ + where: { + id: teamId, + }, + data: { + name, + }, + select: { + organizationId: true, + name: true, + productTeams: { + select: { + productId: true, + }, + }, + }, + }); + + teamCache.revalidate({ id: teamId, organizationId: updatedTeam.organizationId }); + + for (const productTeam of updatedTeam.productTeams) { + teamCache.revalidate({ productId: productTeam.productId }); + } + + return { name: updatedTeam.name }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const deleteTeam = async (teamId: string): Promise => { + validateInputs([teamId, ZId]); + try { + const deletedTeam = await prisma.team.delete({ + where: { + id: teamId, + }, + select: { + organizationId: true, + productTeams: { + select: { + productId: true, + }, + }, + }, + }); + + teamCache.revalidate({ id: teamId, organizationId: deletedTeam.organizationId }); + + for (const productTeam of deletedTeam.productTeams) { + teamCache.revalidate({ productId: productTeam.productId }); + } + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const updateUserTeamRole = async ( + teamId: string, + userId: string, + role: TTeamRole +): Promise => { + validateInputs([teamId, ZId], [userId, ZId], [role, ZTeamRole]); + try { + const teamMembership = await prisma.teamUser.findUnique({ + where: { + teamId_userId: { + teamId, + userId, + }, + }, + select: { + team: { + select: { + organizationId: true, + }, + }, + }, + }); + + if (!teamMembership) { + throw new ResourceNotFoundError("teamMembership", null); + } + + const orgMembership = await prisma.membership.findUniqueOrThrow({ + where: { + userId_organizationId: { + userId, + organizationId: teamMembership.team.organizationId, + }, + }, + select: { + role: true, + }, + }); + + if (!orgMembership) { + throw new ResourceNotFoundError("membership", null); + } + + if (["owner", "manager"].includes(orgMembership.role) && role === "contributor") { + throw new AuthorizationError(`Organization ${orgMembership.role} cannot be a contributor`); + } + + await prisma.teamUser.update({ + where: { + teamId_userId: { + teamId, + userId, + }, + }, + data: { + role, + }, + }); + + teamCache.revalidate({ id: teamId, userId }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const removeTeamMember = async (teamId: string, userId: string): Promise => { + validateInputs([teamId, ZId], [userId, ZId]); + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + organizationId: true, + productTeams: { + where: { + teamId, + }, + select: { + productId: true, + }, + }, + }, + }); + + if (!team) { + throw new ResourceNotFoundError("team", teamId); + } + + const teamMembership = await prisma.teamUser.findUnique({ + where: { + teamId_userId: { + teamId, + userId, + }, + }, + }); + + if (!teamMembership) { + throw new ResourceNotFoundError("teamMembership", null); + } + + await prisma.teamUser.delete({ + where: { + teamId_userId: { + teamId, + userId, + }, + }, + }); + + teamCache.revalidate({ + id: teamId, + userId, + organizationId: team.organizationId, + }); + + productCache.revalidate({ userId }); + + for (const productTeam of team.productTeams) { + teamCache.revalidate({ productId: productTeam.productId }); + } + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getMembersByOrganizationId = reactCache( + (organizationId: string): Promise => + cache( + async () => { + validateInputs([organizationId, ZString]); + + try { + const membersData = await prisma.membership.findMany({ + where: { + organizationId, + role: { + not: "billing", + }, + }, + select: { + user: { + select: { + name: true, + }, + }, + userId: true, + }, + }); + + const members = membersData.map((member) => { + return { + id: member.userId, + name: member.user?.name || "", + }; + }); + + return members; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw new UnknownError("Error while fetching members"); + } + }, + [`getMembersByOrganizationId-${organizationId}`], + { + tags: [membershipCache.tag.byOrganizationId(organizationId)], + } + )() +); + +export const addTeamMembers = async (teamId: string, userIds: string[]): Promise => { + validateInputs([teamId, ZId], [userIds, z.array(ZId)]); + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + organizationId: true, + organization: { + select: { + products: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new ResourceNotFoundError("team", teamId); + } + + for (const userId of userIds) { + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId, + organizationId: team.organizationId, + }, + }, + select: { + role: true, + }, + }); + + if (!membership) { + throw new ResourceNotFoundError("Membership", null); + } + + let role: TeamUserRole = "contributor"; + + const { isOwner, isManager } = getAccessFlags(membership.role); + + if (isOwner || isManager) { + role = "admin"; + } + + await prisma.teamUser.create({ + data: { + teamId, + userId, + role, + }, + }); + + teamCache.revalidate({ userId }); + productCache.revalidate({ userId }); + } + + for (const product of team.organization.products) { + teamCache.revalidate({ productId: product.id }); + } + + productCache.revalidate({ organizationId: team.organizationId }); + teamCache.revalidate({ id: teamId, organizationId: team.organizationId }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getTeamProducts = reactCache( + (teamId: string): Promise => + cache( + async () => { + validateInputs([teamId, ZId]); + + try { + const products = await prisma.productTeam.findMany({ + where: { + teamId, + }, + select: { + product: { + select: { + id: true, + name: true, + }, + }, + permission: true, + }, + }); + + return products.map((product) => ({ + id: product.product.id, + name: product.product.name, + permission: product.permission, + })); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getTeamProducts-${teamId}`], + { tags: [teamCache.tag.byId(teamId)] } + )() +); + +export const updateTeamProductPermission = async ( + teamId: string, + productId: string, + permission: TTeamPermission +): Promise => { + validateInputs([teamId, ZId], [productId, ZId], [permission, ZTeamPermission]); + try { + const productTeam = await prisma.productTeam.findUnique({ + where: { + productId_teamId: { + productId, + teamId, + }, + }, + }); + + if (!productTeam) { + throw new ResourceNotFoundError("productTeam", null); + } + + await prisma.productTeam.update({ + where: { + productId_teamId: { + productId, + teamId, + }, + }, + data: { + permission, + }, + }); + + teamCache.revalidate({ id: teamId, productId }); + productCache.revalidate({ id: productId }); + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const removeTeamProduct = async (teamId: string, productId: string): Promise => { + validateInputs([teamId, ZId], [productId, ZId]); + try { + const product = await prisma.product.findUnique({ + where: { + id: productId, + }, + select: { + id: true, + organizationId: true, + environments: { + select: { + id: true, + }, + }, + }, + }); + + if (!product) { + throw new ResourceNotFoundError("product", productId); + } + const productTeam = await prisma.productTeam.findUnique({ + where: { + productId_teamId: { + productId, + teamId, + }, + }, + }); + + if (!productTeam) { + throw new ResourceNotFoundError("productTeam", null); + } + + await prisma.productTeam.delete({ + where: { + productId_teamId: { + productId, + teamId, + }, + }, + }); + + teamCache.revalidate({ id: teamId, productId }); + productCache.revalidate({ id: productId, organizationId: product.organizationId }); + + for (const environment of product.environments) { + organizationCache.revalidate({ environmentId: environment.id }); + } + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getProductsByOrganizationId = reactCache( + (organizationId: string): Promise => + cache( + async () => { + validateInputs([organizationId, ZString]); + + try { + const products = await prisma.product.findMany({ + where: { + organizationId, + }, + select: { + id: true, + name: true, + }, + }); + + return products.map((product) => ({ + id: product.id, + name: product.name, + })); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw new UnknownError("Error while fetching products"); + } + }, + [`getProductsByOrganizationId-${organizationId}`], + { + tags: [productCache.tag.byOrganizationId(organizationId)], + } + )() +); + +export const addTeamProducts = async (teamId: string, productIds: string[]): Promise => { + validateInputs([teamId, ZId], [productIds, z.array(ZId)]); + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + organizationId: true, + }, + }); + + if (!team) { + throw new ResourceNotFoundError("team", teamId); + } + + for (const productId of productIds) { + const product = await prisma.product.findUnique({ + where: { + id: productId, + organizationId: team.organizationId, + }, + select: { + environments: { + select: { + id: true, + }, + }, + }, + }); + + if (!product) { + throw new ResourceNotFoundError("product", productId); + } + + const productTeam = await prisma.productTeam.findUnique({ + where: { + productId_teamId: { + productId, + teamId, + }, + }, + }); + + if (productTeam) { + continue; + } + + await prisma.productTeam.create({ + data: { + productId, + teamId, + permission: "read", + }, + }); + + teamCache.revalidate({ id: teamId, productId }); + productCache.revalidate({ id: productId, organizationId: team.organizationId }); + + for (const environment of product.environments) { + organizationCache.revalidate({ environmentId: environment.id }); + } + } + + return true; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/modules/ee/teams/team-details/page.tsx b/apps/web/modules/ee/teams/team-details/page.tsx new file mode 100644 index 0000000000..7056d7ee31 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/page.tsx @@ -0,0 +1,70 @@ +import { getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles"; +import { DetailsView } from "@/modules/ee/teams/team-details/components/details-view"; +import { TeamsNavigationBreadcrumbs } from "@/modules/ee/teams/team-details/components/team-navigation"; +import { + getMembersByOrganizationId, + getProductsByOrganizationId, + getTeam, + getTeamProducts, +} from "@/modules/ee/teams/team-details/lib/teams"; +import { getServerSession } from "next-auth"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { getRoleManagementPermission } from "@formbricks/ee/lib/service"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; + +export const TeamDetails = async ({ params }) => { + const t = await getTranslations(); + const session = await getServerSession(authOptions); + if (!session) { + throw new Error("common.session_not_found"); + } + const organization = await getOrganizationByEnvironmentId(params.environmentId); + + if (!organization) { + throw new Error(t("common.organization_not_found")); + } + + const team = await getTeam(params.teamId); + if (!team) { + throw new Error(t("common.team_not_found")); + } + + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isBilling, isMember } = getAccessFlags(currentUserMembership?.role); + + const teamRole = await getTeamRoleByTeamIdUserId(params.teamId, session.user.id); + + const canDoRoleManagement = await getRoleManagementPermission(organization); + + if (!canDoRoleManagement || isBilling || (isMember && !teamRole)) { + notFound(); + } + + const userId = session.user.id; + + const organizationMembers = await getMembersByOrganizationId(organization.id); + + const teamProducts = await getTeamProducts(params.teamId); + + const organizationProducts = await getProductsByOrganizationId(organization.id); + + return ( + + + + + ); +}; diff --git a/apps/web/modules/ee/teams/team-details/types/teams.ts b/apps/web/modules/ee/teams/team-details/types/teams.ts new file mode 100644 index 0000000000..e394f84d79 --- /dev/null +++ b/apps/web/modules/ee/teams/team-details/types/teams.ts @@ -0,0 +1,43 @@ +import { ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams"; +import { ZTeamRole } from "@/modules/ee/teams/team-list/types/teams"; +import { z } from "zod"; + +export const ZTeamMember = z.object({ + role: ZTeamRole, + id: z.string(), + name: z.string(), + email: z.string(), + isRoleEditable: z.boolean(), +}); + +export type TTeamMember = z.infer; + +export const ZTeam = z.object({ + id: z.string(), + name: z.string({ message: "Team name is required" }).trim().min(1, { + message: "Team name must be at least 1 character long", + }), + teamUsers: z.array(ZTeamMember), +}); + +export type TTeam = z.infer; + +export const ZOrganizationMember = z.object({ + id: z.string(), + name: z.string(), +}); +export type TOrganizationMember = z.infer; + +export const TTeamProduct = z.object({ + id: z.string(), + name: z.string(), + permission: ZTeamPermission, +}); +export type TTeamProduct = z.infer; + +export const ZOrganizationProduct = z.object({ + id: z.string(), + name: z.string(), +}); + +export type TOrganizationProduct = z.infer; diff --git a/apps/web/modules/ee/teams/team-list/actions.ts b/apps/web/modules/ee/teams/team-list/actions.ts new file mode 100644 index 0000000000..273f85fb9e --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/actions.ts @@ -0,0 +1,49 @@ +"use server"; + +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { createTeam, getTeams } from "@/modules/ee/teams/team-list/lib/teams"; +import { z } from "zod"; + +const ZGetTeamsAction = z.object({ + organizationId: z.string(), +}); + +export const getTeamsAction = authenticatedActionClient + .schema(ZGetTeamsAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: parsedInput.organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager", "billing", "member"], + }, + ], + }); + + return await getTeams(ctx.user.id, parsedInput.organizationId); + }); + +const ZCreateTeamAction = z.object({ + organizationId: z.string().cuid(), + name: z.string(), +}); + +export const createTeamAction = authenticatedActionClient + .schema(ZCreateTeamAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: parsedInput.organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await createTeam(parsedInput.organizationId, parsedInput.name); + }); diff --git a/apps/web/modules/ee/teams/team-list/components/create-team-button.tsx b/apps/web/modules/ee/teams/team-list/components/create-team-button.tsx new file mode 100644 index 0000000000..d1ff7071a9 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/create-team-button.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Button } from "@formbricks/ui/components/Button"; + +interface CreateTeamButtonProps { + organizationId: string; +} + +export const CreateTeamButton = ({ organizationId }: CreateTeamButtonProps) => { + const t = useTranslations(); + const [openCreateTeamModal, setOpenCreateTeamModal] = useState(false); + return ( + <> + + {openCreateTeamModal && ( + + )} + + ); +}; diff --git a/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx b/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx new file mode 100644 index 0000000000..a134cda814 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { createTeamAction } from "@/modules/ee/teams/team-list/actions"; +import { UsersIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { Button } from "@formbricks/ui/components/Button"; +import { Input } from "@formbricks/ui/components/Input"; +import { Label } from "@formbricks/ui/components/Label"; +import { Modal } from "@formbricks/ui/components/Modal"; +import { H4 } from "@formbricks/ui/components/Typography"; + +interface CreateTeamModalProps { + open: boolean; + setOpen: React.Dispatch>; + organizationId: string; + onCreate?: (teamId: string) => void; +} + +export const CreateTeamModal = ({ open, setOpen, organizationId, onCreate }: CreateTeamModalProps) => { + const [teamName, setTeamName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const t = useTranslations(); + const router = useRouter(); + + const handleTeamCreation = async (e) => { + e.preventDefault(); + setIsLoading(true); + + const name = teamName.trim(); + const createTeamActionResponse = await createTeamAction({ name, organizationId }); + if (createTeamActionResponse?.data) { + toast.success(t("environments.settings.teams.team_created_successfully")); + if (typeof onCreate === "function") { + onCreate(createTeamActionResponse.data); + } + router.refresh(); + setOpen(false); + setTeamName(""); + } else { + const errorMessage = getFormattedErrorMessage(createTeamActionResponse); + toast.error(errorMessage); + } + setIsLoading(false); + }; + + return ( + +
+
+
+ +

{t("environments.settings.teams.create_new_team")}

+
+
+
+
+
+ + { + setTeamName(e.target.value); + }} + placeholder={t("environments.settings.teams.enter_team_name")} + /> +
+
+ + +
+
+
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-list/components/teams-table.tsx b/apps/web/modules/ee/teams/team-list/components/teams-table.tsx new file mode 100644 index 0000000000..1b2746b7e4 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/teams-table.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { CreateTeamButton } from "@/modules/ee/teams/team-list/components/create-team-button"; +import { TOtherTeam, TUserTeam } from "@/modules/ee/teams/team-list/types/teams"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { Badge } from "@formbricks/ui/components/Badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@formbricks/ui/components/Card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@formbricks/ui/components/Table"; + +interface YourTeamsProps { + teams: { userTeams: TUserTeam[]; otherTeams: TOtherTeam[] }; + currentUserId: string; + isOwnerOrManager: boolean; + organizationId: string; +} + +export const TeamsTable = ({ teams, organizationId, isOwnerOrManager }: YourTeamsProps) => { + const t = useTranslations(); + + const { userTeams, otherTeams } = teams; + + const allTeams = [...userTeams, ...otherTeams]; + return ( + <> + + + {t("common.teams")} + {isOwnerOrManager && } + + +
+ + + + {t("common.name")} + + + + + {allTeams.length === 0 && ( + + + {t("environments.settings.teams.empty_teams_state")} + + + )} + {userTeams.map((team) => ( + + + + {team.name} + {" "} + ({team.memberCount} {t("common.members")}) + + + + + + ))} + {otherTeams.map((team) => ( + + + {isOwnerOrManager ? ( + + {team.name}{" "} + + ) : ( + team.name + )} + ({team.memberCount} {t("common.members")}) + + - + + ))} + +
+
+
+
+ + ); +}; diff --git a/apps/web/modules/ee/teams/team-list/components/teams-view.tsx b/apps/web/modules/ee/teams/team-list/components/teams-view.tsx new file mode 100644 index 0000000000..932c08acb6 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/teams-view.tsx @@ -0,0 +1,30 @@ +import { TeamsTable } from "@/modules/ee/teams/team-list/components/teams-table"; +import { TOtherTeam, TUserTeam } from "@/modules/ee/teams/team-list/types/teams"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TOrganizationRole } from "@formbricks/types/memberships"; + +interface TeamsViewProps { + organizationId: string; + teams: { userTeams: TUserTeam[]; otherTeams: TOtherTeam[] }; + membershipRole?: TOrganizationRole; + currentUserId: string; +} + +export const TeamsView = ({ organizationId, teams, membershipRole, currentUserId }: TeamsViewProps) => { + const { isOwner, isManager } = getAccessFlags(membershipRole); + + const isOwnerOrManager = isOwner || isManager; + + return ( +
+
+ +
+
+ ); +}; diff --git a/apps/web/modules/ee/teams/team-list/lib/teams.ts b/apps/web/modules/ee/teams/team-list/lib/teams.ts new file mode 100644 index 0000000000..890e0d23a3 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/lib/teams.ts @@ -0,0 +1,198 @@ +import "server-only"; +import { teamCache } from "@/lib/cache/team"; +import { TOtherTeam, TUserTeam } from "@/modules/ee/teams/team-list/types/teams"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { userCache } from "@formbricks/lib/user/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; + +const getUserTeams = reactCache( + (userId: string, organizationId: string): Promise => + cache( + async () => { + validateInputs([userId, z.string()], [organizationId, ZId]); + try { + const teams = await prisma.team.findMany({ + where: { + organizationId, + teamUsers: { + some: { + userId, + }, + }, + }, + select: { + id: true, + name: true, + teamUsers: { + select: { + role: true, + }, + }, + _count: { + select: { + teamUsers: true, + }, + }, + }, + }); + + const userTeams = teams.map((team) => ({ + id: team.id, + name: team.name, + userRole: team.teamUsers[0].role, + memberCount: team._count.teamUsers, + })); + + return userTeams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getUserTeams-${userId}`], + { + tags: [ + teamCache.tag.byUserId(userId), + userCache.tag.byId(userId), + teamCache.tag.byOrganizationId(organizationId), + ], + } + )() +); + +export const getOtherTeams = reactCache( + (userId: string, organizationId: string): Promise => + cache( + async () => { + validateInputs([userId, z.string()], [organizationId, ZId]); + try { + const teams = await prisma.team.findMany({ + where: { + organizationId, + teamUsers: { + none: { + userId, + }, + }, + }, + select: { + id: true, + name: true, + _count: { + select: { + teamUsers: true, + }, + }, + }, + }); + + const otherTeams = teams.map((team) => ({ + id: team.id, + name: team.name, + memberCount: team._count.teamUsers, + })); + + return otherTeams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getOtherTeams-${userId}`], + { + tags: [ + teamCache.tag.byUserId(userId), + userCache.tag.byId(userId), + teamCache.tag.byOrganizationId(organizationId), + ], + } + )() +); + +export const getTeams = reactCache( + (userId: string, organizationId: string): Promise<{ userTeams: TUserTeam[]; otherTeams: TOtherTeam[] }> => + cache( + async () => { + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + select: { + role: true, + }, + }); + + if (!membership) { + throw new ResourceNotFoundError("Membership", null); + } + + const userTeams = await getUserTeams(userId, organizationId); + let otherTeams = await getOtherTeams(userId, organizationId); + + return { userTeams, otherTeams }; + }, + [`teams-getTeams-${userId}`], + { + tags: [ + teamCache.tag.byUserId(userId), + userCache.tag.byId(userId), + teamCache.tag.byOrganizationId(organizationId), + ], + } + )() +); + +export const createTeam = async (organizationId: string, name: string): Promise => { + validateInputs([organizationId, ZId], [name, z.string()]); + try { + const doesTeamExist = await prisma.team.findFirst({ + where: { + name, + organizationId, + }, + }); + + if (doesTeamExist) { + throw new InvalidInputError("Team name already exists"); + } + + if (name.length < 1) { + throw new InvalidInputError("Team name must be at least 1 character long"); + } + + const team = await prisma.team.create({ + data: { + name, + organizationId, + }, + select: { + id: true, + }, + }); + + teamCache.revalidate({ organizationId }); + + return team.id; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/modules/ee/teams/team-list/page.tsx b/apps/web/modules/ee/teams/team-list/page.tsx new file mode 100644 index 0000000000..0be1cecfdb --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/page.tsx @@ -0,0 +1,59 @@ +import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view"; +import { getTeams } from "@/modules/ee/teams/team-list/lib/teams"; +import { getServerSession } from "next-auth"; +import { notFound } from "next/navigation"; +import { getRoleManagementPermission } from "@formbricks/ee/lib/service"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; +import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper"; +import { PageHeader } from "@formbricks/ui/components/PageHeader"; + +export const TeamsPage = async ({ params }) => { + const session = await getServerSession(authOptions); + if (!session) { + throw new Error("Unauthenticated"); + } + const organization = await getOrganizationByEnvironmentId(params.environmentId); + + if (!organization) { + throw new Error("Organization not found"); + } + + const canDoRoleManagement = await getRoleManagementPermission(organization); + const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); + const { isBilling } = getAccessFlags(currentUserMembership?.role); + + if (!canDoRoleManagement || isBilling) { + notFound(); + } + + const teams = await getTeams(session.user.id, organization.id); + + if (!teams) { + throw new Error("Teams not found"); + } + + return ( + + + + + + + ); +}; diff --git a/apps/web/modules/ee/teams/team-list/types/teams.ts b/apps/web/modules/ee/teams/team-list/types/teams.ts new file mode 100644 index 0000000000..8f9d9ffca2 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/types/teams.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; + +export const ZTeamRole = z.enum(["admin", "contributor"]); +export type TTeamRole = z.infer; + +export const ZUserTeam = z.object({ + id: ZId, + name: z.string(), + userRole: ZTeamRole, + memberCount: z.number(), +}); + +export type TUserTeam = z.infer; + +export const ZOtherTeam = z.object({ + id: ZId, + name: z.string(), + memberCount: z.number(), +}); + +export type TOtherTeam = z.infer; diff --git a/apps/web/modules/ee/teams/utils/teams.ts b/apps/web/modules/ee/teams/utils/teams.ts new file mode 100644 index 0000000000..03b2791160 --- /dev/null +++ b/apps/web/modules/ee/teams/utils/teams.ts @@ -0,0 +1,34 @@ +import { ProductTeamPermission, TeamUserRole } from "@prisma/client"; + +export const TeamPermissionMapping = { + [ProductTeamPermission.read]: "Read", + [ProductTeamPermission.readWrite]: "Read & write", + [ProductTeamPermission.manage]: "Manage", +}; + +export const TeamRoleMapping = { + [TeamUserRole.admin]: "Team Admin", + [TeamUserRole.contributor]: "Contributor", +}; + +export const getTeamAccessFlags = (role?: TeamUserRole | null) => { + const isAdmin = role === TeamUserRole.admin; + const isContributor = role === TeamUserRole.contributor; + + return { + isAdmin, + isContributor, + }; +}; + +export const getTeamPermissionFlags = (permissionLevel?: ProductTeamPermission | null) => { + const hasReadAccess = permissionLevel === ProductTeamPermission.read; + const hasReadWriteAccess = permissionLevel === ProductTeamPermission.readWrite; + const hasManageAccess = permissionLevel === ProductTeamPermission.manage; + + return { + hasReadAccess, + hasReadWriteAccess, + hasManageAccess, + }; +}; diff --git a/packages/email/components/email-button.tsx b/apps/web/modules/email/components/email-button.tsx similarity index 100% rename from packages/email/components/email-button.tsx rename to apps/web/modules/email/components/email-button.tsx diff --git a/packages/email/components/email-footer.tsx b/apps/web/modules/email/components/email-footer.tsx similarity index 100% rename from packages/email/components/email-footer.tsx rename to apps/web/modules/email/components/email-footer.tsx diff --git a/packages/email/components/email-template.tsx b/apps/web/modules/email/components/email-template.tsx similarity index 100% rename from packages/email/components/email-template.tsx rename to apps/web/modules/email/components/email-template.tsx diff --git a/packages/email/components/preview-email-template.tsx b/apps/web/modules/email/components/preview-email-template.tsx similarity index 99% rename from packages/email/components/preview-email-template.tsx rename to apps/web/modules/email/components/preview-email-template.tsx index 3ea129f413..3a44065b3a 100644 --- a/packages/email/components/preview-email-template.tsx +++ b/apps/web/modules/email/components/preview-email-template.tsx @@ -1,3 +1,4 @@ +import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley"; import { Column, Container, @@ -17,7 +18,6 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; import { isLight, mixColor } from "@formbricks/lib/utils/colors"; import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types"; -import { RatingSmiley } from "@formbricks/ui/components/RatingSmiley"; import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils"; interface PreviewEmailTemplateProps { diff --git a/packages/email/emails/auth/forgot-password-email.tsx b/apps/web/modules/email/emails/auth/forgot-password-email.tsx similarity index 100% rename from packages/email/emails/auth/forgot-password-email.tsx rename to apps/web/modules/email/emails/auth/forgot-password-email.tsx diff --git a/packages/email/emails/auth/password-reset-notify-email.tsx b/apps/web/modules/email/emails/auth/password-reset-notify-email.tsx similarity index 100% rename from packages/email/emails/auth/password-reset-notify-email.tsx rename to apps/web/modules/email/emails/auth/password-reset-notify-email.tsx diff --git a/packages/email/emails/auth/verification-email.tsx b/apps/web/modules/email/emails/auth/verification-email.tsx similarity index 100% rename from packages/email/emails/auth/verification-email.tsx rename to apps/web/modules/email/emails/auth/verification-email.tsx diff --git a/packages/email/emails/invite/invite-accepted-email.tsx b/apps/web/modules/email/emails/invite/invite-accepted-email.tsx similarity index 100% rename from packages/email/emails/invite/invite-accepted-email.tsx rename to apps/web/modules/email/emails/invite/invite-accepted-email.tsx diff --git a/packages/email/emails/invite/invite-email.tsx b/apps/web/modules/email/emails/invite/invite-email.tsx similarity index 100% rename from packages/email/emails/invite/invite-email.tsx rename to apps/web/modules/email/emails/invite/invite-email.tsx diff --git a/packages/email/emails/invite/onboarding-invite-email.tsx b/apps/web/modules/email/emails/invite/onboarding-invite-email.tsx similarity index 100% rename from packages/email/emails/invite/onboarding-invite-email.tsx rename to apps/web/modules/email/emails/invite/onboarding-invite-email.tsx diff --git a/packages/email/emails/survey/embed-survey-preview-email.tsx b/apps/web/modules/email/emails/survey/embed-survey-preview-email.tsx similarity index 100% rename from packages/email/emails/survey/embed-survey-preview-email.tsx rename to apps/web/modules/email/emails/survey/embed-survey-preview-email.tsx diff --git a/packages/email/emails/survey/link-survey-email.tsx b/apps/web/modules/email/emails/survey/link-survey-email.tsx similarity index 100% rename from packages/email/emails/survey/link-survey-email.tsx rename to apps/web/modules/email/emails/survey/link-survey-email.tsx diff --git a/packages/email/emails/survey/response-finished-email.tsx b/apps/web/modules/email/emails/survey/response-finished-email.tsx similarity index 100% rename from packages/email/emails/survey/response-finished-email.tsx rename to apps/web/modules/email/emails/survey/response-finished-email.tsx diff --git a/packages/email/emails/weekly-summary/create-reminder-notification-body.tsx b/apps/web/modules/email/emails/weekly-summary/create-reminder-notification-body.tsx similarity index 100% rename from packages/email/emails/weekly-summary/create-reminder-notification-body.tsx rename to apps/web/modules/email/emails/weekly-summary/create-reminder-notification-body.tsx diff --git a/packages/email/emails/weekly-summary/live-survey-notification.tsx b/apps/web/modules/email/emails/weekly-summary/live-survey-notification.tsx similarity index 100% rename from packages/email/emails/weekly-summary/live-survey-notification.tsx rename to apps/web/modules/email/emails/weekly-summary/live-survey-notification.tsx diff --git a/packages/email/emails/weekly-summary/no-live-survey-notification-email.tsx b/apps/web/modules/email/emails/weekly-summary/no-live-survey-notification-email.tsx similarity index 100% rename from packages/email/emails/weekly-summary/no-live-survey-notification-email.tsx rename to apps/web/modules/email/emails/weekly-summary/no-live-survey-notification-email.tsx diff --git a/packages/email/emails/weekly-summary/notification-footer.tsx b/apps/web/modules/email/emails/weekly-summary/notification-footer.tsx similarity index 100% rename from packages/email/emails/weekly-summary/notification-footer.tsx rename to apps/web/modules/email/emails/weekly-summary/notification-footer.tsx diff --git a/packages/email/emails/weekly-summary/notification-header.tsx b/apps/web/modules/email/emails/weekly-summary/notification-header.tsx similarity index 100% rename from packages/email/emails/weekly-summary/notification-header.tsx rename to apps/web/modules/email/emails/weekly-summary/notification-header.tsx diff --git a/packages/email/emails/weekly-summary/notification-insight.tsx b/apps/web/modules/email/emails/weekly-summary/notification-insight.tsx similarity index 100% rename from packages/email/emails/weekly-summary/notification-insight.tsx rename to apps/web/modules/email/emails/weekly-summary/notification-insight.tsx diff --git a/packages/email/emails/weekly-summary/weekly-summary-notification-email.tsx b/apps/web/modules/email/emails/weekly-summary/weekly-summary-notification-email.tsx similarity index 100% rename from packages/email/emails/weekly-summary/weekly-summary-notification-email.tsx rename to apps/web/modules/email/emails/weekly-summary/weekly-summary-notification-email.tsx diff --git a/packages/email/index.tsx b/apps/web/modules/email/index.tsx similarity index 100% rename from packages/email/index.tsx rename to apps/web/modules/email/index.tsx diff --git a/packages/email/lib/utils.ts b/apps/web/modules/email/lib/utils.ts similarity index 92% rename from packages/email/lib/utils.ts rename to apps/web/modules/email/lib/utils.ts index be96a257b1..9dc68b1534 100644 --- a/packages/email/lib/utils.ts +++ b/apps/web/modules/email/lib/utils.ts @@ -22,7 +22,6 @@ export const getRatingNumberOptionColor = (range: number, idx: number): string = const defaultLocale = "en-US"; const getMessages = (locale: string): Record => { - /* eslint-disable-next-line @typescript-eslint/no-var-requires -- Dynamic import is necessary for localization */ const messages = require(`@formbricks/lib/messages/${locale}.json`) as { emails: Record }; return messages.emails; }; diff --git a/packages/ui/components/CreateOrganizationModal/index.tsx b/apps/web/modules/organization/components/CreateOrganizationModal/index.tsx similarity index 90% rename from packages/ui/components/CreateOrganizationModal/index.tsx rename to apps/web/modules/organization/components/CreateOrganizationModal/index.tsx index 6d3e5a31a2..d729ad394e 100644 --- a/packages/ui/components/CreateOrganizationModal/index.tsx +++ b/apps/web/modules/organization/components/CreateOrganizationModal/index.tsx @@ -1,15 +1,17 @@ +"use client"; + +import { createOrganizationAction } from "@/app/(app)/environments/[environmentId]/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { PlusCircleIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; -import { createOrganizationAction } from "../../../../apps/web/app/(app)/environments/[environmentId]/actions"; -import { Button } from "../Button"; -import { Input } from "../Input"; -import { Label } from "../Label"; -import { Modal } from "../Modal"; +import { Button } from "@formbricks/ui/components/Button"; +import { Input } from "@formbricks/ui/components/Input"; +import { Label } from "@formbricks/ui/components/Label"; +import { Modal } from "@formbricks/ui/components/Modal"; interface CreateOrganizationModalProps { open: boolean; diff --git a/packages/ui/components/QuestionFormInput/components/FallbackInput.tsx b/apps/web/modules/surveys/components/QuestionFormInput/components/FallbackInput.tsx similarity index 95% rename from packages/ui/components/QuestionFormInput/components/FallbackInput.tsx rename to apps/web/modules/surveys/components/QuestionFormInput/components/FallbackInput.tsx index f67cc9f21a..f0c62f03e7 100644 --- a/packages/ui/components/QuestionFormInput/components/FallbackInput.tsx +++ b/apps/web/modules/surveys/components/QuestionFormInput/components/FallbackInput.tsx @@ -1,8 +1,8 @@ import { RefObject } from "react"; import { toast } from "react-hot-toast"; import { TSurveyRecallItem } from "@formbricks/types/surveys/types"; -import { Button } from "../../Button"; -import { Input } from "../../Input"; +import { Button } from "@formbricks/ui/components/Button"; +import { Input } from "@formbricks/ui/components/Input"; interface FallbackInputProps { filteredRecallItems: (TSurveyRecallItem | undefined)[]; diff --git a/packages/ui/components/QuestionFormInput/components/RecallItemSelect.tsx b/apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx similarity index 97% rename from packages/ui/components/QuestionFormInput/components/RecallItemSelect.tsx rename to apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx index 64978da97b..24774ecd1d 100644 --- a/packages/ui/components/QuestionFormInput/components/RecallItemSelect.tsx +++ b/apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx @@ -24,8 +24,12 @@ import { TSurveyQuestionId, TSurveyRecallItem, } from "@formbricks/types/surveys/types"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "../../DropdownMenu"; -import { Input } from "../../Input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@formbricks/ui/components/DropdownMenu"; +import { Input } from "@formbricks/ui/components/Input"; const questionIconMapping = { openText: MessageSquareTextIcon, diff --git a/packages/ui/components/QuestionFormInput/index.tsx b/apps/web/modules/surveys/components/QuestionFormInput/index.tsx similarity index 98% rename from packages/ui/components/QuestionFormInput/index.tsx rename to apps/web/modules/surveys/components/QuestionFormInput/index.tsx index 943bc0ac89..64de3378c3 100644 --- a/packages/ui/components/QuestionFormInput/index.tsx +++ b/apps/web/modules/surveys/components/QuestionFormInput/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { LanguageIndicator } from "@/modules/ee/multi-language-surveys/components/language-indicator"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { debounce } from "lodash"; import { ImagePlusIcon, PencilIcon, TrashIcon } from "lucide-react"; @@ -7,6 +8,7 @@ import { useTranslations } from "next-intl"; import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { extractLanguageCodes, getEnabledLanguages, getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { createI18nString } from "@formbricks/lib/i18n/utils"; import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; import { useSyncScroll } from "@formbricks/lib/utils/hooks/useSyncScroll"; import { @@ -30,11 +32,9 @@ import { TSurveyRedirectUrlCard, } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; -import { LanguageIndicator } from "../../../ee/multi-language/components/language-indicator"; -import { createI18nString } from "../../../lib/i18n/utils"; -import { FileInput } from "../FileInput"; -import { Input } from "../Input"; -import { Label } from "../Label"; +import { FileInput } from "@formbricks/ui/components/FileInput"; +import { Input } from "@formbricks/ui/components/Input"; +import { Label } from "@formbricks/ui/components/Label"; import { FallbackInput } from "./components/FallbackInput"; import { RecallItemSelect } from "./components/RecallItemSelect"; import { diff --git a/packages/ui/components/QuestionFormInput/utils.ts b/apps/web/modules/surveys/components/QuestionFormInput/utils.ts similarity index 100% rename from packages/ui/components/QuestionFormInput/utils.ts rename to apps/web/modules/surveys/components/QuestionFormInput/utils.ts diff --git a/packages/ui/components/TemplateList/actions.ts b/apps/web/modules/surveys/components/TemplateList/actions.ts similarity index 53% rename from packages/ui/components/TemplateList/actions.ts rename to apps/web/modules/surveys/components/TemplateList/actions.ts index 2fb8c92e63..cf16db4f4f 100644 --- a/packages/ui/components/TemplateList/actions.ts +++ b/apps/web/modules/surveys/components/TemplateList/actions.ts @@ -1,9 +1,9 @@ "use server"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper"; import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; import { createSurvey } from "@formbricks/lib/survey/service"; import { ZId } from "@formbricks/types/common"; import { ZSurveyCreateInput } from "@formbricks/types/surveys/types"; @@ -16,10 +16,20 @@ const ZCreateSurveyAction = z.object({ export const createSurveyAction = authenticatedActionClient .schema(ZCreateSurveyAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["survey", "create"], + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "productTeam", + minPermission: "readWrite", + productId: await getProductIdFromEnvironmentId(parsedInput.environmentId), + }, + ], }); return await createSurvey(parsedInput.environmentId, parsedInput.surveyBody); diff --git a/packages/ui/components/TemplateList/components/StartFromScratchTemplate.tsx b/apps/web/modules/surveys/components/TemplateList/components/StartFromScratchTemplate.tsx similarity index 97% rename from packages/ui/components/TemplateList/components/StartFromScratchTemplate.tsx rename to apps/web/modules/surveys/components/TemplateList/components/StartFromScratchTemplate.tsx index 3172774b25..3c8a2289ea 100644 --- a/packages/ui/components/TemplateList/components/StartFromScratchTemplate.tsx +++ b/apps/web/modules/surveys/components/TemplateList/components/StartFromScratchTemplate.tsx @@ -4,7 +4,7 @@ import { cn } from "@formbricks/lib/cn"; import { getCustomSurveyTemplate } from "@formbricks/lib/templates"; import { TProduct } from "@formbricks/types/product"; import { TTemplate } from "@formbricks/types/templates"; -import { Button } from "../../Button"; +import { Button } from "@formbricks/ui/components/Button"; import { replacePresetPlaceholders } from "../lib/utils"; interface StartFromScratchTemplateProps { diff --git a/packages/ui/components/TemplateList/components/Template.tsx b/apps/web/modules/surveys/components/TemplateList/components/Template.tsx similarity index 97% rename from packages/ui/components/TemplateList/components/Template.tsx rename to apps/web/modules/surveys/components/TemplateList/components/Template.tsx index afcdfe77fb..5a140612cb 100644 --- a/packages/ui/components/TemplateList/components/Template.tsx +++ b/apps/web/modules/surveys/components/TemplateList/components/Template.tsx @@ -2,7 +2,7 @@ import { useTranslations } from "next-intl"; import { cn } from "@formbricks/lib/cn"; import { TProduct } from "@formbricks/types/product"; import { TTemplate, TTemplateFilter } from "@formbricks/types/templates"; -import { Button } from "../../Button"; +import { Button } from "@formbricks/ui/components/Button"; import { replacePresetPlaceholders } from "../lib/utils"; import { TemplateTags } from "./TemplateTags"; diff --git a/packages/ui/components/TemplateList/components/TemplateFilters.tsx b/apps/web/modules/surveys/components/TemplateList/components/TemplateFilters.tsx similarity index 100% rename from packages/ui/components/TemplateList/components/TemplateFilters.tsx rename to apps/web/modules/surveys/components/TemplateList/components/TemplateFilters.tsx diff --git a/packages/ui/components/TemplateList/components/TemplateTags.tsx b/apps/web/modules/surveys/components/TemplateList/components/TemplateTags.tsx similarity index 98% rename from packages/ui/components/TemplateList/components/TemplateTags.tsx rename to apps/web/modules/surveys/components/TemplateList/components/TemplateTags.tsx index 18c96a06f4..6c19a0ee4f 100644 --- a/packages/ui/components/TemplateList/components/TemplateTags.tsx +++ b/apps/web/modules/surveys/components/TemplateList/components/TemplateTags.tsx @@ -4,7 +4,7 @@ import { useMemo } from "react"; import { cn } from "@formbricks/lib/cn"; import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product"; import { TTemplate, TTemplateFilter, TTemplateRole } from "@formbricks/types/templates"; -import { TooltipRenderer } from "../../Tooltip"; +import { TooltipRenderer } from "@formbricks/ui/components/Tooltip"; import { channelMapping, industryMapping, roleMapping } from "../lib/utils"; interface TemplateTagsProps { diff --git a/packages/ui/components/TemplateList/index.tsx b/apps/web/modules/surveys/components/TemplateList/index.tsx similarity index 98% rename from packages/ui/components/TemplateList/index.tsx rename to apps/web/modules/surveys/components/TemplateList/index.tsx index d009e04dbc..c62d2a3c7f 100644 --- a/packages/ui/components/TemplateList/index.tsx +++ b/apps/web/modules/surveys/components/TemplateList/index.tsx @@ -1,9 +1,9 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; import { templates } from "@formbricks/lib/templates"; import type { TEnvironment } from "@formbricks/types/environment"; import { type TProduct, ZProductConfigChannel, ZProductConfigIndustry } from "@formbricks/types/product"; diff --git a/packages/ui/components/TemplateList/lib/utils.ts b/apps/web/modules/surveys/components/TemplateList/lib/utils.ts similarity index 100% rename from packages/ui/components/TemplateList/lib/utils.ts rename to apps/web/modules/surveys/components/TemplateList/lib/utils.ts diff --git a/packages/lib/organization/hooks/actions.ts b/apps/web/modules/utils/hooks/actions.ts similarity index 57% rename from packages/lib/organization/hooks/actions.ts rename to apps/web/modules/utils/hooks/actions.ts index f2aac75d3e..758710842c 100644 --- a/packages/lib/organization/hooks/actions.ts +++ b/apps/web/modules/utils/hooks/actions.ts @@ -1,11 +1,10 @@ "use server"; -import "server-only"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { z } from "zod"; +import { getOrganization } from "@formbricks/lib/organization/service"; import { ZId } from "@formbricks/types/common"; -import { authenticatedActionClient } from "../../actionClient"; -import { checkAuthorization } from "../../actionClient/utils"; -import { getOrganization } from "../service"; const ZGetOrganizationBillingInfoAction = z.object({ organizationId: ZId, @@ -14,13 +13,17 @@ const ZGetOrganizationBillingInfoAction = z.object({ export const getOrganizationBillingInfoAction = authenticatedActionClient .schema(ZGetOrganizationBillingInfoAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, - rules: ["organization", "read"], + access: [ + { + type: "organization", + roles: ["owner", "manager", "billing"], + }, + ], }); const organization = await getOrganization(parsedInput.organizationId); - return organization?.billing; }); diff --git a/packages/lib/organization/hooks/useGetBillingInfo.ts b/apps/web/modules/utils/hooks/useGetBillingInfo.ts similarity index 100% rename from packages/lib/organization/hooks/useGetBillingInfo.ts rename to apps/web/modules/utils/hooks/useGetBillingInfo.ts diff --git a/apps/web/package.json b/apps/web/package.json index 70ed4c4e47..8247134d69 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,6 @@ "@formbricks/api": "workspace:*", "@formbricks/database": "workspace:*", "@formbricks/ee": "workspace:*", - "@formbricks/email": "workspace:*", "@formbricks/js": "workspace:*", "@formbricks/js-core": "workspace:*", "@formbricks/lib": "workspace:*", diff --git a/apps/web/playwright/onboarding.spec.ts b/apps/web/playwright/onboarding.spec.ts index ad3d42882f..5dcd79719c 100644 --- a/apps/web/playwright/onboarding.spec.ts +++ b/apps/web/playwright/onboarding.spec.ts @@ -16,7 +16,7 @@ test.describe("Onboarding Flow Test", async () => { // await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click(); await page.getByPlaceholder("e.g. Formbricks").click(); await page.getByPlaceholder("e.g. Formbricks").fill(productName); - await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click(); + await page.locator("#form-next-button").click(); await page.waitForURL(/\/environments\/[^/]+\/surveys/); await expect(page.getByText(productName)).toBeVisible(); @@ -33,7 +33,8 @@ test.describe("Onboarding Flow Test", async () => { // await page.getByRole("button", { name: "B2B and B2C E-Commerce" }).click(); await page.getByPlaceholder("e.g. Formbricks").click(); await page.getByPlaceholder("e.g. Formbricks").fill(productName); - await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click(); + await page.locator("#form-next-button").click(); + await page.getByRole("button", { name: "I don't know how to do it" }).click(); await page.waitForURL(/\/environments\/[^/]+\/connect\/invite/); await page.getByRole("button", { name: "Not now" }).click(); @@ -53,7 +54,8 @@ test.describe("CX Onboarding", async () => { await page.getByPlaceholder("e.g. Formbricks").click(); await page.getByPlaceholder("e.g. Formbricks").fill(productName); - await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click(); + await page.locator("#form-next-button").click(); + await page.getByRole("button", { name: "NPS Implement proven best" }).click(); await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit(\?.*)mode=cx$/); diff --git a/apps/web/playwright/organization.spec.ts b/apps/web/playwright/organization.spec.ts index 692dc49d19..1284af1868 100644 --- a/apps/web/playwright/organization.spec.ts +++ b/apps/web/playwright/organization.spec.ts @@ -116,3 +116,132 @@ test.describe("Invite, accept and remove organization member", async () => { // await page.getByRole("button", { name: "Delete", exact: true }).click(); // }); }); + +test.describe("Create, update and delete team", async () => { + test.beforeEach(async ({ page, users }) => { + const user = await users.create(); + await user.login(); + + await page.waitForURL(/\/environments\/[^/]+\/surveys/); + }); + + test("Create and update team", async ({ page }) => { + const dropdownTrigger = page.locator("#userDropdownTrigger"); + await expect(dropdownTrigger).toBeVisible(); + await dropdownTrigger.click(); + + const dropdownInnerContentWrapper = page.locator("#userDropdownInnerContentWrapper"); + await expect(dropdownInnerContentWrapper).toBeVisible(); + + await page.getByRole("link", { name: "Organization" }).click(); + await page.waitForURL(/\/environments\/[^/]+\/settings\/general/); + + await page.locator('[data-testid="members-loading-card"]:first-child').waitFor({ state: "hidden" }); + + await expect(page.getByText("Teams")).toBeVisible(); + await page.getByText("Teams").click(); + await expect(page.getByRole("button", { name: "Create new team" })).toBeVisible(); + await page.getByRole("button", { name: "Create new team" }).click(); + await page.locator("#team-name").fill("E2E"); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.locator("#E2E")).toBeVisible(); + await page.getByRole("link", { name: "E2E" }).click(); + await page.waitForURL(/\/environments\/[^/]+\/settings\/teams\/[^/]+/); + await expect(page.getByRole("heading", { name: "E2E" })).toBeVisible(); + + await expect(page.getByRole("button", { name: "Add Member" })).toBeVisible(); + await page.getByRole("button", { name: "Add Member" }).click(); + + await page.locator("#multi-select-dropdown").click(); + await page.locator(".option-1").click(); + + await page.getByRole("button", { name: "Add" }).click(); + + await expect(page.getByRole("cell", { name: "No members found" })).toBeHidden(); + + await expect(page.getByRole("button", { name: "Products" })).toBeVisible(); + await page.getByRole("button", { name: "Products" }).click(); + + await expect( + page.getByRole("cell", { + name: "You haven't added any products yet. Assign a product to the team to grant access to its members.", + }) + ).toBeVisible(); + + await expect(page.getByRole("button", { name: "Add Product" })).toBeVisible(); + await page.getByRole("button", { name: "Add Product" }).click(); + + await page.locator("#multi-select-dropdown").click(); + await page.locator(".option-1").click(); + + await page.getByRole("button", { name: "Add" }).click(); + + await expect( + page.getByRole("cell", { + name: "You haven't added any products yet. Assign a product to the team to grant access to its members.", + }) + ).toBeHidden(); + + await page.getByRole("combobox").click(); + + await page.getByText("Manage").click(); + + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + await page.getByRole("button", { name: "Settings" }).click(); + + await page.locator("#team-name").fill("E2E Updated"); + await page.getByRole("button", { name: "Update" }).click(); + + await expect(page.getByRole("heading", { name: "E2E Updated" })).toBeVisible(); + + await page.getByRole("link", { name: "Configuration" }).click(); + + await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); + + await expect(page.getByRole("link", { name: "Team Access" })).toBeVisible(); + + await page.getByRole("link", { name: "Team Access" }).click(); + await page.getByRole("combobox").click(); + + await page.getByText("Read & write").click(); + + await page.getByRole("button", { name: "Remove" }).click(); + + await expect(page.getByRole("button", { name: "Confirm", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + + await expect(page.getByRole("cell", { name: "No teams found" })).toBeVisible(); + + await page.getByRole("button", { name: "Add existing team" }).click(); + + await page.locator("#multi-select-dropdown").click(); + await page.locator(".option-1").click(); + + await page.getByRole("button", { name: "Add" }).click(); + + await expect(page.getByRole("link", { name: "E2E Updated" })).toBeVisible(); + + await page.getByRole("link", { name: "E2E Updated" }).click(); + + await page.getByRole("button", { name: "Leave" }).click(); + await expect(page.getByRole("button", { name: "Confirm", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + + await expect(page.getByRole("cell", { name: "No members found" })).toBeVisible(); + + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + await page.getByRole("button", { name: "Settings" }).click(); + + await page.getByRole("button", { name: "Delete" }).click(); + + await expect(page.getByRole("button", { name: "Delete", exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Delete", exact: true }).click(); + + await expect( + page.getByRole("cell", { + name: "You don’t have any teams yet. Create your first team to manage product access for members of your organization.", + }) + ).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index 6f7d8a9cb1..f4d01d08ec 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -94,7 +94,7 @@ export const finishOnboarding = async ( // await page.getByRole("button", { name: "Proven methods SaaS" }).click(); await page.getByPlaceholder("e.g. Formbricks").click(); await page.getByPlaceholder("e.g. Formbricks").fill("My Product"); - await page.locator("form").filter({ hasText: "Brand colorMatch the main" }).getByRole("button").click(); + await page.locator("#form-next-button").click(); if (ProductChannel !== "link") { await page.getByRole("button", { name: "I don't know how to do it" }).click(); diff --git a/apps/web/public/sample-csv/formbricks-organization-members-template.csv b/apps/web/public/sample-csv/formbricks-organization-members-template.csv index 2d53dadf2a..8e0d71de46 100644 --- a/apps/web/public/sample-csv/formbricks-organization-members-template.csv +++ b/apps/web/public/sample-csv/formbricks-organization-members-template.csv @@ -1,5 +1,5 @@ Full Name,Email Address,Role -John Doe,john@example.com,Admin -Jane Doe,jane@example.com,Developer -Janice Doe,janice@example.com,Editor -Jhonny Doe,jhonny@example.com,Viewer \ No newline at end of file +John Doe,john@example.com,Manager +Jane Doe,jane@example.com,Billing +Janice Doe,janice@example.com,Member +Jhonny Doe,jhonny@example.com,Owner \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f00b7d6e91..ef21d3ff9a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -158,7 +158,7 @@ x-environment: &environment # Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn) # (Role Management is an Enterprise feature) # DEFAULT_ORGANIZATION_ID: - # DEFAULT_ORGANIZATION_ROLE: admin + # DEFAULT_ORGANIZATION_ROLE: owner services: postgres: diff --git a/packages/database/data-migrations/20241107161932_add_teams/data-migration.ts b/packages/database/data-migrations/20241107161932_add_teams/data-migration.ts new file mode 100644 index 0000000000..b007abdbcd --- /dev/null +++ b/packages/database/data-migrations/20241107161932_add_teams/data-migration.ts @@ -0,0 +1,326 @@ +/* eslint-disable no-console -- logging is allowed in migration scripts */ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); +const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds + +interface TInvite { + organizationId: string; + deprecatedRole: "owner" | "admin" | "editor" | "developer" | "viewer"; + email: string; + id: string; + creatorId: string; + createdAt: Date; + expiresAt: Date; + name?: string | null | undefined; + acceptorId?: string; +} + +async function runMigration(): Promise { + const startTime = Date.now(); + console.log("Starting data migration..."); + + await prisma.$transaction( + async (transactionPrisma) => { + // Fetch all invites and group them by organizationId + const invites = (await transactionPrisma.invite.findMany({ + select: { + id: true, + organizationId: true, + deprecatedRole: true, + }, + })) as Pick[]; + + // Group invites by organizationId + const groupInvitesMap = new Map< + string, + { id: string; organizationId: string; deprecatedRole: TInvite["deprecatedRole"] }[] + >(); + invites.forEach((invite) => { + if (!groupInvitesMap.has(invite.organizationId)) { + groupInvitesMap.set(invite.organizationId, []); + } + + groupInvitesMap.get(invite.organizationId)?.push(invite); + }); + + const groupInvites = groupInvitesMap.entries(); + + // Process each organization's invites to update roles accordingly + await Promise.all( + Array.from(groupInvites).map(async ([organizationId, organizationInvites]) => { + const adminInvites = organizationInvites.filter((invite) => invite.deprecatedRole === "admin"); + const otherRoles = organizationInvites.filter((invite) => invite.deprecatedRole !== "admin"); + + // If no admin invites exist, skip this organization + if (adminInvites.length === 0) { + return; + } + + // Update admin invites to "manager" if there are non-admin roles + if (otherRoles.length > 0) { + return transactionPrisma.invite.updateMany({ + where: { + id: { + in: adminInvites.map((invite) => invite.id), + }, + }, + data: { + role: "manager", + }, + }); + } + + // Check if there are other memberships (editor, developer, viewer) + const otherMembershipsCount = await transactionPrisma.membership.count({ + where: { + organizationId, + deprecatedRole: { + in: ["editor", "developer", "viewer"], + }, + }, + }); + + // If there are other memberships, update admin invites to "manager" + if (otherMembershipsCount > 0) { + return transactionPrisma.invite.updateMany({ + where: { + id: { + in: adminInvites.map((invite) => invite.id), + }, + }, + data: { + role: "manager", + }, + }); + } + + // If no other memberships exist, promote admins to "owner", case where the organization has only owner and admin memberships as well as invite + return transactionPrisma.invite.updateMany({ + where: { + id: { + in: adminInvites.map((invite) => invite.id), + }, + }, + data: { + role: "owner", + }, + }); + }) + ); + + // Set all invites with roles of editor, developer, or viewer to "member" + await transactionPrisma.invite.updateMany({ + where: { + deprecatedRole: { + in: ["editor", "developer", "viewer"], + }, + }, + data: { + role: "member", + }, + }); + + // Fetch non-owner memberships and group them by organizationId + const nonOwnerMemberships = await transactionPrisma.membership.findMany({ + where: { + role: { + notIn: ["owner"], + }, + }, + select: { + userId: true, + organizationId: true, + deprecatedRole: true, + organization: { + select: { + invites: { + where: { + deprecatedRole: { + not: "admin", + }, + }, + select: { + deprecatedRole: true, + }, + }, + }, + }, + }, + }); + + const groupedMemberships = new Map(); + const otherInvitesCount = new Map(); + + nonOwnerMemberships.forEach((membership) => { + if (!groupedMemberships.has(membership.organizationId)) { + groupedMemberships.set(membership.organizationId, []); + } + + if (!otherInvitesCount.has(membership.organizationId)) { + otherInvitesCount.set(membership.organizationId, membership.organization.invites.length); + } + + groupedMemberships.get(membership.organizationId)?.push(membership); + }); + + const groupedMembershipsEntries = groupedMemberships.entries(); + + // Process each organization's memberships to update or create teams + await Promise.all( + Array.from(groupedMembershipsEntries).map(async ([organizationId, memberships]) => { + const adminMembership = memberships.filter((membership) => membership.deprecatedRole === "admin"); + const developerMembership = memberships.filter( + (membership) => membership.deprecatedRole === "developer" + ); + const editorMembership = memberships.filter((membership) => membership.deprecatedRole === "editor"); + const viewerMembership = memberships.filter((membership) => membership.deprecatedRole === "viewer"); + + const otherMemberships = + developerMembership.length + editorMembership.length + viewerMembership.length; + + // If admin members exist alongside others, set their role to "manager" + if (adminMembership.length) { + const otherInvites = otherInvitesCount.get(organizationId) ?? 0; + + await transactionPrisma.membership.updateMany({ + where: { + organizationId, + deprecatedRole: "admin", + }, + data: { + role: otherMemberships || otherInvites > 0 ? "manager" : "owner", + }, + }); + } + + // Create team and update roles for developer or editor memberships + if (developerMembership.length || editorMembership.length || viewerMembership.length) { + const productIdsInOrganization = await transactionPrisma.product.findMany({ + where: { + organizationId, + }, + select: { + id: true, + }, + }); + + // Create an "all access" team for developer and editor roles + if (developerMembership.length || editorMembership.length) { + await transactionPrisma.team.create({ + data: { + organizationId, + name: "all access", + teamUsers: { + create: [...developerMembership, ...editorMembership].map((membership) => ({ + userId: membership.userId, + role: "contributor", + })), + }, + productTeams: { + create: productIdsInOrganization.map((product) => ({ + productId: product.id, + permission: "manage", + })), + }, + }, + }); + + await transactionPrisma.membership.updateMany({ + where: { + organizationId, + deprecatedRole: { + in: ["developer", "editor"], + }, + }, + data: { + role: "member", + }, + }); + } + + // Create a "read only" team for viewer roles + if (viewerMembership.length) { + await transactionPrisma.team.create({ + data: { + organizationId, + name: "read only", + teamUsers: { + create: viewerMembership.map((membership) => ({ + userId: membership.userId, + role: "contributor", + })), + }, + productTeams: { + create: productIdsInOrganization.map((product) => ({ + productId: product.id, + permission: "read", + })), + }, + }, + }); + + await transactionPrisma.membership.updateMany({ + where: { + organizationId, + deprecatedRole: "viewer", + }, + data: { + role: "member", + }, + }); + } + } + }) + ); + + await transactionPrisma.membership.updateMany({ + where: { + deprecatedRole: "owner", + }, + data: { + role: "owner", + }, + }); + + // Clear out the old "role" field in invites after migration + await transactionPrisma.invite.updateMany({ + data: { + deprecatedRole: null, + }, + }); + + await transactionPrisma.membership.updateMany({ + data: { + deprecatedRole: null, + }, + }); + }, + { + timeout: TRANSACTION_TIMEOUT, + } + ); + + const endTime = Date.now(); + console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`); +} + +function handleError(error: unknown): void { + console.error("An error occurred during migration:", error); + process.exit(1); +} + +function handleDisconnectError(): void { + console.error("Failed to disconnect Prisma client"); + process.exit(1); +} + +function main(): void { + runMigration() + .catch(handleError) + .finally(() => { + prisma.$disconnect().catch(handleDisconnectError); + }); +} + +main(); diff --git a/packages/database/migrations/20241107161932_add_teams/migration.sql b/packages/database/migrations/20241107161932_add_teams/migration.sql new file mode 100644 index 0000000000..5259388165 --- /dev/null +++ b/packages/database/migrations/20241107161932_add_teams/migration.sql @@ -0,0 +1,95 @@ +-- Drop the `accepted` column on "Invite" +ALTER TABLE "Invite" +DROP COLUMN "accepted"; + +-- Rename the `role` column on "Invite" to `deprecatedRole` +ALTER TABLE "Invite" +RENAME COLUMN "role" TO "deprecatedRole"; + +-- Drop NOT NULL constraint on `deprecatedRole` in "Invite" +ALTER TABLE "Invite" +ALTER COLUMN "deprecatedRole" DROP NOT NULL; + +-- Drop DEFAULT constraint on `deprecatedRole` in "Invite" +ALTER TABLE "Invite" +ALTER COLUMN "deprecatedRole" DROP DEFAULT; + +-- Rename the `role` column on "Membership" to `deprecatedRole` +ALTER TABLE "Membership" +RENAME COLUMN "role" TO "deprecatedRole"; + +-- Drop NOT NULL constraint on `deprecatedRole` in "Membership" +ALTER TABLE "Membership" +ALTER COLUMN "deprecatedRole" DROP NOT NULL; + +-- CreateEnum +CREATE TYPE "OrganizationRole" AS ENUM ('owner', 'manager', 'member', 'billing'); + +-- CreateEnum +CREATE TYPE "TeamUserRole" AS ENUM ('admin', 'contributor'); + +-- CreateEnum +CREATE TYPE "ProductTeamPermission" AS ENUM ('read', 'readWrite', 'manage'); + +-- AlterTable +ALTER TABLE "Invite" ADD COLUMN "role" "OrganizationRole" NOT NULL DEFAULT 'member'; + +-- AlterTable +ALTER TABLE "Membership" ADD COLUMN "role" "OrganizationRole" NOT NULL DEFAULT 'member'; + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "name" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamUser" ( + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "team_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" "TeamUserRole" NOT NULL, + + CONSTRAINT "TeamUser_pkey" PRIMARY KEY ("team_id","user_id") +); + +-- CreateTable +CREATE TABLE "ProductTeam" ( + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "product_id" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "permission" "ProductTeamPermission" NOT NULL DEFAULT 'read', + + CONSTRAINT "ProductTeam_pkey" PRIMARY KEY ("product_id","team_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_organization_id_name_key" ON "Team"("organization_id", "name"); + +-- CreateIndex +CREATE INDEX "TeamUser_user_id_idx" ON "TeamUser"("user_id"); + +-- CreateIndex +CREATE INDEX "ProductTeam_team_id_idx" ON "ProductTeam"("team_id"); + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamUser" ADD CONSTRAINT "TeamUser_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamUser" ADD CONSTRAINT "TeamUser_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductTeam" ADD CONSTRAINT "ProductTeam_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductTeam" ADD CONSTRAINT "ProductTeam_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/package.json b/packages/database/package.json index ab164a8d35..95b7b201e2 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -53,7 +53,8 @@ "data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts", "data-migration:segments-actions-cleanup": "ts-node ./data-migrations/20240904091113_removed_actions_table/data-migration.ts", "data-migration:migrate-survey-types": "ts-node ./data-migrations/20241002123456_migrate_survey_types/data-migration.ts", - "data-migration:v2.6": "pnpm data-migration:add-display-id-to-response && pnpm data-migration:address-question && pnpm data-migration:advanced-logic && pnpm data-migration:segments-actions-cleanup && pnpm data-migration:migrate-survey-types" + "data-migration:v2.6": "pnpm data-migration:add-display-id-to-response && pnpm data-migration:address-question && pnpm data-migration:advanced-logic && pnpm data-migration:segments-actions-cleanup && pnpm data-migration:migrate-survey-types", + "data-migration:add-teams": "ts-node ./data-migrations/20241107161932_add_teams/data-migration.ts" }, "dependencies": { "@prisma/client": "5.20.0", diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 0a62b73f11..e224f76973 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -447,6 +447,7 @@ model Product { /// @zod.custom(imports.ZLogo) /// [Logo] logo Json? + productTeams ProductTeam[] @@unique([organizationId, name]) @@index([organizationId]) @@ -464,6 +465,14 @@ model Organization { billing Json invites Invite[] isAIEnabled Boolean @default(false) + teams Team[] +} + +enum OrganizationRole { + owner + manager + member + billing } enum MembershipRole { @@ -475,12 +484,13 @@ enum MembershipRole { } model Membership { - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String - accepted Boolean @default(false) - role MembershipRole + accepted Boolean @default(false) + deprecatedRole MembershipRole? //deprecated + role OrganizationRole @default(member) @@id([userId, organizationId]) @@index([userId]) @@ -488,19 +498,19 @@ model Membership { } model Invite { - id String @id @default(uuid()) + id String @id @default(uuid()) email String name String? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String - creator User @relation("inviteCreatedBy", fields: [creatorId], references: [id]) + creator User @relation("inviteCreatedBy", fields: [creatorId], references: [id]) creatorId String - acceptor User? @relation("inviteAcceptedBy", fields: [acceptorId], references: [id], onDelete: Cascade) + acceptor User? @relation("inviteAcceptedBy", fields: [acceptorId], references: [id], onDelete: Cascade) acceptorId String? - accepted Boolean @default(false) - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) expiresAt DateTime - role MembershipRole @default(admin) + deprecatedRole MembershipRole? //deprecated + role OrganizationRole @default(member) @@index([email, organizationId]) @@index([organizationId]) @@ -604,6 +614,7 @@ model User { /// [Locale] locale String @default("en-US") surveys Survey[] + teamUsers TeamUser[] @@index([email]) } @@ -715,3 +726,53 @@ model Document { @@unique([responseId, questionId]) @@index([createdAt]) } + +model Team { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + organizationId String @map(name: "organization_id") + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + teamUsers TeamUser[] + productTeams ProductTeam[] + + @@unique([organizationId, name]) +} + +enum TeamUserRole { + admin + contributor +} + +model TeamUser { + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + teamId String @map(name: "team_id") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + userId String @map(name: "user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role TeamUserRole + + @@id([teamId, userId]) + @@index([userId]) +} + +enum ProductTeamPermission { + read + readWrite + manage +} + +model ProductTeam { + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + productId String @map(name: "product_id") + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + teamId String @map(name: "team_id") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + permission ProductTeamPermission @default(read) + + @@id([productId, teamId]) + @@index([teamId]) +} diff --git a/packages/ee/advanced-targeting/lib/actions.ts b/packages/ee/advanced-targeting/lib/actions.ts deleted file mode 100644 index 2366330b0d..0000000000 --- a/packages/ee/advanced-targeting/lib/actions.ts +++ /dev/null @@ -1,148 +0,0 @@ -"use server"; - -import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { - getOrganizationIdFromEnvironmentId, - getOrganizationIdFromSegmentId, - getOrganizationIdFromSurveyId, -} from "@formbricks/lib/organization/utils"; -import { - cloneSegment, - createSegment, - deleteSegment, - resetSegmentInSurvey, - updateSegment, -} from "@formbricks/lib/segment/service"; -import { loadNewSegmentInSurvey } from "@formbricks/lib/survey/service"; -import { ZId } from "@formbricks/types/common"; -import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment"; - -export const createSegmentAction = authenticatedActionClient - .schema(ZSegmentCreateInput) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - rules: ["segment", "create"], - }); - - const parsedFilters = ZSegmentFilters.safeParse(parsedInput.filters); - - if (!parsedFilters.success) { - const errMsg = - parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters"; - throw new Error(errMsg); - } - - return await createSegment(parsedInput); - }); - -const ZUpdateSegmentAction = z.object({ - segmentId: ZId, - data: ZSegmentUpdateInput, -}); - -export const updateSegmentAction = authenticatedActionClient - .schema(ZUpdateSegmentAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - data: parsedInput.data, - schema: ZSegmentUpdateInput, - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId), - rules: ["segment", "update"], - }); - - const { filters } = parsedInput.data; - if (filters) { - const parsedFilters = ZSegmentFilters.safeParse(filters); - - if (!parsedFilters.success) { - const errMsg = - parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message || "Invalid filters"; - throw new Error(errMsg); - } - } - - return await updateSegment(parsedInput.segmentId, parsedInput.data); - }); - -const ZLoadNewSegmentAction = z.object({ - surveyId: ZId, - segmentId: ZId, -}); - -export const loadNewSegmentAction = authenticatedActionClient - .schema(ZLoadNewSegmentAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "update"], - }); - - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId), - rules: ["segment", "read"], - }); - - return await loadNewSegmentInSurvey(parsedInput.surveyId, parsedInput.segmentId); - }); - -const ZCloneSegmentAction = z.object({ - segmentId: ZId, - surveyId: ZId, -}); - -export const cloneSegmentAction = authenticatedActionClient - .schema(ZCloneSegmentAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["segment", "create"], - }); - - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId), - rules: ["segment", "create"], - }); - - return await cloneSegment(parsedInput.segmentId, parsedInput.surveyId); - }); - -const ZDeleteSegmentAction = z.object({ - segmentId: ZId, -}); - -export const deleteSegmentAction = authenticatedActionClient - .schema(ZDeleteSegmentAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId), - rules: ["segment", "delete"], - }); - - return await deleteSegment(parsedInput.segmentId); - }); - -const ZResetSegmentFiltersAction = z.object({ - surveyId: ZId, -}); - -export const resetSegmentFiltersAction = authenticatedActionClient - .schema(ZResetSegmentFiltersAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - rules: ["survey", "update"], - }); - - return await resetSegmentInSurvey(parsedInput.surveyId); - }); diff --git a/packages/ee/role-management/components/edit-membership-role.tsx b/packages/ee/role-management/components/edit-membership-role.tsx deleted file mode 100644 index 5d356cd9da..0000000000 --- a/packages/ee/role-management/components/edit-membership-role.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; - -import { ChevronDownIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import toast from "react-hot-toast"; -import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; -import type { TMembershipRole } from "@formbricks/types/memberships"; -import { Badge } from "@formbricks/ui/components/Badge"; -import { Button } from "@formbricks/ui/components/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@formbricks/ui/components/DropdownMenu"; -import { transferOwnershipAction, updateInviteAction, updateMembershipAction } from "../lib/actions"; -import { TransferOwnershipModal } from "./transfer-ownership-modal"; - -interface Role { - isAdminOrOwner: boolean; - memberRole: TMembershipRole; - organizationId: string; - memberId?: string; - memberName: string; - userId: string; - memberAccepted: boolean; - inviteId?: string; - currentUserRole: string; -} - -export function EditMembershipRole({ - isAdminOrOwner, - memberRole, - organizationId, - memberId, - memberName, - userId, - memberAccepted, - inviteId, - currentUserRole, -}: Role) { - const t = useTranslations(); - const router = useRouter(); - const [loading, setLoading] = useState(false); - const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false); - - const disableRole = - memberRole && memberId && userId ? memberRole === "owner" || memberId === userId : false; - - const handleMemberRoleUpdate = async (role: TMembershipRole) => { - setLoading(true); - - try { - if (memberAccepted && memberId) { - await updateMembershipAction({ userId: memberId, organizationId, data: { role } }); - } - - if (inviteId) { - await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } }); - } - } catch (error) { - toast.error(t("common.something_went_wrong_please_try_again")); - } - - setLoading(false); - router.refresh(); - }; - - const handleOwnershipTransfer = async () => { - setLoading(true); - try { - if (memberId) { - await transferOwnershipAction({ organizationId, newOwnerId: memberId }); - } - - setLoading(false); - setTransferOwnershipModalOpen(false); - toast.success(t("environments.settings.general.ownership_transferred_successfully")); - router.refresh(); - } catch (err: any) { - toast.error(`${t("common.error")}: ${err.message}`); - setLoading(false); - setTransferOwnershipModalOpen(false); - } - }; - - const handleRoleChange = (role: TMembershipRole) => { - if (role === "owner") { - setTransferOwnershipModalOpen(true); - } else { - handleMemberRoleUpdate(role); - } - }; - - const getMembershipRoles = () => { - const roles = ["owner", "admin", "editor", "developer", "viewer"]; - if (currentUserRole === "owner" && memberAccepted) { - return roles; - } - - return roles.filter((role) => role !== "owner"); - }; - - if (isAdminOrOwner) { - return ( - <> - - - - - {!disableRole && ( - - { - handleRoleChange(value.toLowerCase() as TMembershipRole); - }} - value={capitalizeFirstLetter(memberRole)}> - {getMembershipRoles().map((role) => ( - - {role.toLowerCase()} - - ))} - - - )} - - - - ); - } - - return ; -} diff --git a/packages/ee/role-management/components/transfer-ownership-modal.tsx b/packages/ee/role-management/components/transfer-ownership-modal.tsx deleted file mode 100644 index d7200e5271..0000000000 --- a/packages/ee/role-management/components/transfer-ownership-modal.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import type { Dispatch, SetStateAction } from "react"; -import { useState } from "react"; -import { CustomDialog } from "@formbricks/ui/components/CustomDialog"; -import { Input } from "@formbricks/ui/components/Input"; - -interface TransferOwnershipModalProps { - open: boolean; - setOpen: Dispatch>; - memberName: string; - onSubmit: () => void; - isLoading?: boolean; -} - -export function TransferOwnershipModal({ - setOpen, - open, - memberName, - onSubmit, - isLoading, -}: TransferOwnershipModalProps) { - const [inputValue, setInputValue] = useState(""); - const t = useTranslations(); - const handleInputChange = (e: React.ChangeEvent) => { - setInputValue(e.target.value); - }; - - return ( - -
-
    -
  • - {t("environments.settings.general.there_can_only_be_one_owner_of_each_organization")} - {memberName},{" "} - {t("environments.settings.general.you_will_lose_all_of_your_ownership_rights")} -
  • -
  • - {t( - "environments.settings.general.when_you_transfer_the_ownership_you_will_remain_an_admin_of_the_organization" - )} -
  • -
-
- - -
-
-
- ); -} diff --git a/packages/ee/role-management/lib/actions.ts b/packages/ee/role-management/lib/actions.ts deleted file mode 100644 index a3c32b7968..0000000000 --- a/packages/ee/role-management/lib/actions.ts +++ /dev/null @@ -1,84 +0,0 @@ -"use server"; - -import { z } from "zod"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; -import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { isOwner } from "@formbricks/lib/auth"; -import { updateInvite } from "@formbricks/lib/invite/service"; -import { - getMembershipByUserIdOrganizationId, - transferOwnership, - updateMembership, -} from "@formbricks/lib/membership/service"; -import { ZId, ZUuid } from "@formbricks/types/common"; -import { AuthorizationError, ValidationError } from "@formbricks/types/errors"; -import { ZInviteUpdateInput } from "@formbricks/types/invites"; -import { ZMembershipUpdateInput } from "@formbricks/types/memberships"; - -const ZTransferOwnershipAction = z.object({ - organizationId: ZId, - newOwnerId: ZId, -}); - -export const transferOwnershipAction = authenticatedActionClient - .schema(ZTransferOwnershipAction) - .action(async ({ ctx, parsedInput }) => { - const isUserOwner = await isOwner(ctx.user.id, parsedInput.organizationId); - if (!isUserOwner) { - throw new AuthorizationError("Not authorized"); - } - - if (parsedInput.newOwnerId === ctx.user.id) { - throw new ValidationError("You are already the owner of this organization"); - } - - const membership = await getMembershipByUserIdOrganizationId( - parsedInput.newOwnerId, - parsedInput.organizationId - ); - if (!membership) { - throw new ValidationError("User is not a member of this organization"); - } - - await transferOwnership(ctx.user.id, parsedInput.newOwnerId, parsedInput.organizationId); - }); - -const ZUpdateInviteAction = z.object({ - inviteId: ZUuid, - organizationId: ZId, - data: ZInviteUpdateInput, -}); - -export const updateInviteAction = authenticatedActionClient - .schema(ZUpdateInviteAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - data: parsedInput.data, - schema: ZInviteUpdateInput, - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - rules: ["invite", "update"], - }); - - return await updateInvite(parsedInput.inviteId, parsedInput.data); - }); - -const ZUpdateMembershipAction = z.object({ - userId: ZId, - organizationId: ZId, - data: ZMembershipUpdateInput, -}); - -export const updateMembershipAction = authenticatedActionClient - .schema(ZUpdateMembershipAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorization({ - data: parsedInput.data, - schema: ZMembershipUpdateInput, - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - rules: ["membership", "update"], - }); - - return await updateMembership(parsedInput.userId, parsedInput.organizationId, parsedInput.data); - }); diff --git a/packages/email/.eslintrc.js b/packages/email/.eslintrc.js deleted file mode 100644 index 4d8dbbccec..0000000000 --- a/packages/email/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - extends: ["@formbricks/eslint-config/react.js"], - parserOptions: { - project: "tsconfig.json", - tsconfigRootDir: __dirname, - }, -}; diff --git a/packages/email/package.json b/packages/email/package.json deleted file mode 100644 index d05bcf0bf9..0000000000 --- a/packages/email/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@formbricks/email", - "version": "1.0.0", - "description": "Email package", - "main": "./index.tsx", - "scripts": { - "dev": "email dev --port 3003", - "clean": "rimraf .turbo node_modules dist", - "lint": "eslint --ext .ts,.tsx --fix ." - }, - "dependencies": { - "@formbricks/config-typescript": "workspace:*", - "@formbricks/lib": "workspace:*", - "@formbricks/types": "workspace:*", - "@formbricks/ui": "workspace:*", - "@react-email/components": "0.0.25", - "@react-email/render": "1.0.1", - "lucide-react": "0.452.0", - "nodemailer": "6.9.15" - }, - "devDependencies": { - "@types/nodemailer": "6.4.16", - "@types/react": "18.3.11", - "react-email": "2.1.6" - } -} diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json deleted file mode 100644 index e85feff065..0000000000 --- a/packages/email/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["./*"] - }, - "resolveJsonModule": true, - "strictNullChecks": true - }, - "exclude": ["dist", "build", "node_modules"], - "extends": "@formbricks/config-typescript/nextjs.json", - "include": [".", "../../packages/types/*.d.ts"] -} diff --git a/packages/lib/actionClass/auth.ts b/packages/lib/actionClass/auth.ts index 358311f85e..c00f0a5579 100644 --- a/packages/lib/actionClass/auth.ts +++ b/packages/lib/actionClass/auth.ts @@ -2,9 +2,6 @@ import "server-only"; import { ZId } from "@formbricks/types/common"; import { cache } from "../cache"; import { hasUserEnvironmentAccess } from "../environment/auth"; -import { getMembershipByUserIdOrganizationId } from "../membership/service"; -import { getAccessFlags } from "../membership/utils"; -import { getOrganizationByEnvironmentId } from "../organization/service"; import { validateInputs } from "../utils/validate"; import { actionClassCache } from "./cache"; import { getActionClass } from "./service"; @@ -35,34 +32,3 @@ export const canUserUpdateActionClass = (userId: string, actionClassId: string): tags: [actionClassCache.tag.byId(actionClassId)], } )(); - -export const verifyUserRoleAccess = async ( - environmentId: string, - userId: string -): Promise<{ - hasCreateOrUpdateAccess: boolean; - hasDeleteAccess: boolean; -}> => { - try { - const accessObject = { - hasCreateOrUpdateAccess: true, - hasDeleteAccess: true, - }; - - const organization = await getOrganizationByEnvironmentId(environmentId); - if (!organization) { - throw new Error("Organization not found"); - } - - const currentUserMembership = await getMembershipByUserIdOrganizationId(userId, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); - - if (isViewer) { - accessObject.hasCreateOrUpdateAccess = false; - accessObject.hasDeleteAccess = false; - } - return accessObject; - } catch (error) { - throw error; - } -}; diff --git a/packages/lib/actionClient/helper.ts b/packages/lib/actionClient/helper.ts deleted file mode 100644 index b303a12f3f..0000000000 --- a/packages/lib/actionClient/helper.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const getFormattedErrorMessage = (result) => { - let message = ""; - - if (result.serverError) { - message = result.serverError; - } else { - const errors = result.validationErrors; - message = Object.keys(errors || {}) - .map((key) => { - if (key === "_errors") return errors[key].join(", "); - return `${key ? `${key}` : ""}${errors?.[key]?._errors?.join(", ")}`; - }) - .join("\n"); - } - - return message; -}; diff --git a/packages/lib/actionClient/permissions.ts b/packages/lib/actionClient/permissions.ts index 32a16b43a8..dab4572845 100644 --- a/packages/lib/actionClient/permissions.ts +++ b/packages/lib/actionClient/permissions.ts @@ -1,5 +1,3 @@ -import { ZProductUpdateInput } from "@formbricks/types/product"; - export const Permissions = { owner: { environment: { @@ -22,6 +20,7 @@ export const Permissions = { delete: true, }, person: { + read: true, delete: true, }, response: { @@ -31,8 +30,8 @@ export const Permissions = { }, survey: { create: true, - update: true, read: true, + update: true, delete: true, }, tag: { @@ -67,7 +66,6 @@ export const Permissions = { }, apiKey: { create: true, - update: true, delete: true, }, subscription: { @@ -87,9 +85,26 @@ export const Permissions = { update: true, delete: true, }, + team: { + create: true, + read: true, + update: true, + delete: true, + }, + teamMembership: { + create: true, + read: true, + update: true, + delete: true, + }, + productTeam: { + create: true, + update: true, + delete: true, + }, }, - admin: { + manager: { environment: { read: true, }, @@ -110,6 +125,7 @@ export const Permissions = { delete: true, }, person: { + read: true, delete: true, }, response: { @@ -155,7 +171,6 @@ export const Permissions = { }, apiKey: { create: true, - update: true, delete: true, }, subscription: { @@ -175,17 +190,34 @@ export const Permissions = { update: true, delete: true, }, + team: { + create: true, + read: true, + update: true, + delete: true, + }, + teamMembership: { + create: true, + read: true, + update: true, + delete: true, + }, + productTeam: { + create: true, + update: true, + delete: true, + }, }, - editor: { + billing: { environment: { read: true, }, product: { create: false, read: true, - update: true, - delete: true, + update: false, + delete: false, }, organization: { read: true, @@ -198,60 +230,60 @@ export const Permissions = { delete: false, }, person: { - delete: true, + read: false, + delete: false, }, response: { - read: true, - update: true, - delete: true, + read: false, + update: false, + delete: false, }, survey: { - create: true, - read: true, - update: true, - delete: true, - }, - tag: { - create: true, - update: true, - delete: true, - }, - responseNote: { - create: true, - update: true, - delete: true, - }, - segment: { - create: true, - read: true, - update: true, - delete: true, - }, - actionClass: { - create: true, - delete: true, - }, - integration: { - create: true, - update: true, - delete: true, - }, - webhook: { - create: true, - update: true, - delete: true, - }, - apiKey: { - create: true, - update: true, - delete: true, - }, - subscription: { create: false, read: false, update: false, delete: false, }, + tag: { + create: false, + update: false, + delete: false, + }, + responseNote: { + create: false, + update: false, + delete: false, + }, + segment: { + create: false, + read: false, + update: false, + delete: false, + }, + actionClass: { + create: false, + delete: false, + }, + integration: { + create: false, + update: false, + delete: false, + }, + webhook: { + create: false, + update: false, + delete: false, + }, + apiKey: { + create: false, + delete: false, + }, + subscription: { + create: true, + read: true, + update: true, + delete: true, + }, invite: { create: false, read: false, @@ -263,106 +295,33 @@ export const Permissions = { update: false, delete: false, }, - }, - - developer: { - environment: { - read: true, - }, - product: { + team: { create: false, - read: true, - update: ZProductUpdateInput.omit({ - name: true, - }), - delete: true, - }, - organization: { read: true, update: false, delete: false, }, - membership: { - create: false, - update: false, - delete: false, - }, - person: { - delete: true, - }, - response: { - read: true, - update: true, - delete: true, - }, - survey: { - create: true, - read: true, - update: true, - delete: true, - }, - tag: { - create: true, - update: true, - delete: true, - }, - responseNote: { - create: true, - update: true, - delete: true, - }, - segment: { - create: true, - read: true, - update: true, - delete: true, - }, - actionClass: { - create: true, - delete: true, - }, - integration: { - create: true, - update: true, - delete: true, - }, - webhook: { - create: true, - update: true, - delete: true, - }, - apiKey: { - create: true, - update: true, - delete: true, - }, - subscription: { + teamMembership: { create: false, read: false, update: false, delete: false, }, - invite: { - create: false, - read: false, - update: false, - delete: false, - }, - language: { + productTeam: { create: false, update: false, delete: false, }, }, - viewer: { + member: { environment: { read: true, }, product: { create: false, read: true, - update: false, + update: true, delete: false, }, organization: { @@ -376,53 +335,53 @@ export const Permissions = { delete: false, }, person: { - delete: false, + read: true, + delete: true, }, response: { read: true, - update: false, - delete: false, + update: true, + delete: true, }, survey: { - create: false, + create: true, read: true, - update: false, - delete: false, + update: true, + delete: true, }, tag: { - create: false, - update: false, - delete: false, + create: true, + update: true, + delete: true, }, responseNote: { - create: false, - update: false, - delete: false, + create: true, + update: true, + delete: true, }, segment: { - create: false, + create: true, read: true, - update: false, - delete: false, + update: true, + delete: true, }, actionClass: { - create: false, - delete: false, + create: true, + delete: true, }, integration: { - create: false, + create: true, update: true, - delete: false, + delete: true, }, webhook: { - create: false, - update: false, - delete: false, + create: true, + update: true, + delete: true, }, apiKey: { - create: false, - update: false, - delete: false, + create: true, + delete: true, }, subscription: { create: false, @@ -437,6 +396,23 @@ export const Permissions = { delete: false, }, language: { + create: true, + update: true, + delete: true, + }, + team: { + create: false, + read: true, + update: false, + delete: false, + }, + teamMembership: { + create: true, + read: true, + update: true, + delete: true, + }, + productTeam: { create: false, update: false, delete: false, diff --git a/packages/lib/actionClient/utils.ts b/packages/lib/actionClient/utils.ts deleted file mode 100644 index fac3e14adb..0000000000 --- a/packages/lib/actionClient/utils.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { returnValidationErrors } from "next-safe-action"; -import { ZodIssue, z } from "zod"; -import { TOperation, TResource } from "@formbricks/types/action-client"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { TMembershipRole } from "@formbricks/types/memberships"; -import { getMembershipRole } from "../membership/hooks/actions"; -import { Permissions } from "./permissions"; - -export const getOperationPermissions = (role: TMembershipRole, entity: TResource, operation: TOperation) => { - const permission = Permissions[role][entity][operation]; - - if (typeof permission === "boolean" && !permission) { - throw new AuthorizationError("Not authorized"); - } - - return permission; -}; - -export const getRoleBasedSchema = ( - schema: z.ZodObject, - role: TMembershipRole, - entity: TResource, - operation: TOperation -): z.ZodObject => { - const data = getOperationPermissions(role, entity, operation); - - return typeof data === "boolean" && data === true ? schema.strict() : data; -}; - -export const formatErrors = (issues: ZodIssue[]): Record => { - return { - ...issues.reduce((acc, issue) => { - acc[issue.path.join(".")] = { - _errors: [issue.message], - }; - return acc; - }, {}), - }; -}; - -export const checkAuthorization = async ({ - schema, - data, - userId, - organizationId, - rules, -}: { - schema?: z.ZodObject; - data?: z.ZodObject["_output"]; - userId: string; - organizationId: string; - rules: [TResource, TOperation]; -}) => { - const role = await getMembershipRole(userId, organizationId); - if (schema) { - const resultSchema = getRoleBasedSchema(schema, role, ...rules); - const parsedResult = resultSchema.safeParse(data); - if (!parsedResult.success) { - // @ts-expect-error -- TODO: match dynamic next-safe-action types - return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues)); - } - } else { - getOperationPermissions(role, ...rules); - } -}; diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index 23d18d1028..27de87a074 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -29,7 +29,7 @@ export const hasOrganizationAccess = async (userId: string, organizationId: stri return false; }; -export const isAdminOrOwner = async (userId: string, organizationId: string) => { +export const isManagerOrOwner = async (userId: string, organizationId: string) => { const membership = await prisma.membership.findUnique({ where: { userId_organizationId: { @@ -39,7 +39,7 @@ export const isAdminOrOwner = async (userId: string, organizationId: string) => }, }); - if (membership && (membership.role === "admin" || membership.role === "owner")) { + if (membership && (membership.role === "owner" || membership.role === "manager")) { return true; } @@ -69,9 +69,9 @@ export const hasOrganizationAuthority = async (userId: string, organizationId: s throw new AuthenticationError("Not authorized"); } - const isAdminOrOwnerAccess = await isAdminOrOwner(userId, organizationId); - if (!isAdminOrOwnerAccess) { - throw new AuthenticationError("You are not the admin or owner of this organization"); + const isManagerOrOwnerAccess = await isManagerOrOwner(userId, organizationId); + if (!isManagerOrOwnerAccess) { + throw new AuthenticationError("You are not the manager or owner of this organization"); } return true; diff --git a/packages/lib/authOptions.ts b/packages/lib/authOptions.ts index 1db7c96c85..d11e136b58 100644 --- a/packages/lib/authOptions.ts +++ b/packages/lib/authOptions.ts @@ -273,8 +273,8 @@ export const authOptions: NextAuthOptions = { }); isNewOrganization = true; } - const role = isNewOrganization ? "owner" : DEFAULT_ORGANIZATION_ROLE || "admin"; - await createMembership(organization.id, userProfile.id, { role, accepted: true }); + const role = isNewOrganization ? "owner" : DEFAULT_ORGANIZATION_ROLE || "manager"; + await createMembership(organization.id, userProfile.id, { role: role, accepted: true }); await createAccount({ ...account, userId: userProfile.id, diff --git a/packages/lib/env.ts b/packages/lib/env.ts index 2341aa8cd8..d4a3ca3687 100644 --- a/packages/lib/env.ts +++ b/packages/lib/env.ts @@ -23,7 +23,7 @@ export const env = createEnv({ DATABASE_URL: z.string().url(), DEBUG: z.enum(["1", "0"]).optional(), DEFAULT_ORGANIZATION_ID: z.string().optional(), - DEFAULT_ORGANIZATION_ROLE: z.enum(["owner", "admin", "editor", "developer", "viewer"]).optional(), + DEFAULT_ORGANIZATION_ROLE: z.enum(["owner", "manager", "member", "billing"]).optional(), E2E_TESTING: z.enum(["1", "0"]).optional(), EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(), EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(), diff --git a/packages/lib/environment/auth.ts b/packages/lib/environment/auth.ts index 8ae4a2b371..27c1ee1b12 100644 --- a/packages/lib/environment/auth.ts +++ b/packages/lib/environment/auth.ts @@ -12,18 +12,42 @@ export const hasUserEnvironmentAccess = async (userId: string, environmentId: st validateInputs([userId, ZId], [environmentId, ZId]); try { - const environment = await prisma.environment.findUnique({ + const orgMembership = await prisma.membership.findFirst({ where: { - id: environmentId, + userId, + organization: { + products: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, }, - select: { - product: { - select: { - organization: { - select: { - memberships: { - select: { - userId: true, + }); + + if (!orgMembership) return false; + + if ( + orgMembership.role === "owner" || + orgMembership.role === "manager" || + orgMembership.role === "billing" + ) + return true; + + const teamMembership = await prisma.teamUser.findFirst({ + where: { + userId, + team: { + productTeams: { + some: { + product: { + environments: { + some: { + id: environmentId, }, }, }, @@ -33,9 +57,9 @@ export const hasUserEnvironmentAccess = async (userId: string, environmentId: st }, }); - const environmentUsers = - environment?.product.organization.memberships.map((member) => member.userId) || []; - return environmentUsers.includes(userId); + if (teamMembership) return true; + + return false; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); diff --git a/packages/lib/environment/service.ts b/packages/lib/environment/service.ts index 0c3c3ad56d..7ccc42b4f6 100644 --- a/packages/lib/environment/service.ts +++ b/packages/lib/environment/service.ts @@ -18,7 +18,7 @@ import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbric import { cache } from "../cache"; import { getOrganizationsByUserId } from "../organization/service"; import { capturePosthogEnvironmentEvent } from "../posthogServer"; -import { getProducts } from "../product/service"; +import { getUserProducts } from "../product/service"; import { validateInputs } from "../utils/validate"; import { environmentCache } from "./cache"; @@ -128,14 +128,14 @@ export const updateEnvironment = async ( } }; -export const getFirstEnvironmentByUserId = async (userId: string): Promise => { +export const getFirstEnvironmentIdByUserId = async (userId: string): Promise => { try { const organizations = await getOrganizationsByUserId(userId); if (organizations.length === 0) { throw new Error(`Unable to get first environment: User ${userId} has no organizations`); } const firstOrganization = organizations[0]; - const products = await getProducts(firstOrganization.id); + const products = await getUserProducts(userId, firstOrganization.id); if (products.length === 0) { throw new Error( `Unable to get first environment: Organization ${firstOrganization.id} has no products` @@ -150,7 +150,7 @@ export const getFirstEnvironmentByUserId = async (userId: string): Promise { - const [membershipRole, setMembershipRole] = useState(); + const [membershipRole, setMembershipRole] = useState(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); @@ -19,7 +12,7 @@ export const useMembershipRole = (environmentId: string) => { try { setIsLoading(true); const role = await getMembershipByUserIdOrganizationIdAction(environmentId); - setMembershipRole(role as MembershipRole); + setMembershipRole(role); setIsLoading(false); } catch (err: any) { const error = err?.message || "Something went wrong"; diff --git a/packages/lib/membership/service.ts b/packages/lib/membership/service.ts index c5dcc1ff58..c935779e16 100644 --- a/packages/lib/membership/service.ts +++ b/packages/lib/membership/service.ts @@ -2,71 +2,13 @@ import "server-only"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { ZOptionalNumber, ZString } from "@formbricks/types/common"; -import { DatabaseError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors"; -import { - TMember, - TMembership, - TMembershipUpdateInput, - ZMembership, - ZMembershipUpdateInput, -} from "@formbricks/types/memberships"; +import { ZString } from "@formbricks/types/common"; +import { DatabaseError, UnknownError } from "@formbricks/types/errors"; +import { TMembership, ZMembership } from "@formbricks/types/memberships"; import { cache } from "../cache"; -import { ITEMS_PER_PAGE } from "../constants"; +import { membershipCache } from "../membership/cache"; import { organizationCache } from "../organization/cache"; import { validateInputs } from "../utils/validate"; -import { membershipCache } from "./cache"; - -export const getMembersByOrganizationId = reactCache( - (organizationId: string, page?: number): Promise => - cache( - async () => { - validateInputs([organizationId, ZString], [page, ZOptionalNumber]); - - try { - const membersData = await prisma.membership.findMany({ - where: { organizationId }, - select: { - user: { - select: { - name: true, - email: true, - }, - }, - userId: true, - accepted: true, - role: true, - }, - take: page ? ITEMS_PER_PAGE : undefined, - skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, - }); - - const members = membersData.map((member) => { - return { - name: member.user?.name || "", - email: member.user?.email || "", - userId: member.userId, - accepted: member.accepted, - role: member.role, - }; - }); - - return members; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - console.error(error); - throw new DatabaseError(error.message); - } - - throw new UnknownError("Error while fetching members"); - } - }, - [`getMembersByOrganizationId-${organizationId}-${page}`], - { - tags: [membershipCache.tag.byOrganizationId(organizationId)], - } - )() -); export const getMembershipByUserIdOrganizationId = reactCache( (userId: string, organizationId: string): Promise => @@ -103,37 +45,6 @@ export const getMembershipByUserIdOrganizationId = reactCache( )() ); -export const getMembershipsByUserId = reactCache( - (userId: string, page?: number): Promise => - cache( - async () => { - validateInputs([userId, ZString], [page, ZOptionalNumber]); - - try { - const memberships = await prisma.membership.findMany({ - where: { - userId, - }, - take: page ? ITEMS_PER_PAGE : undefined, - skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, - }); - - return memberships; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getMembershipsByUserId-${userId}-${page}`], - { - tags: [membershipCache.tag.byUserId(userId)], - } - )() -); - export const createMembership = async ( organizationId: string, userId: string, @@ -168,127 +79,3 @@ export const createMembership = async ( throw error; } }; - -export const updateMembership = async ( - userId: string, - organizationId: string, - data: TMembershipUpdateInput -): Promise => { - validateInputs([userId, ZString], [organizationId, ZString], [data, ZMembershipUpdateInput]); - - try { - const membership = await prisma.membership.update({ - where: { - userId_organizationId: { - userId, - organizationId, - }, - }, - data, - }); - - organizationCache.revalidate({ - userId, - }); - - membershipCache.revalidate({ - userId, - organizationId, - }); - - return membership; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") { - throw new ResourceNotFoundError("Membership", `userId: ${userId}, organizationId: ${organizationId}`); - } - - throw error; - } -}; - -export const deleteMembership = async (userId: string, organizationId: string): Promise => { - validateInputs([userId, ZString], [organizationId, ZString]); - - try { - const deletedMembership = await prisma.membership.delete({ - where: { - userId_organizationId: { - organizationId, - userId, - }, - }, - }); - - organizationCache.revalidate({ - userId, - }); - - membershipCache.revalidate({ - userId, - organizationId, - }); - - return deletedMembership; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } -}; - -export const transferOwnership = async ( - currentOwnerId: string, - newOwnerId: string, - organizationId: string -): Promise => { - validateInputs([currentOwnerId, ZString], [newOwnerId, ZString], [organizationId, ZString]); - - try { - const memberships = await prisma.$transaction([ - prisma.membership.update({ - where: { - userId_organizationId: { - organizationId, - userId: currentOwnerId, - }, - }, - data: { - role: "admin", - }, - }), - prisma.membership.update({ - where: { - userId_organizationId: { - organizationId, - userId: newOwnerId, - }, - }, - data: { - role: "owner", - }, - }), - ]); - - memberships.forEach((membership) => { - organizationCache.revalidate({ - userId: membership.userId, - }); - - membershipCache.revalidate({ - userId: membership.userId, - organizationId: membership.organizationId, - }); - }); - - return memberships; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - const message = error instanceof Error ? error.message : ""; - throw new UnknownError(`Error while transfering ownership: ${message}`); - } -}; diff --git a/packages/lib/membership/utils.ts b/packages/lib/membership/utils.ts index 4a39b50e4b..404cf3829f 100644 --- a/packages/lib/membership/utils.ts +++ b/packages/lib/membership/utils.ts @@ -1,17 +1,15 @@ -import { TMembershipRole } from "@formbricks/types/memberships"; +import { TOrganizationRole } from "@formbricks/types/memberships"; -export const getAccessFlags = (role?: TMembershipRole) => { - const isAdmin = role === "admin"; - const isEditor = role === "editor"; +export const getAccessFlags = (role?: TOrganizationRole) => { const isOwner = role === "owner"; - const isDeveloper = role === "developer"; - const isViewer = role === "viewer"; + const isManager = role === "manager"; + const isBilling = role === "billing"; + const isMember = role === "member"; return { - isAdmin, - isEditor, + isManager, isOwner, - isDeveloper, - isViewer, + isBilling, + isMember, }; }; diff --git a/packages/lib/messages/de-DE.json b/packages/lib/messages/de-DE.json index 7ae2c17281..07f309568c 100644 --- a/packages/lib/messages/de-DE.json +++ b/packages/lib/messages/de-DE.json @@ -25,8 +25,6 @@ "reset_password": "Passwort zurücksetzen" }, "invite": { - "already_part_of_squad": "Du bist schon Teil des Teams.", - "already_part_of_squad_description": "Diese Einladung wurde bereits verwendet.", "contact_support": "Support kontaktieren", "create_account": "Konto erstellen", "email_does_not_match": "Ooops! Falsche E-Mail-Adresse", @@ -104,6 +102,7 @@ "actions": "Aktionen", "active_surveys": "Aktive Umfragen", "activity": "Aktivität", + "add": "Hinzufügen", "add_action": "Aktion hinzufügen", "add_filter": "Filter hinzufügen", "add_logo": "Logo hinzufügen", @@ -189,6 +188,7 @@ "error_component_title": "Fehler beim Laden der Ressourcen", "expand_rows": "Zeilen erweitern", "experience": "Experience", + "failed_to_get_first_environment_of_user": "Fehler beim Abrufen der ersten Umgebung des Benutzers", "filters_reset_successfully": "Filter erfolgreich zurückgesetzt", "finish": "Fertigstellen", "follow_these": "Folge diesen", @@ -238,6 +238,8 @@ "look_and_feel": "Darstellung", "manage": "Verwalten", "marketing": "Marketing", + "member": "Mitglied", + "members": "Mitglieder", "membership_not_found": "Mitgliedschaft nicht gefunden", "meta": "Meta", "metadata": "Metadaten", @@ -267,10 +269,13 @@ "off": "Aus", "on": "An", "only_one_file_allowed": "Es ist nur eine Datei erlaubt", - "only_owners_admins_and_editors_can_perform_this_action": "Nur Eigentümer, Admins und Redakteure können diese Aktion ausführen.", + "only_organization_owners_and_managers_can_access_this_setting": "Nur Organisationsbesitzer und Manager haben Zugriff auf diese Einstellung.", + "only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.", + "only_owners_managers_and_team_admins_can_perform_this_action": "Nur Eigentümer, Manager und Team-Admins können diese Aktion ausführen.", "or": "oder", "organization": "Organisation", "organization_not_found": "Organisation nicht gefunden", + "organization_teams_not_found": "Organisations-Teams nicht gefunden", "other": "Andere", "other_filters": "Weitere Filter", "others": "Andere", @@ -297,6 +302,8 @@ "product_manager": "Produktmanager", "product_name": "Produktname", "product_not_found": "Produkt nicht gefunden", + "product_permission_not_found": "Produkt-Berechtigung nicht gefunden", + "products": "Produkte", "profile": "Profil", "question": "Frage", "question_id": "Frage-ID", @@ -358,6 +365,9 @@ "table_settings": "Tabelleinstellungen", "tags": "Tags", "targeting": "Targeting", + "team": "Team", + "team_access": "Teamzugriff", + "teams": "Teams", "text": "Text", "time": "Zeit", "time_to_finish": "Zeit zum Fertigstellen", @@ -530,6 +540,7 @@ "attributes": { "ex_user_property": "Benutzereigenschaft", "how_to_add_attributes": "Wie man Attribute hinzufügt", + "show_archived": "Archivierte anzeigen", "this_attribute_was_added_automatically_you_cannot_make_changes_to_it": "Dieses Attribut wurde automatisch hinzugefügt. Du kannst keine Änderungen daran vornehmen.", "this_is_a_code_attribute_you_can_only_change_the_description": "Das ist ein Code-Attribut. Du kannst nur die Beschreibung ändern." }, @@ -787,7 +798,7 @@ "delete_product_confirmation": "Bist Du sicher, dass Du {productName} löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", "delete_product_name_includes_surveys_responses_people_and_more": "Lösche {productName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute.", "delete_product_settings_description": "Produkt mit allen Umfragen, Antworten, Personen, Aktionen und Attributen löschen. Dies kann nicht rückgängig gemacht werden.", - "only_admin_or_owners_can_delete_products": "Nur Admins oder Besitzer können Produkte löschen.", + "only_owners_or_managers_can_delete_products": "Nur Eigentümer oder Manager können Produkte löschen", "organization_name": "Name der Organisation", "product_deleted_successfully": "Produkt erfolgreich gelöscht", "product_name_settings_description": "Ändere den Namen deines Produkts.", @@ -873,6 +884,20 @@ "tag_deleted": "Tag gelöscht", "tags_merged": "Tags zusammengeführt", "unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen" + }, + "teams": { + "add_existing_team": "Vorhandenes Team hinzufügen", + "create_new_team": "Neues Team erstellen", + "manage": "Verwalten", + "no_teams_found": "Keine Teams gefunden", + "permission": "Berechtigung", + "read": "Lesen", + "read_write": "Lesen & Schreiben", + "remove_access": "Zugang entfernen", + "remove_access_confirmation": "Sind Sie sicher, dass Sie den Zugang für dieses Team entfernen möchten?", + "select_teams": "Teams auswählen", + "team_name": "Teamname", + "team_settings_description": "Teams und ihre Mitglieder können auf dieses Produkt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren." } }, "products_environments_organizations_not_found": "Produkte, Umgebungen oder Organisationen nicht gefunden", @@ -1022,7 +1047,6 @@ "copy_invite_link_to_clipboard": "Einladungslink in die Zwischenablage kopieren", "create_new_organization": "Neue Organisation erstellen", "create_new_organization_description": "Erstelle eine neue Organisation, um weitere Produkte zu verwalten.", - "create_new_organization_title": "Organisation erstellen", "delete_organization": "Organisation löschen", "delete_organization_description": "Organisation mit allen Produkten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen", "delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:", @@ -1048,8 +1072,9 @@ "manage_members_description": "Mitglieder in deiner Organisation hinzufügen oder entfernen.", "member_deleted_successfully": "Mitglied erfolgreich gelöscht", "member_invited_successfully": "Mitglied erfolgreich eingeladen", + "member_role_info_message": "Mitglieder haben standardmäßig keinen Zugriff auf Produkte. Fügen Sie sie nach dem Beitritt zu Teams hinzu, um deren Zugriff zu verwalten.", "once_its_gone_its_gone": "Watt fott is, is fott.", - "only_owner_can_delete_organization": "Nur der Besitzer kann die Organisation löschen.", + "only_org_owner_can_perform_action": "Nur der Besitzer kann die Organisation löschen.", "organization_created_successfully": "Organisation erfolgreich erstellt!", "organization_deleted_successfully": "Organisation erfolgreich gelöscht.", "organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!", @@ -1128,6 +1153,54 @@ "upload_image": "Bild hochladen", "warning_cannot_undo": "Das kann nicht rückgängig gemacht werden", "you_must_select_a_file": "Du musst eine Datei auswählen." + }, + "teams": { + "add_member": "Mitglied hinzufügen", + "add_members": "Mitglieder hinzufügen", + "add_product": "Produkt hinzufügen", + "add_products": "Produkte hinzufügen", + "are_you_sure_you_want_to_delete_this_team": "Sind Sie sicher, dass Sie dieses Team löschen möchten? Dadurch wird auch der Zugriff auf alle Produkte und Umfragen entfernt, die mit diesem Team verbunden sind.", + "contributor": "Mitwirkender", + "create": "Erstellen", + "create_new_team": "Neues Team erstellen", + "delete_team": "Team löschen", + "empty_product_message": "Du hast noch keine Produkte hinzugefügt. Weise dem Team ein Produkt zu, um den Mitgliedern Zugriff zu gewähren.", + "empty_teams_state": "Du hast noch keine Teams. Erstelle dein erstes Team, um den Zugriff auf Produkte für die Mitglieder deiner Organisation zu verwalten.", + "enter_team_name": "Teamname eingeben", + "invite_member": "Mitglied einladen", + "join_team": "Team beitreten", + "leave": "Verlassen", + "leave_team": "Team verlassen", + "leave_team_confirmation": "Bist Du sicher, dass Du dieses Team verlassen möchtest?", + "manage": "Verwalten", + "member_removed_successfully": "Mitglied erfolgreich entfernt", + "members_added_successfully": "Mitglieder erfolgreich hinzugefügt", + "no_members_found": "Keine Mitglieder gefunden", + "no_other_teams_found": "Keine anderen Teams gefunden", + "org_owner_and_managers_can_only_be_team_admin": "Organisationsbesitzer und Manager können nur Team-Administratoren sein.", + "organization_members": "Organisationsmitglieder", + "organization_products": "Organisationsprodukte", + "permission": "Berechtigung", + "permission_updated_successfully": "Berechtigung erfolgreich aktualisiert.", + "product_name": "Produktname", + "product_removed_successfully": "Produkt erfolgreich entfernt.", + "read": "Lesen", + "read_write": "Lesen & Schreiben", + "remove": "Entfernen", + "remove_member_confirmation": "Sind Sie sicher, dass Sie dieses Mitglied entfernen möchten?", + "remove_product": "Produkt entfernen", + "remove_product_confirmation": "Sind Sie sicher, dass Sie dieses Produkt entfernen möchten?", + "role_updated_successfully": "Rolle erfolgreich aktualisiert", + "select_type": "Typ auswählen", + "team_admin": "Team-Admin", + "team_created_successfully": "Team erfolgreich erstellt.", + "team_deleted_successfully": "Team erfolgreich gelöscht.", + "team_members": "Teammitglieder", + "team_name": "Teamname", + "team_name_description": "Geben Sie Ihrem Team einen beschreibenden Namen.", + "team_products": "Teamprodukte", + "this_action_cannot_be_undone_if_it_s_gone_it_s_gone": "Diese Aktion kann nicht rückgängig gemacht werden. Wenn es weg ist, ist es weg.", + "your_team": "Dein Team" } }, "surveys": { @@ -1142,7 +1215,7 @@ "edit": { "1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:", "2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:", - "add": "Hinzufügen", + "add": "Hinzufügen +", "add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.", "add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu", "add_a_new_question_to_your_survey": "Neue Frage hinzufügen", @@ -1512,6 +1585,7 @@ "no_surveys_created_yet": "Noch keine Umfragen erstellt", "open_options": "Optionen öffnen", "preview_survey_in_a_new_tab": "Vorschau der Umfrage in einem neuen Tab", + "read_only_user_not_allowed_to_create_survey_warning": "Als Benutzer mit Nur-Lese-Rechten dürfen Sie keine Umfragen erstellen. Bitten Sie einen Benutzer mit Schreibrechten, eine Umfrage zu erstellen, oder einen Manager, Ihre Rolle zu aktualisieren.", "relevance": "Relevanz", "responses": { "address_line_1": "Adresszeile 1", @@ -1688,8 +1762,7 @@ "multiple_industries": "Mehrere Branchen", "use_this_template": "Vorlage verwenden", "uses_branching_logic": "Diese Umfrage verwendet Logik." - }, - "viewer_not_allowed_to_create_survey_warning": "Als Betrachter darfst Du keine Umfragen erstellen. Bitte frage einen Editor, eine Umfrage zu erstellen, oder einen Admin, deine Rolle zu aktualisieren." + } }, "xm-templates": { "ces": "CES", @@ -1712,6 +1785,11 @@ "healthy": "Alle Systeme laufen und sind betriebsbereit" }, "organizations": { + "landing": { + "create_organization": "Organisation erstellen", + "no_products_warning_subtitle": "Wenden Sie sich an den Eigentümer Ihrer Organisation, um Zugriff auf Produkte zu erhalten. Oder erstellen Sie eine eigene Organisation, um loszulegen.", + "no_products_warning_title": "Ihr Konto hat noch keinen Zugriff auf Produkte." + }, "products": { "new": { "channel": { @@ -1741,6 +1819,7 @@ "channel_settings_description": "Erhalte doppelt so viele Antworten mit Umfragen, die zu deiner Marke und UI passen.", "channel_settings_subtitle": "Wenn Leute deine Marke erkennen, ist es viel wahrscheinlicher, dass sie Umfragen beantworten und abschließen.", "channel_settings_title": "Passe deine Marke an, erhalte doppelt so viele Antworten.", + "create_new_team": "Neues Team erstellen", "link_channel_headline": "Du pflegst ein Produkt, wie aufregend!", "product_name": "Produktname", "product_name_description": "Wie heißt dein Produkt?", diff --git a/packages/lib/messages/en-US.json b/packages/lib/messages/en-US.json index 28e092eab6..259c435630 100644 --- a/packages/lib/messages/en-US.json +++ b/packages/lib/messages/en-US.json @@ -25,8 +25,6 @@ "reset_password": "Reset password" }, "invite": { - "already_part_of_squad": "You’re already part of the squad.", - "already_part_of_squad_description": "This invitation has already been used.", "contact_support": "Contact support", "create_account": "Create an account", "email_does_not_match": "Ooops! Wrong email 🤦", @@ -104,6 +102,7 @@ "actions": "Actions", "active_surveys": "Active surveys", "activity": "Activity", + "add": "Add", "add_action": "Add action", "add_filter": "Add filter", "add_logo": "Add logo", @@ -189,6 +188,7 @@ "error_component_title": "Error loading resources", "expand_rows": "Expand rows", "experience": "Experience", + "failed_to_get_first_environment_of_user": "Failed to get first environment of user", "filters_reset_successfully": "Filters reset successfully", "finish": "Finish", "follow_these": "Follow these", @@ -238,6 +238,8 @@ "look_and_feel": "Look & Feel", "manage": "Manage", "marketing": "Marketing", + "member": "Member", + "members": "Members", "membership_not_found": "Membership not found", "meta": "Meta", "metadata": "Metadata", @@ -267,10 +269,13 @@ "off": "Off", "on": "On", "only_one_file_allowed": "Only one file is allowed", - "only_owners_admins_and_editors_can_perform_this_action": "Only Owners, Admins and Editors can perform this action.", + "only_organization_owners_and_managers_can_access_this_setting": "Only organization owners and managers can access this setting.", + "only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners, managers and manage access members can perform this action.", + "only_owners_managers_and_team_admins_can_perform_this_action": "Only owners, managers and team admins can perform this action.", "or": "or", "organization": "Organization", "organization_not_found": "Organization not found", + "organization_teams_not_found": "Organization teams not found", "other": "Other", "other_filters": "Other filters", "others": "Others", @@ -297,6 +302,8 @@ "product_manager": "Product Manager", "product_name": "Product Name", "product_not_found": "Product not found", + "product_permission_not_found": "Product permission not found", + "products": "Products", "profile": "Profile", "question": "Question", "question_id": "Question ID", @@ -358,6 +365,9 @@ "table_settings": "Table settings", "tags": "Tags", "targeting": "Targeting", + "team": "Team", + "team_access": "Team Access", + "teams": "Teams", "text": "Text", "time": "Time", "time_to_finish": "Time to finish", @@ -530,6 +540,7 @@ "attributes": { "ex_user_property": "Ex. User property", "how_to_add_attributes": "How to add Attributes", + "show_archived": "Show archived", "this_attribute_was_added_automatically_you_cannot_make_changes_to_it": "This attribute was added automatically. You cannot make changes to it.", "this_is_a_code_attribute_you_can_only_change_the_description": "This is a code attribute. You can only change the description." }, @@ -787,7 +798,7 @@ "delete_product_confirmation": "Are you sure you want to delete {productName}? This action cannot be undone.", "delete_product_name_includes_surveys_responses_people_and_more": "Delete {productName} incl. all surveys, responses, people, actions and attributes.", "delete_product_settings_description": "Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.", - "only_admin_or_owners_can_delete_products": "Only Admin or Owners can delete products.", + "only_owners_or_managers_can_delete_products": "Only owners or managers can delete products", "organization_name": "Organization Name", "product_deleted_successfully": "Product deleted successfully", "product_name_settings_description": "Change your products name.", @@ -873,6 +884,20 @@ "tag_deleted": "Tag deleted", "tags_merged": "Tags merged", "unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields" + }, + "teams": { + "add_existing_team": "Add existing team", + "create_new_team": "Create new team", + "manage": "Manage", + "no_teams_found": "No teams found", + "permission": "Permission", + "read": "Read", + "read_write": "Read & write", + "remove_access": "Remove Access", + "remove_access_confirmation": "Are you sure you want to remove access for this team?", + "select_teams": "Select teams", + "team_name": "Team Name", + "team_settings_description": "Teams and their members can access this product and its surveys. Organization owners and managers can grant this access." } }, "products_environments_organizations_not_found": "Products, environments or organizations not found", @@ -1016,13 +1041,12 @@ "general": { "add_member": "Add member", "bulk_invite": "Bulk Invite", - "bulk_invite_warning_description": "Please note that on the Free Plan, all organization members are automatically assigned the \"Admin\" role regardless of the role specified in the CSV file.", + "bulk_invite_warning_description": "On the free plan, all organization members are always assigned the \"Admin\" role.", "cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.", "cannot_leave_only_organization": "You cannot leave this organization as it is your only organization. Create a new organization first.", "copy_invite_link_to_clipboard": "Copy invite link to clipboard", "create_new_organization": "Create new organization", "create_new_organization_description": "Create a new organization to handle a different set of products.", - "create_new_organization_title": "Create organization", "delete_organization": "Delete Organization", "delete_organization_description": "Delete organization with all its products including all surveys, responses, people, actions and attributes", "delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:", @@ -1048,8 +1072,9 @@ "manage_members_description": "Add or remove members in your organization.", "member_deleted_successfully": "Member deleted successfully", "member_invited_successfully": "Member invited successfully", + "member_role_info_message": "Members don't have access to products by default. Add them to Teams after joining to manage their access.", "once_its_gone_its_gone": "Once it's gone, it's gone.", - "only_owner_can_delete_organization": "Only Owner can delete the organization.", + "only_org_owner_can_perform_action": "Only organization owners can access this setting.", "organization_created_successfully": "Organization created successfully!", "organization_deleted_successfully": "Organization deleted successfully.", "organization_invite_link_ready": "Your organization invite link is ready!", @@ -1128,6 +1153,54 @@ "upload_image": "Upload image", "warning_cannot_undo": "This cannot be undone", "you_must_select_a_file": "You must select a file." + }, + "teams": { + "add_member": "Add Member", + "add_members": "Add members", + "add_product": "Add Product", + "add_products": "Add products", + "are_you_sure_you_want_to_delete_this_team": "Are you sure you want to delete this team? This also removes the access to all the products and surveys associated with this team.", + "contributor": "Contributor", + "create": "Create", + "create_new_team": "Create new team", + "delete_team": "Delete Team", + "empty_product_message": "You haven't added any products yet. Assign a product to the team to grant access to its members.", + "empty_teams_state": "You don’t have any teams yet. Create your first team to manage product access for members of your organization.", + "enter_team_name": "Enter team name", + "invite_member": "Invite Member", + "join_team": "Join Team", + "leave": "Leave", + "leave_team": "Leave Team", + "leave_team_confirmation": "Are you sure you want to leave this team?", + "manage": "Manage", + "member_removed_successfully": "Member removed successfully", + "members_added_successfully": "Members added successfully", + "no_members_found": "No members found", + "no_other_teams_found": "No other teams found", + "org_owner_and_managers_can_only_be_team_admin": "Org owner and managers can only be team admin.", + "organization_members": "Organization Members", + "organization_products": "Organization Products", + "permission": "Permission", + "permission_updated_successfully": "Permission updated successfully.", + "product_name": "Product Name", + "product_removed_successfully": "Product removed successfully.", + "read": "Read", + "read_write": "Read & Write", + "remove": "Remove", + "remove_member_confirmation": "Are you sure you want to remove this member?", + "remove_product": "Remove Product", + "remove_product_confirmation": "Are you sure you want to remove this product?", + "role_updated_successfully": "Role updated successfully", + "select_type": "Select type", + "team_admin": "Team Admin", + "team_created_successfully": "Team created successfully.", + "team_deleted_successfully": "Team deleted successfully.", + "team_members": "Team Members", + "team_name": "Team Name", + "team_name_description": "Give your team a descriptive name.", + "team_products": "Team Products", + "this_action_cannot_be_undone_if_it_s_gone_it_s_gone": "This action cannot be undone. If it's gone, it's gone.", + "your_team": "Your Team" } }, "surveys": { @@ -1512,6 +1585,7 @@ "no_surveys_created_yet": "No surveys created yet", "open_options": "Open options", "preview_survey_in_a_new_tab": "Preview survey in a new tab", + "read_only_user_not_allowed_to_create_survey_warning": "As a Read-Only user you are not allowed to create surveys. Please ask a user with write access to create a survey or a manager to upgrade your role.", "relevance": "Relevance", "responses": { "address_line_1": "Address Line 1", @@ -1688,8 +1762,7 @@ "multiple_industries": "Multiple industries", "use_this_template": "Use this template", "uses_branching_logic": "This survey uses branching logic." - }, - "viewer_not_allowed_to_create_survey_warning": "As a Viewer you are not allowed to create surveys. Please ask an Editor to create a survey or an Admin to upgrade your role" + } }, "xm-templates": { "ces": "CES", @@ -1712,6 +1785,11 @@ "healthy": "All systems are up and running" }, "organizations": { + "landing": { + "create_organization": "Create organization", + "no_products_warning_subtitle": "Reach out to your organization owner to get access to products. Or create an own organization to get started.", + "no_products_warning_title": "Your account doesn't have access to any products yet." + }, "products": { "new": { "channel": { @@ -1741,6 +1819,7 @@ "channel_settings_description": "Get 2x more responses matching surveys with your brand and UI", "channel_settings_subtitle": "When people recognize your brand, they are much more likely to start and complete responses.", "channel_settings_title": "Match your brand, get 2x more responses.", + "create_new_team": "Create new team", "link_channel_headline": "You maintain a product, how exciting!", "product_name": "Product name", "product_name_description": "What is your product called?", diff --git a/packages/lib/messages/pt-BR.json b/packages/lib/messages/pt-BR.json index f37bdd72a0..49bbaae5e4 100644 --- a/packages/lib/messages/pt-BR.json +++ b/packages/lib/messages/pt-BR.json @@ -25,8 +25,6 @@ "reset_password": "Redefinir senha" }, "invite": { - "already_part_of_squad": "Você já faz parte do time.", - "already_part_of_squad_description": "Esse convite já foi usado.", "contact_support": "Fale com o suporte", "create_account": "Cria uma conta", "email_does_not_match": "Opa! Email errado 🤦", @@ -104,6 +102,7 @@ "actions": "Ações", "active_surveys": "Pesquisas ativas", "activity": "Atividade", + "add": "Adicionar", "add_action": "Adicionar ação", "add_filter": "Adicionar filtro", "add_logo": "Adicionar logo", @@ -189,6 +188,7 @@ "error_component_title": "Erro ao carregar recursos", "expand_rows": "Expandir linhas", "experience": "experiência", + "failed_to_get_first_environment_of_user": "Falha ao obter a primeira ambiente do usuário", "filters_reset_successfully": "Filtros redefinidos com sucesso", "finish": "Terminar", "follow_these": "Siga esses", @@ -238,6 +238,8 @@ "look_and_feel": "Aparência e Experiência", "manage": "gerenciar", "marketing": "marketing", + "member": "Membros", + "members": "Membros", "membership_not_found": "Assinatura não encontrada", "meta": "Meta", "metadata": "metadados", @@ -267,10 +269,13 @@ "off": "desligado", "on": "ligado", "only_one_file_allowed": "É permitido apenas um arquivo", - "only_owners_admins_and_editors_can_perform_this_action": "Apenas Proprietários, Administradores e Editores podem realizar essa ação.", + "only_organization_owners_and_managers_can_access_this_setting": "Apenas proprietários e gerentes da organização podem acessar essa configuração.", + "only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.", + "only_owners_managers_and_team_admins_can_perform_this_action": "Apenas proprietários, gerentes e administradores de equipe podem realizar essa ação.", "or": "ou", "organization": "organização", "organization_not_found": "Organização não encontrada", + "organization_teams_not_found": "Equipes da organização não encontradas", "other": "outro", "other_filters": "Outros filtros", "others": "Outros", @@ -297,6 +302,8 @@ "product_manager": "Gerente de Produto", "product_name": "Nome do Produto", "product_not_found": "Produto não encontrado", + "product_permission_not_found": "Permissão de produto não encontrada", + "products": "Produtos", "profile": "Perfil", "question": "Pergunta", "question_id": "ID da Pergunta", @@ -358,6 +365,9 @@ "table_settings": "Arrumação da mesa", "tags": "etiquetas", "targeting": "mirando", + "team": "Time", + "team_access": "Acesso da equipe", + "teams": "Times", "text": "Texto", "time": "tempo", "time_to_finish": "Hora de terminar", @@ -530,6 +540,7 @@ "attributes": { "ex_user_property": "Ex. Propriedade do usuário", "how_to_add_attributes": "Como adicionar Atributos", + "show_archived": "Mostrar arquivados", "this_attribute_was_added_automatically_you_cannot_make_changes_to_it": "Esse atributo foi adicionado automaticamente. Você não pode fazer alterações nele.", "this_is_a_code_attribute_you_can_only_change_the_description": "Esse é um atributo de código. Você só pode mudar a descrição." }, @@ -787,7 +798,7 @@ "delete_product_confirmation": "Tem certeza de que quer deletar {productName}? Essa ação não pode ser desfeita.", "delete_product_name_includes_surveys_responses_people_and_more": "Excluir {productName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.", "delete_product_settings_description": "Excluir produto com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.", - "only_admin_or_owners_can_delete_products": "Somente Admins ou Donos podem deletar produtos.", + "only_owners_or_managers_can_delete_products": "Apenas proprietários ou gerentes podem excluir produtos", "organization_name": "Nome da Organização", "product_deleted_successfully": "Produto deletado com sucesso", "product_name_settings_description": "Mude o nome dos seus produtos.", @@ -873,6 +884,20 @@ "tag_deleted": "Tag apagada", "tags_merged": "Tags mescladas", "unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos" + }, + "teams": { + "add_existing_team": "Adicionar equipe existente", + "create_new_team": "Criar nova equipe", + "manage": "Gerenciar", + "no_teams_found": "Nenhuma equipe encontrada", + "permission": "Permissão", + "read": "Leitura", + "read_write": "Leitura & Escrita", + "remove_access": "Remover acesso", + "remove_access_confirmation": "Tem certeza de que deseja remover o acesso para esta equipe?", + "select_teams": "Selecionar equipes", + "team_name": "Nome da equipe", + "team_settings_description": "As equipes e seus membros podem acessar este produto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso." } }, "products_environments_organizations_not_found": "Produtos, ambientes ou organizações não encontrados", @@ -1022,7 +1047,6 @@ "copy_invite_link_to_clipboard": "Copiar link do convite para a área de transferência", "create_new_organization": "Criar nova organização", "create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de produtos.", - "create_new_organization_title": "Criar organização", "delete_organization": "Excluir Organização", "delete_organization_description": "Excluir organização com todos os seus produtos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos", "delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:", @@ -1048,8 +1072,9 @@ "manage_members_description": "Adicionar ou remover membros na sua organização.", "member_deleted_successfully": "Membro deletado com sucesso", "member_invited_successfully": "Membro convidado com sucesso", + "member_role_info_message": "Os membros não têm acesso aos produtos por padrão. Adicione-os às equipes depois que entrarem para gerenciar o acesso.", "once_its_gone_its_gone": "Uma vez que se foi, se foi.", - "only_owner_can_delete_organization": "Somente o Dono pode deletar a organização.", + "only_org_owner_can_perform_action": "Somente o Dono pode deletar a organização.", "organization_created_successfully": "Organização criada com sucesso!", "organization_deleted_successfully": "Organização deletada com sucesso.", "organization_invite_link_ready": "O link de convite da sua organização está pronto!", @@ -1128,6 +1153,54 @@ "upload_image": "Enviar imagem", "warning_cannot_undo": "Isso não pode ser desfeito", "you_must_select_a_file": "Você tem que selecionar um arquivo." + }, + "teams": { + "add_member": "Adicionar membro", + "add_members": "Adicionar membros", + "add_product": "Adicionar produto", + "add_products": "Adicionar produtos", + "are_you_sure_you_want_to_delete_this_team": "Tem certeza de que deseja excluir esta equipe? Isso também remove o acesso a todos os produtos e pesquisas associados a esta equipe.", + "contributor": "Contribuinte", + "create": "Criar", + "create_new_team": "Criar nova equipe", + "delete_team": "Excluir equipe", + "empty_product_message": "Você ainda não adicionou nenhum produto. Atribua um produto à equipe para conceder acesso aos membros.", + "empty_teams_state": "Você ainda não tem equipes. Crie sua primeira equipe para gerenciar o acesso aos produtos para os membros da sua organização.", + "enter_team_name": "Insira o nome da equipe", + "invite_member": "Convidar membro", + "join_team": "Entrar na equipe", + "leave": "Sair", + "leave_team": "Sair da equipe", + "leave_team_confirmation": "Tem certeza de que deseja sair desta equipe?", + "manage": "Gerenciar", + "member_removed_successfully": "Membro removido com sucesso", + "members_added_successfully": "Membros adicionados com sucesso", + "no_members_found": "Nenhum membro encontrado", + "no_other_teams_found": "Nenhuma outra equipe encontrada", + "org_owner_and_managers_can_only_be_team_admin": "Proprietários e gerentes da organização só podem ser administradores da equipe.", + "organization_members": "Membros da organização", + "organization_products": "Produtos da organização", + "permission": "Permissão", + "permission_updated_successfully": "Permissão atualizada com sucesso.", + "product_name": "Nome do produto", + "product_removed_successfully": "Produto removido com sucesso.", + "read": "Leitura", + "read_write": "Leitura & Escrita", + "remove": "Remover", + "remove_member_confirmation": "Tem certeza de que deseja remover este membro?", + "remove_product": "Remover produto", + "remove_product_confirmation": "Tem certeza de que deseja remover este produto?", + "role_updated_successfully": "Função atualizada com sucesso", + "select_type": "Selecionar tipo", + "team_admin": "Administrador da equipe", + "team_created_successfully": "Equipe criada com sucesso.", + "team_deleted_successfully": "Equipe excluída com sucesso.", + "team_members": "Membros da equipe", + "team_name": "Nome da equipe", + "team_name_description": "Dê um nome descritivo à sua equipe.", + "team_products": "Produtos da equipe", + "this_action_cannot_be_undone_if_it_s_gone_it_s_gone": "Esta ação não pode ser desfeita. Se foi, foi.", + "your_team": "Sua equipe" } }, "surveys": { @@ -1512,6 +1585,7 @@ "no_surveys_created_yet": "Ainda não foram criadas pesquisas", "open_options": "Abre opções", "preview_survey_in_a_new_tab": "Visualizar pesquisa em uma nova aba", + "read_only_user_not_allowed_to_create_survey_warning": "Como um usuário somente leitura, você não tem permissão para criar pesquisas. Peça a um usuário com acesso de gravação para criar uma pesquisa ou a um gerente para atualizar sua função.", "relevance": "Relevância", "responses": { "address_line_1": "Endereço Linha 1", @@ -1688,8 +1762,7 @@ "multiple_industries": "várias indústrias", "use_this_template": "Use esse modelo", "uses_branching_logic": "Essa pesquisa usa lógica de ramificação." - }, - "viewer_not_allowed_to_create_survey_warning": "Como Visualizador, você não pode criar pesquisas. Por favor, peça a um Editor para criar uma pesquisa ou a um Admin para atualizar seu papel." + } }, "xm-templates": { "ces": "CES", @@ -1712,6 +1785,11 @@ "healthy": "Todos os sistemas estão funcionando" }, "organizations": { + "landing": { + "create_organization": "Criar organização", + "no_products_warning_subtitle": "Entre em contato com o proprietário da sua organização para obter acesso aos produtos. Ou crie uma organização própria para começar.", + "no_products_warning_title": "Sua conta ainda não tem acesso a nenhum produto." + }, "products": { "new": { "channel": { @@ -1741,6 +1819,7 @@ "channel_settings_description": "Obtenha 2x mais respostas combinando pesquisas com sua marca e interface", "channel_settings_subtitle": "Quando as pessoas reconhecem sua marca, é muito mais provável que comecem e concluam respostas.", "channel_settings_title": "Combine sua marca, receba 2x mais respostas.", + "create_new_team": "Criar nova equipe", "link_channel_headline": "Você mantém um produto, que empolgante!", "product_name": "Nome do produto", "product_name_description": "Como se chama o seu produto?", diff --git a/packages/lib/organization/auth.ts b/packages/lib/organization/auth.ts index 6cb7731057..133b43ea62 100644 --- a/packages/lib/organization/auth.ts +++ b/packages/lib/organization/auth.ts @@ -51,7 +51,7 @@ export const verifyUserRoleAccess = async ( }; const currentUserMembership = await getMembershipByUserIdOrganizationId(userId, organizationId); - const { isOwner, isAdmin } = getAccessFlags(currentUserMembership?.role); + const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role); if (!isOwner) { accessObject.hasCreateOrUpdateAccess = false; @@ -61,7 +61,7 @@ export const verifyUserRoleAccess = async ( accessObject.hasBillingAccess = false; } - if (isAdmin) { + if (isManager) { accessObject.hasCreateOrUpdateMembersAccess = true; accessObject.hasDeleteMembersAccess = true; accessObject.hasBillingAccess = true; diff --git a/packages/lib/organization/utils.ts b/packages/lib/organization/utils.ts deleted file mode 100644 index 989963c555..0000000000 --- a/packages/lib/organization/utils.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { ResourceNotFoundError } from "@formbricks/types/errors"; -import { getActionClass } from "../actionClass/service"; -import { getApiKey } from "../apiKey/service"; -import { getAttributeClass } from "../attributeClass/service"; -import { getEnvironment } from "../environment/service"; -import { getIntegration } from "../integration/service"; -import { getInvite } from "../invite/service"; -import { getLanguage } from "../language/service"; -import { getPerson } from "../person/service"; -import { getProduct } from "../product/service"; -import { getResponse } from "../response/service"; -import { getResponseNote } from "../responseNote/service"; -import { getSegment } from "../segment/service"; -import { getSurvey } from "../survey/service"; -import { getTag } from "../tag/service"; -import { getWebhook } from "../webhook/service"; - -/** - * GET organization ID from RESOURCE ID - */ - -export const getOrganizationIdFromProductId = async (productId: string) => { - const product = await getProduct(productId); - if (!product) { - throw new ResourceNotFoundError("product", productId); - } - - return product.organizationId; -}; - -export const getOrganizationIdFromEnvironmentId = async (environmentId: string) => { - const environment = await getEnvironment(environmentId); - if (!environment) { - throw new ResourceNotFoundError("environment", environmentId); - } - - return await getOrganizationIdFromProductId(environment.productId); -}; - -export const getOrganizationIdFromSurveyId = async (surveyId: string) => { - const survey = await getSurvey(surveyId); - if (!survey) { - throw new ResourceNotFoundError("survey", surveyId); - } - - return await getOrganizationIdFromEnvironmentId(survey.environmentId); -}; - -export const getOrganizationIdFromResponseId = async (responseId: string) => { - const response = await getResponse(responseId); - if (!response) { - throw new ResourceNotFoundError("response", responseId); - } - - return await getOrganizationIdFromSurveyId(response.surveyId); -}; - -export const getOrganizationIdFromPersonId = async (personId: string) => { - const person = await getPerson(personId); - if (!person) { - throw new ResourceNotFoundError("person", personId); - } - - return await getOrganizationIdFromEnvironmentId(person.environmentId); -}; - -export const getOrganizationIdFromTagId = async (tagId: string) => { - const tag = await getTag(tagId); - if (!tag) { - throw new ResourceNotFoundError("tag", tagId); - } - - return await getOrganizationIdFromEnvironmentId(tag.environmentId); -}; - -export const getOrganizationIdFromResponseNoteId = async (responseNoteId: string) => { - const responseNote = await getResponseNote(responseNoteId); - if (!responseNote) { - throw new ResourceNotFoundError("responseNote", responseNoteId); - } - - return await getOrganizationIdFromResponseId(responseNote.responseId); -}; - -export const getOrganizationIdFromAttributeClassId = async (attributeClassId: string) => { - const attributeClass = await getAttributeClass(attributeClassId); - if (!attributeClass) { - throw new ResourceNotFoundError("attributeClass", attributeClassId); - } - - return await getOrganizationIdFromEnvironmentId(attributeClass.environmentId); -}; - -export const getOrganizationIdFromSegmentId = async (segmentId: string) => { - const segment = await getSegment(segmentId); - if (!segment) { - throw new ResourceNotFoundError("segment", segmentId); - } - - return await getOrganizationIdFromEnvironmentId(segment.environmentId); -}; - -export const getOrganizationIdFromActionClassId = async (actionClassId: string) => { - const actionClass = await getActionClass(actionClassId); - if (!actionClass) { - throw new ResourceNotFoundError("actionClass", actionClassId); - } - - return await getOrganizationIdFromEnvironmentId(actionClass.environmentId); -}; - -export const getOrganizationIdFromIntegrationId = async (integrationId: string) => { - const integration = await getIntegration(integrationId); - if (!integration) { - throw new ResourceNotFoundError("integration", integrationId); - } - - return await getOrganizationIdFromEnvironmentId(integration.environmentId); -}; - -export const getOrganizationIdFromWebhookId = async (webhookId: string) => { - const webhook = await getWebhook(webhookId); - if (!webhook) { - throw new ResourceNotFoundError("webhook", webhookId); - } - - return await getOrganizationIdFromEnvironmentId(webhook.environmentId); -}; - -export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => { - const apiKeyFromServer = await getApiKey(apiKeyId); - if (!apiKeyFromServer) { - throw new ResourceNotFoundError("apiKey", apiKeyId); - } - - return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId); -}; - -export const getOrganizationIdFromInviteId = async (inviteId: string) => { - const invite = await getInvite(inviteId); - if (!invite) { - throw new ResourceNotFoundError("invite", inviteId); - } - - return invite.organizationId; -}; - -export const getOrganizationIdFromLanguageId = async (languageId: string) => { - const language = await getLanguage(languageId); - if (!language) { - throw new ResourceNotFoundError("language", languageId); - } - - return await getOrganizationIdFromProductId(language.productId); -}; diff --git a/packages/lib/product/auth.ts b/packages/lib/product/auth.ts deleted file mode 100644 index 7d4212d895..0000000000 --- a/packages/lib/product/auth.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ZId } from "@formbricks/types/common"; -import { cache } from "../cache"; -import { getMembershipByUserIdOrganizationId } from "../membership/service"; -import { getAccessFlags } from "../membership/utils"; -import { getOrganizationsByUserId } from "../organization/service"; -import { validateInputs } from "../utils/validate"; -import { productCache } from "./cache"; -import { getProduct } from "./service"; - -export const canUserAccessProduct = (userId: string, productId: string): Promise => - cache( - async () => { - validateInputs([userId, ZId], [productId, ZId]); - - if (!userId || !productId) return false; - - try { - const product = await getProduct(productId); - if (!product) return false; - - const organizationIds = (await getOrganizationsByUserId(userId)).map( - (organization) => organization.id - ); - return organizationIds.includes(product.organizationId); - } catch (error) { - throw error; - } - }, - [`canUserAccessProduct-${userId}-${productId}`], - { - tags: [productCache.tag.byId(productId), productCache.tag.byUserId(userId)], - } - )(); - -export const verifyUserRoleAccess = async ( - organizationId: string, - userId: string -): Promise<{ - hasCreateOrUpdateAccess: boolean; - hasDeleteAccess: boolean; -}> => { - const accessObject = { - hasCreateOrUpdateAccess: true, - hasDeleteAccess: true, - }; - - if (!organizationId) { - throw new Error("Organization not found"); - } - - const currentUserMembership = await getMembershipByUserIdOrganizationId(userId, organizationId); - const { isDeveloper, isViewer } = getAccessFlags(currentUserMembership?.role); - - if (isDeveloper || isViewer) { - accessObject.hasCreateOrUpdateAccess = false; - accessObject.hasDeleteAccess = false; - } - - return accessObject; -}; diff --git a/packages/lib/product/service.ts b/packages/lib/product/service.ts index de43d89ec4..5694cbeb47 100644 --- a/packages/lib/product/service.ts +++ b/packages/lib/product/service.ts @@ -35,6 +35,67 @@ const selectProduct = { logo: true, }; +export const getUserProducts = reactCache( + (userId: string, organizationId: string, page?: number): Promise => + cache( + async () => { + validateInputs([userId, ZString], [organizationId, ZId], [page, ZOptionalNumber]); + + const orgMembership = await prisma.membership.findFirst({ + where: { + userId, + organizationId, + }, + }); + + if (!orgMembership) { + throw new ValidationError("User is not a member of this organization"); + } + + let productWhereClause: Prisma.ProductWhereInput = {}; + + if (orgMembership.role === "member") { + productWhereClause = { + productTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }; + } + + try { + const products = await prisma.product.findMany({ + where: { + organizationId, + ...productWhereClause, + }, + select: selectProduct, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + return products; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getUserProducts-${userId}-${organizationId}-${page}`], + { + tags: [productCache.tag.byUserId(userId), productCache.tag.byOrganizationId(organizationId)], + } + )() +); + export const getProducts = reactCache( (organizationId: string, page?: number): Promise => cache( @@ -256,7 +317,7 @@ export const createProduct = async ( throw new ValidationError("Product Name is required"); } - const { environments, ...data } = productInput; + const { environments, teamIds, ...data } = productInput; try { let product = await prisma.product.create({ @@ -272,6 +333,15 @@ export const createProduct = async ( select: selectProduct, }); + if (teamIds) { + await prisma.productTeam.createMany({ + data: teamIds.map((teamId) => ({ + productId: product.id, + teamId, + })), + }); + } + productCache.revalidate({ id: product.id, organizationId: product.organizationId, diff --git a/packages/lib/survey/auth.ts b/packages/lib/survey/auth.ts index 810573a8a7..ab9dde031f 100644 --- a/packages/lib/survey/auth.ts +++ b/packages/lib/survey/auth.ts @@ -1,9 +1,6 @@ import { ZId } from "@formbricks/types/common"; import { cache } from "../cache"; import { hasUserEnvironmentAccess } from "../environment/auth"; -import { getMembershipByUserIdOrganizationId } from "../membership/service"; -import { getAccessFlags } from "../membership/utils"; -import { getOrganizationByEnvironmentId } from "../organization/service"; import { validateInputs } from "../utils/validate"; import { surveyCache } from "./cache"; import { getSurvey } from "./service"; @@ -32,31 +29,3 @@ export const canUserAccessSurvey = (userId: string, surveyId: string): Promise => { - const accessObject = { - hasCreateOrUpdateAccess: true, - hasDeleteAccess: true, - }; - - const organization = await getOrganizationByEnvironmentId(environmentId); - if (!organization) { - throw new Error("Organization not found"); - } - - const currentUserMembership = await getMembershipByUserIdOrganizationId(userId, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); - - if (isViewer) { - accessObject.hasCreateOrUpdateAccess = false; - accessObject.hasDeleteAccess = false; - } - - return accessObject; -}; diff --git a/packages/lib/tag/auth.ts b/packages/lib/tag/auth.ts index 1e75bd7d58..385a4d31e1 100644 --- a/packages/lib/tag/auth.ts +++ b/packages/lib/tag/auth.ts @@ -1,9 +1,6 @@ import "server-only"; import { ZId } from "@formbricks/types/common"; import { hasUserEnvironmentAccess } from "../environment/auth"; -import { getMembershipByUserIdOrganizationId } from "../membership/service"; -import { getAccessFlags } from "../membership/utils"; -import { getOrganizationByEnvironmentId } from "../organization/service"; import { validateInputs } from "../utils/validate"; import { getTag } from "./service"; @@ -22,30 +19,3 @@ export const canUserAccessTag = async (userId: string, tagId: string): Promise => { - const organization = await getOrganizationByEnvironmentId(environmentId); - if (!organization) { - throw new Error("Organization not found"); - } - const currentUserMembership = await getMembershipByUserIdOrganizationId(userId, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); - - if (isViewer) { - return { - hasCreateOrUpdateAccess: false, - hasDeleteAccess: false, - }; - } - - return { - hasCreateOrUpdateAccess: true, - hasDeleteAccess: true, - }; -}; diff --git a/packages/lib/tagOnResponse/auth.ts b/packages/lib/tagOnResponse/auth.ts index 15be2c7ddf..840fcade0f 100644 --- a/packages/lib/tagOnResponse/auth.ts +++ b/packages/lib/tagOnResponse/auth.ts @@ -1,9 +1,6 @@ import "server-only"; import { ZId } from "@formbricks/types/common"; import { cache } from "../cache"; -import { getMembershipByUserIdOrganizationId } from "../membership/service"; -import { getAccessFlags } from "../membership/utils"; -import { getOrganizationByEnvironmentId } from "../organization/service"; import { canUserAccessResponse } from "../response/auth"; import { canUserAccessTag } from "../tag/auth"; import { validateInputs } from "../utils/validate"; @@ -32,30 +29,3 @@ export const canUserAccessTagOnResponse = ( tags: [tagOnResponseCache.tag.byResponseIdAndTagId(responseId, tagId)], } )(); - -export const verifyUserRoleAccess = async ( - environmentId: string, - userId: string -): Promise<{ - hasCreateOrUpdateAccess: boolean; - hasDeleteAccess: boolean; -}> => { - const organization = await getOrganizationByEnvironmentId(environmentId); - if (!organization) { - throw new Error("Organization not found"); - } - const currentUserMembership = await getMembershipByUserIdOrganizationId(userId, organization.id); - const { isViewer } = getAccessFlags(currentUserMembership?.role); - - if (isViewer) { - return { - hasCreateOrUpdateAccess: false, - hasDeleteAccess: false, - }; - } - - return { - hasCreateOrUpdateAccess: true, - hasDeleteAccess: true, - }; -}; diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 8b31167f6f..0c5ed75f28 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -14,5 +14,5 @@ }, "exclude": ["dist", "build", "node_modules", "../../packages/types/surveys.d.ts"], "extends": "@formbricks/config-typescript/nextjs.json", - "include": [".", "../types/*.d.ts"] + "include": [".", "../types/*.d.ts", "../../apps/web/modules/utils/hooks"] } diff --git a/packages/lib/user/service.ts b/packages/lib/user/service.ts index 566ac5366c..0e9cca31ac 100644 --- a/packages/lib/user/service.ts +++ b/packages/lib/user/service.ts @@ -5,7 +5,6 @@ import { z } from "zod"; import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { TMembership } from "@formbricks/types/memberships"; import { TUser, TUserCreateInput, @@ -15,7 +14,6 @@ import { } from "@formbricks/types/user"; import { cache } from "../cache"; import { createCustomerIoCustomer } from "../customerio"; -import { deleteMembership, updateMembership } from "../membership/service"; import { deleteOrganization } from "../organization/service"; import { validateInputs } from "../utils/validate"; import { userCache } from "./cache"; @@ -100,9 +98,6 @@ export const getUserByEmail = reactCache( )() ); -const getAdminMemberships = (memberships: TMembership[]): TMembership[] => - memberships.filter((membership) => membership.role === "admin"); - // function to update a user's user export const updateUser = async (personId: string, data: TUserUpdateInput): Promise => { validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]); @@ -213,17 +208,11 @@ export const deleteUser = async (id: string): Promise => { const role = currentUserMembership.role; const organizationId = currentUserMembership.organizationId; - const organizationAdminMemberships = getAdminMemberships(organizationMemberships); - const organizationHasAtLeastOneAdmin = organizationAdminMemberships.length > 0; const organizationHasOnlyOneMember = organizationMemberships.length === 1; const currentUserIsOrganizationOwner = role === "owner"; - await deleteMembership(id, organizationId); if (organizationHasOnlyOneMember) { await deleteOrganization(organizationId); - } else if (currentUserIsOrganizationOwner && organizationHasAtLeastOneAdmin) { - const firstAdmin = organizationAdminMemberships[0]; - await updateMembership(firstAdmin.userId, organizationId, { role: "owner" }); } else if (currentUserIsOrganizationOwner) { await deleteOrganization(organizationId); } diff --git a/packages/types/action-client.ts b/packages/types/action-client.ts deleted file mode 100644 index ebf9600589..0000000000 --- a/packages/types/action-client.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from "zod"; - -export const ZResource = z.enum([ - "product", - "organization", - "environment", - "membership", - "invite", - "response", - "survey", - "person", - "tag", - "responseNote", - "membership", - "attributeClass", - "segment", - "actionClass", - "integration", - "webhook", - "apiKey", - "subscription", - "invite", - "language", -]); -export type TResource = z.infer; - -export const ZOperation = z.enum(["create", "read", "update", "delete"]); -export type TOperation = z.infer; diff --git a/packages/types/errors.ts b/packages/types/errors.ts index b427083850..4243d2527b 100644 --- a/packages/types/errors.ts +++ b/packages/types/errors.ts @@ -33,7 +33,7 @@ class UnknownError extends Error { statusCode = 500; constructor(message: string) { super(message); - this.name = "DatabaseError"; + this.name = "UnknownError"; } } diff --git a/packages/types/invites.ts b/packages/types/invites.ts index 91172c4220..18be936091 100644 --- a/packages/types/invites.ts +++ b/packages/types/invites.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ZMembershipRole } from "./memberships"; +import { ZOrganizationRole } from "./memberships"; export const ZInvite = z.object({ id: z.string(), @@ -8,17 +8,16 @@ export const ZInvite = z.object({ organizationId: z.string(), creatorId: z.string(), acceptorId: z.string().nullish(), - accepted: z.boolean(), createdAt: z.date(), expiresAt: z.date(), - role: ZMembershipRole, + role: ZOrganizationRole, }); export type TInvite = z.infer; export const ZInvitee = z.object({ email: z.string().email(), name: z.string(), - role: ZMembershipRole, + role: ZOrganizationRole, }); export type TInvitee = z.infer; @@ -31,7 +30,7 @@ export const ZCurrentUser = z.object({ export type TCurrentUser = z.infer; export const ZInviteUpdateInput = z.object({ - role: ZMembershipRole, + role: ZOrganizationRole, }); export type TInviteUpdateInput = z.infer; diff --git a/packages/types/memberships.ts b/packages/types/memberships.ts index d3f75c601b..1b3a3123b2 100644 --- a/packages/types/memberships.ts +++ b/packages/types/memberships.ts @@ -1,14 +1,16 @@ import { z } from "zod"; export const ZMembershipRole = z.enum(["owner", "admin", "editor", "developer", "viewer"]); +export const ZOrganizationRole = z.enum(["owner", "manager", "member", "billing"]); export type TMembershipRole = z.infer; +export type TOrganizationRole = z.infer; export const ZMembership = z.object({ organizationId: z.string(), userId: z.string(), accepted: z.boolean(), - role: ZMembershipRole, + role: ZOrganizationRole, }); export type TMembership = z.infer; @@ -18,12 +20,12 @@ export const ZMember = z.object({ email: z.string().email(), userId: z.string(), accepted: z.boolean(), - role: ZMembershipRole, + role: ZOrganizationRole, }); export type TMember = z.infer; export const ZMembershipUpdateInput = z.object({ - role: ZMembershipRole, + role: ZOrganizationRole, }); export type TMembershipUpdateInput = z.infer; diff --git a/packages/types/product.ts b/packages/types/product.ts index e22bb0a22d..9675a645f0 100644 --- a/packages/types/product.ts +++ b/packages/types/product.ts @@ -91,6 +91,7 @@ export const ZProductUpdateInput = z.object({ environments: z.array(ZEnvironment).optional(), styling: ZProductStyling.optional(), logo: ZLogo.optional(), + teamIds: z.array(z.string()).optional(), }); export type TProductUpdateInput = z.infer; diff --git a/packages/types/weekly-summary.ts b/packages/types/weekly-summary.ts index 2b6841ad67..8dd84710ec 100644 --- a/packages/types/weekly-summary.ts +++ b/packages/types/weekly-summary.ts @@ -74,6 +74,7 @@ export const ZWeeklySummaryEnvironmentData = z.object({ export type TWeeklySummaryEnvironmentData = z.infer; export const ZWeeklySummaryUserData = z.object({ + id: z.string(), email: z.string(), notificationSettings: ZUserNotificationSettings, locale: z.string(), diff --git a/packages/ui/components/Breadcrumb/index.tsx b/packages/ui/components/Breadcrumb/index.tsx new file mode 100644 index 0000000000..5bdf750dcb --- /dev/null +++ b/packages/ui/components/Breadcrumb/index.tsx @@ -0,0 +1,98 @@ +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import * as React from "react"; +import { cn } from "../../lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>