From 13508f42be44e9ea052adce2fb679591fd9828dd Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Mon, 1 May 2023 15:11:53 +0200 Subject: [PATCH] Add Team switch (#259) * add team switch when user has multiple teams * fix hydration error by changing the way env variables load --------- Co-authored-by: Matthias Nannt --- .../[environmentId]/EnvironmentsNavbar.tsx | 75 +++++- .../settings/SettingsNavbar.tsx | 254 +++++++++--------- .../settings/billing/PricingTable.tsx | 2 +- .../settings/members/EditTeamName.tsx | 49 +++- .../[environmentId]/settings/members/page.tsx | 6 +- .../[surveyId]/summary/SummaryList.tsx | 2 - apps/web/lib/teams/mutateTeams.ts | 11 + apps/web/lib/{ => teams}/teams.ts | 0 apps/web/next.config.js | 13 + apps/web/package.json | 1 + apps/web/pages/api/v1/memberships/index.ts | 20 ++ apps/web/pages/api/v1/teams/[teamId]/index.ts | 60 +++++ packages/lib/constants.ts | 1 + pnpm-lock.yaml | 26 +- 14 files changed, 369 insertions(+), 151 deletions(-) create mode 100644 apps/web/lib/teams/mutateTeams.ts rename apps/web/lib/{ => teams}/teams.ts (100%) create mode 100644 apps/web/pages/api/v1/teams/[teamId]/index.ts diff --git a/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx b/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx index c50a3a3a72..4ffdcda5cb 100644 --- a/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx +++ b/apps/web/app/environments/[environmentId]/EnvironmentsNavbar.tsx @@ -1,5 +1,6 @@ "use client"; +import FaveIcon from "@/app/favicon.ico"; import { DropdownMenu, DropdownMenuContent, @@ -17,6 +18,8 @@ import { } from "@/components/shared/DropdownMenu"; import LoadingSpinner from "@/components/shared/LoadingSpinner"; import { useEnvironment } from "@/lib/environments/environments"; +import { useMemberships } from "@/lib/memberships"; +import { useTeam } from "@/lib/teams/teams"; import { capitalizeFirstLetter } from "@/lib/utils"; import { CustomersIcon, @@ -47,7 +50,6 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import AddProductModal from "./AddProductModal"; -import FaveIcon from "@/app/favicon.ico"; interface EnvironmentsNavbarProps { environmentId: string; @@ -56,12 +58,16 @@ interface EnvironmentsNavbarProps { export default function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) { const router = useRouter(); - const [loading, setLoading] = useState(false); - const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId); const pathname = usePathname(); - const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false); + const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId); + const { memberships, isErrorMemberships, isLoadingMemberships } = useMemberships(); + const { team } = useTeam(environmentId); + const [currentTeamName, setCurrentTeamName] = useState(""); + const [currentTeamId, setCurrentTeamId] = useState(""); + const [loading, setLoading] = useState(false); + const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false); const [showAddProductModal, setShowAddProductModal] = useState(false); useEffect(() => { @@ -72,6 +78,13 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme } }, [environment]); + useEffect(() => { + if (team && team.name !== "") { + setCurrentTeamName(team.name); + setCurrentTeamId(team.id); + } + }, [team]); + const navigation = useMemo( () => [ { @@ -139,11 +152,6 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme href: `/environments/${environmentId}/settings/billing`, hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1", }, - /* { - icon: RocketLaunchIcon, - label: "Upgrade account", - href: `/environments/${environmentId}/settings/billing`, - }, */ ], }, { @@ -182,11 +190,24 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme router.push(`/environments/${newEnvironmentId}/`); }; - if (isLoadingEnvironment || loading) { + const changeEnvironmentByTeam = (teamId: string) => { + const newTeamMembership = memberships.find((m) => m.teamId === teamId); + const newTeamProduct = newTeamMembership?.team?.products?.[0]; + + if (newTeamProduct) { + const newEnvironmentId = newTeamProduct.environments.find((e) => e.type === "production")?.id; + + if (newEnvironmentId) { + router.push(`/environments/${newEnvironmentId}/`); + } + } + }; + + if (isLoadingEnvironment || loading || isLoadingMemberships) { return ; } - if (isErrorEnvironment) { + if (isErrorEnvironment || isErrorMemberships) { return ; } @@ -255,6 +276,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme + {/* Product Switch */} +
@@ -286,6 +309,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme + {/* Environment Switch */} +
@@ -309,6 +334,34 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme + {/* Team Switch */} + {memberships.length > 1 && ( + + +
+

{currentTeamName}

+

Team

+
+
+ + + changeEnvironmentByTeam(teamId)}> + {memberships?.map((membership) => ( + + {membership.team.name} + + ))} + + + +
+ )} + {dropdownnavigation.map((item) => ( diff --git a/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx b/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx index 097ba2e56b..6dd893d451 100644 --- a/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx +++ b/apps/web/app/environments/[environmentId]/settings/SettingsNavbar.tsx @@ -1,155 +1,155 @@ "use client"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { AdjustmentsVerticalIcon, ChatBubbleLeftEllipsisIcon, CreditCardIcon, DocumentCheckIcon, DocumentMagnifyingGlassIcon, + KeyIcon, LinkIcon, PaintBrushIcon, UserCircleIcon, UsersIcon, - KeyIcon, } from "@heroicons/react/24/solid"; import clsx from "clsx"; import Link from "next/link"; - import { usePathname } from "next/navigation"; +import { useMemo } from "react"; export default function SettingsNavbar({ environmentId }: { environmentId: string }) { const pathname = usePathname(); - const navigation = [ - { - title: "Account", - links: [ - { - name: "Profile", - href: `/environments/${environmentId}/settings/profile`, - icon: UserCircleIcon, - current: pathname?.includes("/profile"), - hidden: false, - }, - /* { - name: "Notifications", - href: `/environments/${environmentId}/settings/notifications`, - icon: MegaphoneIcon, - current: pathname?.includes("/notifications"), - }, */ - ], - }, - { - title: "Product", - links: [ - { - name: "Settings", - href: `/environments/${environmentId}/settings/product`, - icon: AdjustmentsVerticalIcon, - current: pathname?.includes("/product"), - hidden: false, - }, - { - name: "Look & Feel", - href: `/environments/${environmentId}/settings/lookandfeel`, - icon: PaintBrushIcon, - current: pathname?.includes("/lookandfeel"), - hidden: false, - }, - { - name: "API Keys", - href: `/environments/${environmentId}/settings/api-keys`, - icon: KeyIcon, - current: pathname?.includes("/api-keys"), - hidden: false, - }, - ], - }, - { - title: "Team", - links: [ - { - name: "Members", - href: `/environments/${environmentId}/settings/members`, - icon: UsersIcon, - current: pathname?.includes("/members"), - hidden: false, - }, - /* + const navigation = useMemo( + () => [ + { + title: "Account", + links: [ + { + name: "Profile", + href: `/environments/${environmentId}/settings/profile`, + icon: UserCircleIcon, + current: pathname?.includes("/profile"), + hidden: false, + }, + ], + }, + { + title: "Product", + links: [ + { + name: "Settings", + href: `/environments/${environmentId}/settings/product`, + icon: AdjustmentsVerticalIcon, + current: pathname?.includes("/product"), + hidden: false, + }, + { + name: "Look & Feel", + href: `/environments/${environmentId}/settings/lookandfeel`, + icon: PaintBrushIcon, + current: pathname?.includes("/lookandfeel"), + hidden: false, + }, + { + name: "API Keys", + href: `/environments/${environmentId}/settings/api-keys`, + icon: KeyIcon, + current: pathname?.includes("/api-keys"), + hidden: false, + }, + ], + }, + { + title: "Team", + links: [ + { + name: "Members", + href: `/environments/${environmentId}/settings/members`, + icon: UsersIcon, + current: pathname?.includes("/members"), + hidden: false, + }, + /* { name: "Tags", href: `/environments/${environmentId}/settings/tags`, icon: PlusCircleIcon, current: pathname?.includes("/tags"), }, */ - { - name: "Billing & Plan", - href: `/environments/${environmentId}/settings/billing`, - icon: CreditCardIcon, - hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1", - current: pathname?.includes("/billing"), - }, - ], - }, - { - title: "Setup", - links: [ - { - name: "Setup Checklist", - href: `/environments/${environmentId}/settings/setup`, - icon: DocumentCheckIcon, - current: pathname?.includes("/setup"), - hidden: false, - }, - { - name: "Documentation", - href: "https://formbricks.com/docs", - icon: DocumentMagnifyingGlassIcon, - target: "_blank", - hidden: false, - }, - { - name: "Join Discord", - href: "https://formbricks.com/discord", - icon: ChatBubbleLeftEllipsisIcon, - target: "_blank", - hidden: false, - }, - ], - }, - { - title: "Compliance", - links: [ - { - name: "GDPR & CCPA", - href: "https://formbricks.com/gdpr", - icon: LinkIcon, - target: "_blank", - hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1", - }, - { - name: "Privacy", - href: "https://formbricks.com/privacy", - icon: LinkIcon, - target: "_blank", - hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1", - }, - { - name: "Terms", - href: "https://formbricks.com/terms", - icon: LinkIcon, - target: "_blank", - hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1", - }, - { - name: "License", - href: "https://github.com/formbricks/formbricks/blob/main/LICENSE", - icon: LinkIcon, - target: "_blank", - hidden: false, - }, - ], - }, - ]; + { + name: "Billing & Plan", + href: `/environments/${environmentId}/settings/billing`, + icon: CreditCardIcon, + hidden: !IS_FORMBRICKS_CLOUD, + current: pathname?.includes("/billing"), + }, + ], + }, + { + title: "Setup", + links: [ + { + name: "Setup Checklist", + href: `/environments/${environmentId}/settings/setup`, + icon: DocumentCheckIcon, + current: pathname?.includes("/setup"), + hidden: false, + }, + { + name: "Documentation", + href: "https://formbricks.com/docs", + icon: DocumentMagnifyingGlassIcon, + target: "_blank", + hidden: false, + }, + { + name: "Join Discord", + href: "https://formbricks.com/discord", + icon: ChatBubbleLeftEllipsisIcon, + target: "_blank", + hidden: false, + }, + ], + }, + { + title: "Compliance", + links: [ + { + name: "GDPR & CCPA", + href: "https://formbricks.com/gdpr", + icon: LinkIcon, + target: "_blank", + hidden: !IS_FORMBRICKS_CLOUD, + }, + { + name: "Privacy", + href: "https://formbricks.com/privacy", + icon: LinkIcon, + target: "_blank", + hidden: !IS_FORMBRICKS_CLOUD, + }, + { + name: "Terms", + href: "https://formbricks.com/terms", + icon: LinkIcon, + target: "_blank", + hidden: !IS_FORMBRICKS_CLOUD, + }, + { + name: "License", + href: "https://github.com/formbricks/formbricks/blob/main/LICENSE", + icon: LinkIcon, + target: "_blank", + hidden: false, + }, + ], + }, + ], + [environmentId, pathname] + ); + + if (!navigation) return null; return (
diff --git a/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx b/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx index bbfd04c6ba..0d5c81812b 100644 --- a/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx +++ b/apps/web/app/environments/[environmentId]/settings/billing/PricingTable.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; import LoadingSpinner from "@/components/shared/LoadingSpinner"; -import { useTeam } from "@/lib/teams"; +import { useTeam } from "@/lib/teams/teams"; import { Badge, Button, ErrorComponent } from "@formbricks/ui"; import type { Session } from "next-auth"; import { useRouter } from "next/navigation"; diff --git a/apps/web/app/environments/[environmentId]/settings/members/EditTeamName.tsx b/apps/web/app/environments/[environmentId]/settings/members/EditTeamName.tsx index 8a8d7c64f7..223b12b922 100644 --- a/apps/web/app/environments/[environmentId]/settings/members/EditTeamName.tsx +++ b/apps/web/app/environments/[environmentId]/settings/members/EditTeamName.tsx @@ -1,18 +1,51 @@ "use client"; -import { Button } from "@formbricks/ui"; -import { Input } from "@formbricks/ui"; -import { Label } from "@formbricks/ui"; +import { useEffect, useState } from "react"; +import LoadingSpinner from "@/components/shared/LoadingSpinner"; +import { useTeamMutation } from "@/lib/teams/mutateTeams"; +import { useTeam } from "@/lib/teams/teams"; +import { Button, ErrorComponent, Input, Label } from "@formbricks/ui"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; + +export default function EditTeamName({ environmentId }) { + const { team, isLoadingTeam, isErrorTeam } = useTeam(environmentId); + const { register, handleSubmit } = useForm(); + const [teamId, setTeamId] = useState(""); + + useEffect(() => { + if (team && team.id !== "") { + setTeamId(team.id); + } + }, [team]); + + const { isMutatingTeam, triggerTeamMutate } = useTeamMutation(teamId); + + if (isLoadingTeam) { + return ; + } + if (isErrorTeam) { + return ; + } -export function EditTeamName() { return ( -
+
{ + triggerTeamMutate({ ...data }) + .catch((error) => { + toast.error(`Error: ${error.message}`); + }) + .then(() => { + toast.success("Team name updated successfully."); + }); + })}> - + - -
+ ); } diff --git a/apps/web/app/environments/[environmentId]/settings/members/page.tsx b/apps/web/app/environments/[environmentId]/settings/members/page.tsx index 44a699de7f..83a3524346 100644 --- a/apps/web/app/environments/[environmentId]/settings/members/page.tsx +++ b/apps/web/app/environments/[environmentId]/settings/members/page.tsx @@ -1,14 +1,18 @@ import SettingsCard from "../SettingsCard"; import SettingsTitle from "../SettingsTitle"; import { EditMemberships } from "./EditMemberships"; +import EditTeamName from "./EditTeamName"; export default function MembersSettingsPage({ params }) { return (
- + + + +
); } diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx index 8a3eafb562..7bf72e5726 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx @@ -19,8 +19,6 @@ export default function SummaryList({ environmentId, surveyId }) { const responses = responsesData?.responses; - console.log(responses); - const summaryData: QuestionSummary[] = useMemo(() => { if (survey && responses) { return survey.questions.map((question) => { diff --git a/apps/web/lib/teams/mutateTeams.ts b/apps/web/lib/teams/mutateTeams.ts new file mode 100644 index 0000000000..023c7df2cc --- /dev/null +++ b/apps/web/lib/teams/mutateTeams.ts @@ -0,0 +1,11 @@ +import useSWRMutation from "swr/mutation"; +import { updateRessource } from "@formbricks/lib/fetcher"; + +export function useTeamMutation(teamId: string) { + const { trigger, isMutating } = useSWRMutation(`/api/v1/teams/${teamId}`, updateRessource); + + return { + triggerTeamMutate: trigger, + isMutatingTeam: isMutating, + }; +} diff --git a/apps/web/lib/teams.ts b/apps/web/lib/teams/teams.ts similarity index 100% rename from apps/web/lib/teams.ts rename to apps/web/lib/teams/teams.ts diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 2b373bb8a3..27e991e862 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,5 +1,10 @@ /** @type {import('next').NextConfig} */ +const path = require("path"); +const Dotenv = require("dotenv-webpack"); + +const rootPath = path.join(__dirname, "..", ".."); + const { createId } = require("@paralleldrive/cuid2"); const nextConfig = { @@ -48,6 +53,14 @@ const nextConfig = { }, ]; }, + webpack: (config) => { + config.plugins.push( + new Dotenv({ + path: path.resolve(rootPath, ".env"), + }) + ); + return config; + }, env: { INSTANCE_ID: createId(), }, diff --git a/apps/web/package.json b/apps/web/package.json index 8efb083f67..01f8c1a76f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "bcryptjs": "^2.4.3", "class-variance-authority": "^0.5.2", "date-fns": "^2.29.3", + "dotenv-webpack": "^8.0.1", "eslint": "8.38.0", "eslint-config-next": "^13.3.0", "jsonwebtoken": "^9.0.0", diff --git a/apps/web/pages/api/v1/memberships/index.ts b/apps/web/pages/api/v1/memberships/index.ts index 7bfb26b42f..74c43d3b08 100644 --- a/apps/web/pages/api/v1/memberships/index.ts +++ b/apps/web/pages/api/v1/memberships/index.ts @@ -16,6 +16,26 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) where: { userId: user.id, }, + include: { + team: { + select: { + id: true, + name: true, + products: { + select: { + id: true, + name: true, + environments: { + select: { + id: true, + type: true, + }, + }, + }, + }, + }, + }, + }, }); return res.json(memberships); } diff --git a/apps/web/pages/api/v1/teams/[teamId]/index.ts b/apps/web/pages/api/v1/teams/[teamId]/index.ts new file mode 100644 index 0000000000..1c16cb7d4f --- /dev/null +++ b/apps/web/pages/api/v1/teams/[teamId]/index.ts @@ -0,0 +1,60 @@ +import { getSessionUser, hasTeamAccess } from "@/lib/api/apiHelper"; +import { prisma } from "@formbricks/database"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handle(req: NextApiRequest, res: NextApiResponse) { + // Check Authentication + + const currentUser: any = await getSessionUser(req, res); + if (!currentUser) { + return res.status(401).json({ message: "Not authenticated" }); + } + + const teamId = req.query.teamId?.toString(); + if (teamId === undefined) { + return res.status(400).json({ message: "Missing teamId" }); + } + + const hasAccess = await hasTeamAccess(currentUser, teamId); + if (!hasAccess) { + return res.status(403).json({ message: "Not authorized" }); + } + + // PUT /api/v1/teams/[teamId] + // Update a team + if (req.method === "PUT") { + const { name } = req.body; + if (name === undefined) { + return res.status(400).json({ message: "Missing name" }); + } + + // check if currentUser is owner of the team + const membership = await prisma.membership.findUnique({ + where: { + userId_teamId: { + userId: currentUser.id, + teamId, + }, + }, + }); + if (membership?.role !== "owner") { + return res.status(403).json({ message: "You are not allowed to update this team" }); + } + + // update team + const team = await prisma.team.update({ + where: { + id: teamId, + }, + data: { + name, + }, + }); + return res.json(team); + } + + // Unknown HTTP Method + else { + throw new Error(`The HTTP ${req.method} method is not supported by this route.`); + } +} diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 98c9ba9fff..ad2ea2c39f 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -1 +1,2 @@ export const RESPONSES_LIMIT_FREE = 100; +export const IS_FORMBRICKS_CLOUD = process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70b3760551..f0382753a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: date-fns: specifier: ^2.29.3 version: 2.29.3 + dotenv-webpack: + specifier: ^8.0.1 + version: 8.0.1(webpack@5.75.0) eslint: specifier: 8.38.0 version: 8.38.0 @@ -4645,7 +4648,7 @@ packages: tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.3.1(postcss@8.4.21) + tailwindcss: 3.3.1(postcss@8.4.22) dev: true /@tailwindcss/typography@0.5.9(tailwindcss@3.3.1): @@ -8445,6 +8448,22 @@ packages: dependencies: is-obj: 2.0.0 + /dotenv-defaults@2.0.2: + resolution: {integrity: sha512-iOIzovWfsUHU91L5i8bJce3NYK5JXeAwH50Jh6+ARUdLiiGlYWfGw6UkzsYqaXZH/hjE/eCd/PlfM/qqyK0AMg==} + dependencies: + dotenv: 8.6.0 + dev: false + + /dotenv-webpack@8.0.1(webpack@5.75.0): + resolution: {integrity: sha512-CdrgfhZOnx4uB18SgaoP9XHRN2v48BbjuXQsZY5ixs5A8579NxQkmMxRtI7aTwSiSQcM2ao12Fdu+L3ZS3bG4w==} + engines: {node: '>=10'} + peerDependencies: + webpack: ^4 || ^5 + dependencies: + dotenv-defaults: 2.0.2 + webpack: 5.75.0 + dev: false + /dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -8455,6 +8474,11 @@ packages: engines: {node: '>=4.6.0'} dev: false + /dotenv@8.6.0: + resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} + engines: {node: '>=10'} + dev: false + /duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}