feat: team users clean up (#4448)

Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
Piyush Gupta
2024-12-18 17:50:50 +05:30
committed by GitHub
parent 7b11ef9b40
commit 15f36651d8
112 changed files with 3292 additions and 4077 deletions

View File

@@ -2,7 +2,7 @@
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/teams";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
@@ -201,8 +201,8 @@ export const ProjectSettings = ({
<div>
<MultiSelect
value={field.value}
onChange={(teamIds) => field.onChange(teamIds)}
options={organizationTeamsOptions}
onChange={(teamIds) => field.onChange(teamIds)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>

View File

@@ -69,7 +69,7 @@ export const BackgroundStylingCard = ({
<p className={cn("font-semibold text-slate-800", isSettingsPage ? "text-sm" : "text-base")}>
{t("environments.surveys.edit.background_styling")}
</p>
{isSettingsPage && <Badge text={t("common.link_surveys")} type="gray" size="normal" />}
{isSettingsPage && <Badge type="gray" size="normal" text={t("common.link_surveys")} />}
</div>
<p className={cn("mt-1 text-slate-500", isSettingsPage ? "text-xs" : "text-sm")}>
{t("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")}

View File

@@ -258,11 +258,10 @@ export const CardStylingSettings = ({
onCheckedChange={(checked) => field.onChange(checked)}
/>
</FormControl>
<div>
<FormLabel>
{t("environments.surveys.edit.hide_logo")}
<Badge text={t("common.link_surveys")} type="gray" size="normal" />
<Badge type="gray" size="normal" text={t("common.link_surveys")} />
</FormLabel>
<FormDescription>
{t("environments.surveys.edit.hide_the_logo_in_this_specific_survey")}

View File

@@ -162,7 +162,12 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, locale
{option.name}
</p>
{option.comingSoon && (
<Badge text="coming soon" size="normal" type="success" className="ml-2" />
<Badge
size="normal"
type="success"
className="ml-2"
text={t("environments.settings.enterprise.coming_soon")}
/>
)}
</div>
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>

View File

@@ -1,6 +1,6 @@
import { TargetingLockedCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/TargetingLockedCard";
import { TargetingCard } from "@/modules/ee/contacts/segments/components/targeting-card";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { TActionClass } from "@formbricks/types/action-classes";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TEnvironment } from "@formbricks/types/environment";

View File

@@ -1,6 +1,6 @@
"use client";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { FollowUpsView } from "@/modules/survey-follow-ups/components/follow-ups-view";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { useCallback, useEffect, useRef, useState } from "react";

View File

@@ -1,6 +1,6 @@
"use client";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";

View File

@@ -1,5 +1,5 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";

View File

@@ -1,7 +1,7 @@
"use client";
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";

View File

@@ -80,7 +80,7 @@ export const WebhookRowData = ({
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm text-slate-800">
<Badge text={capitalizeFirstLetter(webhook.source) || t("common.user")} type="gray" size="tiny" />
<Badge type="gray" size="tiny" text={capitalizeFirstLetter(webhook.source) || t("common.user")} />
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}

View File

@@ -12,7 +12,6 @@ interface OrganizationSettingsNavbarProps {
membershipRole?: TOrganizationRole;
activeId: string;
loading?: boolean;
canDoRoleManagement?: boolean;
}
export const OrganizationSettingsNavbar = ({
@@ -21,10 +20,9 @@ export const OrganizationSettingsNavbar = ({
membershipRole,
activeId,
loading,
canDoRoleManagement = false,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isBilling, isMember } = getAccessFlags(membershipRole);
const { isMember } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember;
const t = useTranslations();
@@ -47,7 +45,6 @@ export const OrganizationSettingsNavbar = ({
id: "teams",
label: t("common.teams"),
href: `/environments/${environmentId}/settings/teams`,
hidden: !canDoRoleManagement || isBilling,
current: pathname?.includes("/teams"),
},
{

View File

@@ -1,6 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense, getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -43,8 +43,6 @@ const Page = async (props) => {
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const canDoRoleManagement = await getRoleManagementPermission(organization);
const paidFeatures = [
{
title: t("environments.project.languages.multi_language_surveys"),
@@ -96,7 +94,6 @@ const Page = async (props) => {
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="enterprise"
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
{isEnterpriseEdition ? (

View File

@@ -1,30 +1,12 @@
"use server";
import {
deleteMembership,
getMembershipsByUserId,
getOrganizationOwnerCount,
} 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 { getIsMultiOrgEnabled, getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { sendInviteMemberEmail } from "@/modules/email";
import { OrganizationRole } from "@prisma/client";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
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 { getAccessFlags } from "@formbricks/lib/membership/utils";
import {
deleteOrganization,
getOrganization,
updateOrganization,
} from "@formbricks/lib/organization/service";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZOrganizationRole } from "@formbricks/types/memberships";
import { deleteOrganization, updateOrganization } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
const ZUpdateOrganizationNameAction = z.object({
@@ -51,256 +33,6 @@ export const updateOrganizationNameAction = authenticatedActionClient
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
});
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const deleteInviteAction = authenticatedActionClient
.schema(ZDeleteInviteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
return await deleteInvite(parsedInput.inviteId);
});
const ZDeleteMembershipAction = z.object({
userId: ZId,
organizationId: ZId,
});
export const deleteMembershipAction = authenticatedActionClient
.schema(ZDeleteMembershipAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
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(
parsedInput.userId,
parsedInput.organizationId
);
if (!membership) {
throw new AuthenticationError("Not a member of this organization");
}
const isOwner = membership.role === "owner";
if (isOwner) {
const ownerCount = await getOrganizationOwnerCount(parsedInput.organizationId);
if (ownerCount <= 1) {
throw new ValidationError("You cannot delete the last owner of the organization");
}
}
return await deleteMembership(parsedInput.userId, parsedInput.organizationId);
});
const ZLeaveOrganizationAction = z.object({
organizationId: ZId,
});
export const leaveOrganizationAction = authenticatedActionClient
.schema(ZLeaveOrganizationAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing", "member"],
},
],
});
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthenticationError("Not a member of this organization");
}
const { isOwner } = getAccessFlags(membership.role);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isOwner) {
throw new OperationNotAllowedError("You cannot leave an organization you own");
}
if (!isMultiOrgEnabled) {
throw new OperationNotAllowedError(
"You cannot leave the organization because you are the only owner and organization deletion is disabled"
);
}
const memberships = await getMembershipsByUserId(ctx.user.id);
if (!memberships || memberships?.length <= 1) {
throw new ValidationError("You cannot leave the only organization you are a member of");
}
return await deleteMembership(ctx.user.id, parsedInput.organizationId);
});
const ZCreateInviteTokenAction = z.object({
inviteId: z.string(),
});
export const createInviteTokenAction = authenticatedActionClient
.schema(ZCreateInviteTokenAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const invite = await getInvite(parsedInput.inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
}
const inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
});
const ZResendInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const resendInviteAction = authenticatedActionClient
.schema(ZResendInviteAction)
.action(async ({ parsedInput, ctx }) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
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,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const invite = await getInvite(parsedInput.inviteId);
const updatedInvite = await resendInvite(parsedInput.inviteId);
await sendInviteMemberEmail(
parsedInput.inviteId,
updatedInvite.email,
invite?.creator.name ?? "",
updatedInvite.name ?? "",
undefined,
ctx.user.locale
);
});
const ZInviteUserAction = z.object({
organizationId: ZId,
email: z.string(),
name: z.string(),
role: ZOrganizationRole,
});
export const inviteUserAction = authenticatedActionClient
.schema(ZInviteUserAction)
.action(async ({ parsedInput, ctx }) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
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,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
if (parsedInput.role !== "owner") {
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const canDoRoleManagement = await getRoleManagementPermission(organization);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("Role management is disabled");
}
}
const invite = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
name: parsedInput.name,
role: parsedInput.role,
},
currentUserId: ctx.user.id,
});
if (invite) {
await sendInviteMemberEmail(
invite.id,
parsedInput.email,
ctx.user.name ?? "",
parsedInput.name ?? "",
false,
undefined,
ctx.user.locale
);
}
return invite;
});
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});

View File

@@ -14,10 +14,10 @@ import { TOrganization } from "@formbricks/types/organizations";
interface AIToggleProps {
environmentId: string;
organization: TOrganization;
isUserManagerOrOwner: boolean;
isOwnerOrManager: boolean;
}
export const AIToggle = ({ organization, isUserManagerOrOwner }: AIToggleProps) => {
export const AIToggle = ({ organization, isOwnerOrManager }: AIToggleProps) => {
const t = useTranslations();
const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -65,7 +65,7 @@ export const AIToggle = ({ organization, isUserManagerOrOwner }: AIToggleProps)
</Label>
<Switch
id="formbricks-ai-toggle"
disabled={!isUserManagerOrOwner || isSubmitting}
disabled={!isOwnerOrManager || isSubmitting}
checked={isAIEnabled}
onClick={(e) => {
e.stopPropagation();
@@ -85,7 +85,7 @@ export const AIToggle = ({ organization, isUserManagerOrOwner }: AIToggleProps)
.
</div>
</div>
{!isUserManagerOrOwner && (
{!isOwnerOrManager && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("environments.settings.general.only_org_owner_can_perform_action")}

View File

@@ -1,64 +0,0 @@
"use client";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { useTranslations } from "next-intl";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { BulkInviteTab } from "./BulkInviteTab";
import { IndividualInviteTab } from "./IndividualInviteTab";
interface AddMemberModalProps {
open: boolean;
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
environmentId: string;
}
export const AddMemberModal = ({
open,
setOpen,
onSubmit,
canDoRoleManagement,
isFormbricksCloud,
environmentId,
}: AddMemberModalProps) => {
const t = useTranslations();
const tabs = [
{
title: t("environments.settings.general.individual_invite"),
children: (
<IndividualInviteTab
setOpen={setOpen}
environmentId={environmentId}
onSubmit={onSubmit}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
/>
),
},
{
title: t("environments.settings.general.bulk_invite"),
children: (
<BulkInviteTab
setOpen={setOpen}
onSubmit={onSubmit}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
/>
),
},
];
return (
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
label={t("environments.settings.general.invite_organization_member")}
closeOnOutsideClick={true}
/>
</>
);
};

View File

@@ -1,2 +0,0 @@
// export { EditMembershipsClient } from "./EditMembershipscClient";
export { EditMemberships } from "./EditMemberships";

View File

@@ -1,137 +0,0 @@
"use client";
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { zodResolver } from "@hookform/resolvers/zod";
import { OrganizationRole } from "@prisma/client";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { ZUserName } from "@formbricks/types/user";
interface IndividualInviteTabProps {
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
environmentId: string;
}
export const IndividualInviteTab = ({
setOpen,
onSubmit,
canDoRoleManagement,
isFormbricksCloud,
environmentId,
}: IndividualInviteTabProps) => {
const ZFormSchema = z.object({
name: ZUserName,
email: z.string().email("Invalid email address"),
role: ZOrganizationRole,
});
type TFormData = z.infer<typeof ZFormSchema>;
const t = useTranslations();
const {
register,
getValues,
handleSubmit,
reset,
control,
watch,
formState: { isSubmitting, errors },
} = useForm<TFormData>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
role: "owner",
},
});
const submitEventClass = async () => {
const data = getValues();
data.role = data.role || OrganizationRole.owner;
onSubmit([data]);
setOpen(false);
reset();
};
return (
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg">
<div className="w-full space-y-4">
<div>
<Label htmlFor="memberNameInput">{t("common.full_name")}</Label>
<Input
id="memberNameInput"
placeholder="Hans Wurst"
{...register("name", { required: true, validate: (value) => value.trim() !== "" })}
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name.message}</p>}
</div>
<div>
<Label htmlFor="memberEmailInput">{t("common.email")}</Label>
<Input
id="memberEmailInput"
type="email"
placeholder="hans@wurst.com"
{...register("email", { required: true })}
/>
</div>
<div>
<AddMemberRole
control={control}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
/>
{watch("role") === "member" && (
<Alert className="mt-2" variant="info">
<AlertDescription>
{t("environments.settings.general.member_role_info_message")}
</AlertDescription>
</Alert>
)}
{!canDoRoleManagement && (
<UpgradePrompt
title={t("environments.settings.general.upgrade_plan_notice_message")}
buttons={[
{
text: t("common.start_free_trial"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
)}
</div>
</div>
</div>
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button
size="sm"
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{t("environments.settings.general.send_invitation")}
</Button>
</div>
</div>
</form>
);
};

View File

@@ -9,11 +9,6 @@ const Loading = () => {
const t = useTranslations();
const cards = [
{
title: t("environments.settings.general.manage_members"),
description: t("environments.settings.general.manage_members_description"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }, { classes: "h-8 w-80" }],
},
{
title: t("environments.settings.general.organization_name"),
description: t("environments.settings.general.organization_name_description"),

View File

@@ -1,38 +1,20 @@
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 { authOptions } from "@/modules/auth/lib/authOptions";
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getIsMultiOrgEnabled, getIsOrganizationAIReady } from "@/modules/ee/license-check/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { Suspense } from "react";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
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 { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditMemberships } from "./components/EditMemberships";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
const MembersLoading = () => (
<div className="px-2">
{Array.from(Array(2)).map((_, index) => (
<div key={index} className="mt-4">
<div className={`h-8 w-80 animate-pulse rounded-full bg-slate-200`} />
</div>
))}
</div>
);
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslations();
@@ -45,18 +27,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const canDoRoleManagement = await getRoleManagementPermission(organization);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const userMemberships = await getMembershipsByUserId(session.user.id);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
const isLeaveOrganizationDisabled = userMemberships.length <= 1;
const isUserManagerOrOwner = isManager || isOwner;
const isOwnerOrManager = isManager || isOwner;
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
@@ -68,37 +47,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="general"
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("environments.settings.general.manage_members")}
description={t("environments.settings.general.manage_members_description")}>
{currentUserRole && (
<OrganizationActions
organization={organization}
isUserManagerOrOwner={isUserManagerOrOwner}
role={currentUserRole}
isLeaveOrganizationDisabled={isLeaveOrganizationDisabled}
isInviteDisabled={INVITE_DISABLED}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
environmentId={params.environmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
/>
)}
{currentUserMembership && (
<Suspense fallback={<MembersLoading />}>
<EditMemberships
organization={organization}
currentUserId={session.user?.id}
allMemberships={userMemberships}
currentUserMembership={currentUserMembership}
/>
</Suspense>
)}
</SettingsCard>
<SettingsCard
title={t("environments.settings.general.organization_name")}
description={t("environments.settings.general.organization_name_description")}>
@@ -115,7 +65,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<AIToggle
environmentId={params.environmentId}
organization={organization}
isUserManagerOrOwner={isUserManagerOrOwner}
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
)}

View File

@@ -1,3 +0,0 @@
import { TeamDetails } from "@/modules/ee/teams/team-details/page";
export default TeamDetails;

View File

@@ -1,3 +1,3 @@
import { TeamsPage } from "@/modules/ee/teams/team-list/page";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
export default TeamsPage;

View File

@@ -1,4 +1,5 @@
import { Badge } from "@/modules/ui/components/badge";
import { useTranslations } from "next-intl";
import { cn } from "@formbricks/lib/cn";
export const SettingsCard = ({
@@ -18,6 +19,7 @@ export const SettingsCard = ({
beta?: boolean;
className?: string;
}) => {
const t = useTranslations();
return (
<div
className={cn(
@@ -29,8 +31,10 @@ export const SettingsCard = ({
<div className="flex">
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
<div className="ml-2">
{beta && <Badge text="Beta" size="normal" type="warning" />}
{soon && <Badge text="coming soon" size="normal" type="success" />}
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (
<Badge size="normal" type="success" text={t("environments.settings.enterprise.coming_soon")} />
)}
</div>
</div>
<p className="mt-1 text-sm text-slate-500">{description}</p>

View File

@@ -45,7 +45,7 @@ export const EnableInsightsBanner = ({
<div className="flex-1">
<AlertTitle>
<span className="mr-2">{t("environments.surveys.summary.enable_ai_insights_banner_title")}</span>
<Badge text="Beta" type="gray" size="normal" />
<Badge type="gray" size="normal" text="Beta" />
</AlertTitle>
<AlertDescription className="flex items-start justify-between gap-4">
{t("environments.surveys.summary.enable_ai_insights_banner_description")}

View File

@@ -137,7 +137,12 @@ export const ShareEmbedSurvey = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<UsersRound className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.send_to_panel")}
<Badge size="tiny" type="success" text="New" className="absolute right-3 top-3" />
<Badge
size="tiny"
type="success"
className="absolute right-3 top-3"
text={t("common.new")}
/>
</button>
</div>
</div>

View File

@@ -147,10 +147,10 @@ export const SurveyAnalysisCTA = ({
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.resultShareKey && (
<Badge
text={t("environments.surveys.summary.results_are_public")}
type="warning"
size="normal"
className="rounded-lg"
text={t("environments.surveys.summary.results_are_public")}
/>
)}

View File

@@ -35,7 +35,6 @@ export const SurveyCard = ({
duplicateSurvey,
locale,
}: SurveyCardProps) => {
const isSurveyCreationDeletionDisabled = isReadOnly;
const t = useTranslations();
const surveyStatusLabel =
survey.status === "inProgress"
@@ -50,6 +49,8 @@ export const SurveyCard = ({
? t("common.paused")
: undefined;
const isSurveyCreationDeletionDisabled = isReadOnly;
const [singleUseId, setSingleUseId] = useState<string | undefined>();
useEffect(() => {
@@ -79,9 +80,15 @@ export const SurveyCard = ({
: `/environments/${environment.id}/surveys/${survey.id}/summary`;
}, [survey.status, survey.id, environment.id]);
return (
<Link href={linkHref} key={survey.id} className="relative block">
<div className="grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out hover:border-slate-400">
const isDraftAndReadOnly = survey.status === "draft" && isReadOnly;
const CardContent = (
<>
<div
className={cn(
"grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out",
!isDraftAndReadOnly && "hover:border-slate-400"
)}>
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
<div className="w-full truncate">{survey.name}</div>
</div>
@@ -102,7 +109,6 @@ export const SurveyCard = ({
<div className="col-span-1 flex justify-between">
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{convertDateString(survey.createdAt.toString())}
</div>
@@ -121,12 +127,21 @@ export const SurveyCard = ({
environment={environment}
otherEnvironment={otherEnvironment!}
webAppUrl={WEBAPP_URL}
disabled={isDraftAndReadOnly}
singleUseId={singleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
duplicateSurvey={duplicateSurvey}
deleteSurvey={deleteSurvey}
/>
</div>
</>
);
return isDraftAndReadOnly ? (
<div className="relative block">{CardContent}</div>
) : (
<Link href={linkHref} key={survey.id} className="relative block">
{CardContent}
</Link>
);
};

View File

@@ -29,6 +29,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import type { TEnvironment } from "@formbricks/types/environment";
import { CopySurveyModal } from "./CopySurveyModal";
@@ -39,6 +40,7 @@ interface SurveyDropDownMenuProps {
otherEnvironment: TEnvironment;
webAppUrl: string;
singleUseId?: string;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
duplicateSurvey: (survey: TSurvey) => void;
deleteSurvey: (surveyId: string) => void;
@@ -49,6 +51,7 @@ export const SurveyDropDownMenu = ({
survey,
webAppUrl,
singleUseId,
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
duplicateSurvey,
@@ -107,8 +110,12 @@ export const SurveyDropDownMenu = ({
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
onClick={(e) => e.stopPropagation()}>
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="rounded-lg border bg-white p-2 hover:bg-slate-50">
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}>
<span className="sr-only">{t("environments.surveys.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>

View File

@@ -1,5 +1,6 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { sendInviteAcceptedEmail } from "@/modules/email";
import { createTeamMembership } from "@/modules/invite/lib/team";
import { Button } from "@/modules/ui/components/button";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
@@ -43,6 +44,9 @@ const Page = async (props) => {
accepted: true,
role: invite.role,
});
if (invite.teamIds) {
await createTeamMembership(invite, user.id);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",

View File

@@ -41,6 +41,7 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
email: parsedInput.email,
name: "",
role: "manager",
teamIds: [],
},
currentUserId: ctx.user.id,
});

View File

@@ -1,6 +1,6 @@
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { type TTeamRole } from "@/modules/ee/teams/team-list/types/teams";
import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { returnValidationErrors } from "next-safe-action";
import { ZodIssue, z } from "zod";
import { getMembershipRole } from "@formbricks/lib/membership/hooks/actions";

View File

@@ -5,6 +5,7 @@ import { createUser } from "@/modules/auth/lib/user";
import { updateUser } from "@/modules/auth/lib/user";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
import { createTeamMembership } from "@/modules/invite/lib/team";
import { z } from "zod";
import { hashPassword } from "@formbricks/lib/auth";
import { getInvite } from "@formbricks/lib/invite/service";
@@ -49,6 +50,10 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
role: invite.role,
});
if (invite.teamIds) {
await createTeamMembership(invite, user.id);
}
await updateUser(user.id, {
notificationSettings: {
alert: {},

View File

@@ -129,7 +129,7 @@ export const PricingCard = ({
{t(plan.name)}
</h2>
{isCurrentPlan && (
<Badge text={t("environments.settings.billing.current_plan")} type="success" size="normal" />
<Badge type="success" size="normal" text={t("environments.settings.billing.current_plan")} />
)}
</div>
<div className="flex flex-col items-end gap-6 sm:flex-row sm:justify-between lg:flex-col lg:items-stretch">

View File

@@ -153,9 +153,9 @@ export const PricingTable = ({
{cancellingOn && (
<Badge
className="mx-2"
text={`Cancelling: ${cancellingOn ? cancellingOn.toDateString() : ""}`}
size="normal"
type="warning"
text={`Cancelling: ${cancellingOn ? cancellingOn.toDateString() : ""}`}
/>
)}
</h2>
@@ -192,9 +192,9 @@ export const PricingTable = ({
{responsesUnlimitedCheck && (
<Badge
text={t("environments.settings.billing.unlimited_responses")}
type="success"
size="normal"
text={t("environments.settings.billing.unlimited_responses")}
/>
)}
</div>
@@ -218,7 +218,7 @@ export const PricingTable = ({
)}
{peopleUnlimitedCheck && (
<Badge text={t("environments.settings.billing.unlimited_miu")} type="success" size="normal" />
<Badge type="success" size="normal" text={t("environments.settings.billing.unlimited_miu")} />
)}
</div>
@@ -240,9 +240,9 @@ export const PricingTable = ({
{projectsUnlimitedCheck && (
<Badge
text={t("environments.settings.billing.unlimited_projects")}
type="success"
size="normal"
text={t("environments.settings.billing.unlimited_projects")}
/>
)}
</div>

View File

@@ -1,6 +1,5 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
@@ -46,8 +45,6 @@ export const PricingPage = async (props) => {
const { isMember } = getAccessFlags(currentUserMembership?.role);
const hasBillingRights = !isMember;
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
@@ -56,7 +53,6 @@ export const PricingPage = async (props) => {
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="billing"
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>

View File

@@ -1,7 +1,7 @@
"use client";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { useEffect, useState } from "react";

View File

@@ -1,6 +1,6 @@
"use client";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { ArrowDownUpIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";

View File

@@ -145,9 +145,9 @@ export const UploadContactsAttributes = ({
{isNewTag ? (
<Badge
size="normal"
text={t("environments.contacts.upload_contacts_modal_attributes_new")}
type="success"
className="rounded-md"
text={t("environments.contacts.upload_contacts_modal_attributes_new")}
/>
) : null}
</div>

View File

@@ -90,11 +90,11 @@ export const ExperiencePageStats = ({ statsFrom, environmentId }: ExperiencePage
<div className="flex items-center font-medium text-slate-700">
<TooltipRenderer tooltipContent={`${stat.value} positive`}>
{stats.overallSentiment === "positive" ? (
<Badge text="Positive" type="success" size="large" />
<Badge type="success" size="large" text={t("common.positive")} />
) : stats.overallSentiment === "negative" ? (
<Badge text="Negative" type="error" size="large" />
<Badge type="error" size="large" text={t("common.negative")} />
) : (
<Badge text="Neutral" type="gray" size="large" />
<Badge type="gray" size="large" text={t("common.neutral")} />
)}
</TooltipRenderer>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { PlusIcon } from "lucide-react";
@@ -197,16 +198,22 @@ export function EditLanguage({ project, locale, isReadOnly }: EditLanguageProps)
)}
<AddLanguageButton onClick={handleAddLanguage} />
</div>
{!isReadOnly && (
<EditSaveButtons
isEditing={isEditing}
onCancel={handleCancelChanges}
onEdit={() => {
setIsEditing(true);
}}
onSave={handleSaveChanges}
t={t}
/>
<EditSaveButtons
isEditing={isEditing}
onCancel={handleCancelChanges}
disabled={isReadOnly}
onEdit={() => {
setIsEditing(true);
}}
onSave={handleSaveChanges}
t={t}
/>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
<ConfirmationModal
buttonText={t("environments.project.languages.remove_language")}
@@ -224,23 +231,24 @@ export function EditLanguage({ project, locale, isReadOnly }: EditLanguageProps)
}
const EditSaveButtons: React.FC<{
disabled: boolean;
isEditing: boolean;
onSave: () => void;
onCancel: () => void;
onEdit: () => void;
t: (key: string) => string;
}> = ({ isEditing, onEdit, onSave, onCancel, t }) =>
}> = ({ isEditing, onEdit, onSave, onCancel, disabled, t }) =>
isEditing ? (
<div className="flex gap-4">
<Button onClick={onSave} size="sm">
<Button onClick={onSave} size="sm" disabled={disabled}>
{t("common.save_changes")}
</Button>
<Button onClick={onCancel} size="sm" variant="ghost">
<Button onClick={onCancel} size="sm" variant="ghost" disabled={disabled}>
{t("common.cancel")}
</Button>
</div>
) : (
<Button className="w-fit" onClick={onEdit} size="sm">
<Button className="w-fit" onClick={onEdit} size="sm" disabled={disabled}>
{t("environments.project.languages.edit_languages")}
</Button>
);

View File

@@ -7,6 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Muted, P } from "@/modules/ui/components/typography";
import { OrganizationRole } from "@prisma/client";
import { useTranslations } from "next-intl";
import type { Control } from "react-hook-form";
@@ -14,7 +15,7 @@ import { Controller } from "react-hook-form";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface AddMemberRoleProps {
control: Control<{ name: string; email: string; role: TOrganizationRole }>;
control: Control<{ name: string; email: string; role: TOrganizationRole; teamIds: string[] }>;
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
}
@@ -25,13 +26,21 @@ export function AddMemberRole({ control, canDoRoleManagement, isFormbricksCloud
: Object.keys(OrganizationRole).filter((role) => role !== "billing");
const t = useTranslations();
const rolesDescription = {
owner: t("environments.settings.teams.owner_role_description"),
manager: t("environments.settings.teams.manager_role_description"),
member: t("environments.settings.teams.member_role_description"),
billing: t("environments.settings.teams.billing_role_description"),
};
return (
<Controller
control={control}
name="role"
render={({ field: { onChange, value } }) => (
<div>
<Label>{t("common.role")}</Label>
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Select
defaultValue="owner"
disabled={!canDoRoleManagement}
@@ -40,13 +49,16 @@ export function AddMemberRole({ control, canDoRoleManagement, isFormbricksCloud
}}
value={value}>
<SelectTrigger className="capitalize">
<SelectValue />
<SelectValue>
<P>{value}</P>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{roles.map((role) => (
<SelectItem className="capitalize" key={role} value={role}>
{role}
<SelectItem key={role} value={role}>
<P className="capitalize">{role}</P>
<Muted className="text-slate-500">{rolesDescription[role]}</Muted>
</SelectItem>
))}
</SelectGroup>

View File

@@ -15,12 +15,13 @@ 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 { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { updateInviteAction, updateMembershipAction } from "../actions";
interface Role {
isUserManagerOrOwner: boolean;
currentUserRole: TOrganizationRole;
memberRole: TOrganizationRole;
organizationId: string;
memberId?: string;
@@ -32,9 +33,9 @@ interface Role {
}
export function EditMembershipRole({
isUserManagerOrOwner,
memberRole,
organizationId,
currentUserRole,
memberId,
userId,
memberAccepted,
@@ -46,7 +47,13 @@ export function EditMembershipRole({
const router = useRouter();
const [loading, setLoading] = useState(false);
const disableRole = memberId === userId || (memberRole === "owner" && !doesOrgHaveMoreThanOneOwner);
const { isOwner, isManager } = getAccessFlags(currentUserRole);
const isOwnerOrManager = isOwner || isManager;
const disableRole =
memberId === userId ||
(memberRole === "owner" && !doesOrgHaveMoreThanOneOwner) ||
(currentUserRole === "manager" && memberRole === "owner");
const handleMemberRoleUpdate = async (role: TOrganizationRole) => {
setLoading(true);
@@ -79,7 +86,7 @@ export function EditMembershipRole({
return roles;
};
if (isUserManagerOrOwner) {
if (isOwnerOrManager) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -99,7 +106,7 @@ export function EditMembershipRole({
onValueChange={(value) => {
handleRoleChange(value.toLowerCase() as TOrganizationRole);
}}
value={capitalizeFirstLetter(memberRole)}>
value={memberRole}>
{getMembershipRoles().map((role) => (
<DropdownMenuRadioItem className="capitalize" key={role} value={role}>
{role.toLowerCase()}
@@ -112,5 +119,5 @@ export function EditMembershipRole({
);
}
return <Badge size="tiny" text={capitalizeFirstLetter(memberRole)} type="gray" />;
return <Badge size="tiny" type="gray" text={capitalizeFirstLetter(memberRole)} />;
}

View File

@@ -1,7 +1,7 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamRole } from "@/modules/ee/teams/team-list/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { TTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";

View File

@@ -1,106 +0,0 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProjectId, getOrganizationIdFromTeamId } from "@/lib/utils/helper";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import {
addTeamAccess,
removeTeamAccess,
updateTeamAccessPermission,
} from "@/modules/ee/teams/project-teams/lib/teams";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZTeamPermission } from "./types/teams";
const ZRemoveAccessAction = z.object({
projectId: z.string(),
teamId: z.string(),
});
export const removeAccessAction = authenticatedActionClient
.schema(ZRemoveAccessAction)
.action(async ({ ctx, parsedInput }) => {
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
if (projectOrganizationId !== teamOrganizationId) {
throw new Error("Team and project are not in the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(projectOrganizationId);
return await removeTeamAccess(parsedInput.projectId, parsedInput.teamId);
});
const ZAddAccessAction = z.object({
projectId: z.string(),
teamIds: z.array(ZId),
});
export const addAccessAction = authenticatedActionClient
.schema(ZAddAccessAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(organizationId);
return await addTeamAccess(parsedInput.projectId, parsedInput.teamIds);
});
const ZUpdateAccessPermissionAction = z.object({
projectId: z.string(),
teamId: z.string(),
permission: ZTeamPermission,
});
export const updateAccessPermissionAction = authenticatedActionClient
.schema(ZUpdateAccessPermissionAction)
.action(async ({ ctx, parsedInput }) => {
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
if (projectOrganizationId !== teamOrganizationId) {
throw new Error("Team and project are not in the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(projectOrganizationId);
return await updateTeamAccessPermission(
parsedInput.projectId,
parsedInput.teamId,
parsedInput.permission
);
});

View File

@@ -1,157 +1,52 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { removeAccessAction, updateAccessPermissionAction } from "@/modules/ee/teams/project-teams/actions";
import { TProjectTeam, TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
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";
interface AccessTableProps {
teams: TProjectTeam[];
environmentId: string;
projectId: string;
isOwnerOrManager: boolean;
}
export const AccessTable = ({ teams, environmentId, projectId, isOwnerOrManager }: AccessTableProps) => {
export const AccessTable = ({ teams }: AccessTableProps) => {
const t = useTranslations();
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [removeAccessModalOpen, setRemoveAccessModalOpen] = useState<boolean>(false);
const router = useRouter();
const removeAccess = async (teamId: string) => {
const removeAccessActionResponse = await removeAccessAction({ projectId, 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({
projectId,
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 (
<>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead>{t("environments.project.teams.team_name")}</TableHead>
<TableHead>{t("environments.project.teams.permission")}</TableHead>
{isOwnerOrManager && <TableHead>Actions</TableHead>}
<div className="overflow-hidden rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead className="font-medium text-slate-500">
{t("environments.project.teams.team_name")}
</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
<TableHead className="font-medium text-slate-500">
{t("environments.project.teams.permission")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_tr:last-child]:border-b">
{teams.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="text-center">
{t("environments.project.teams.no_teams_found")}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{teams.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center">
{t("environments.project.teams.no_teams_found")}
</TableCell>
</TableRow>
)}
{teams.map((team) => (
<TableRow key={team.id}>
<TableCell>
{isOwnerOrManager ? (
<Link href={`/environments/${environmentId}/settings/teams/${team.id}`}>{team.name}</Link>
) : (
team.name
)}
({team.memberCount} members)
</TableCell>
<TableCell>
{isOwnerOrManager ? (
<Select
value={team.permission}
onValueChange={(val: TTeamPermission) => {
handlePermissionChange(team.id, val);
}}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select type" className="text-sm" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamPermission.Enum.read}>
{t("environments.project.teams.read")}
</SelectItem>
<SelectItem value={ZTeamPermission.Enum.readWrite}>
{t("environments.project.teams.read_write")}
</SelectItem>
<SelectItem value={ZTeamPermission.Enum.manage}>
{t("environments.project.teams.manage")}
</SelectItem>
</SelectContent>
</Select>
) : (
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
)}
</TableCell>
{isOwnerOrManager && (
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => {
setSelectedTeamId(team.id);
setRemoveAccessModalOpen(true);
}}>
{t("common.remove")}
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{removeAccessModalOpen && selectedTeamId && (
<AlertDialog
open={removeAccessModalOpen}
setOpen={setRemoveAccessModalOpen}
headerText={t("environments.project.teams.remove_access")}
mainText={t("environments.project.teams.remove_access_confirmation")}
confirmBtnLabel={t("common.confirm")}
onDecline={() => {
setSelectedTeamId(null);
setRemoveAccessModalOpen(false);
}}
onConfirm={() => handleRemoveAccess(selectedTeamId)}
/>
)}
</>
)}
{teams.map((team) => (
<TableRow key={team.id} className="border-slate-200 hover:bg-transparent">
<TableCell className="font-medium">{team.name}</TableCell>
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -2,50 +2,27 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { AccessTable } from "@/modules/ee/teams/project-teams/components/access-table";
import { AddTeam } from "@/modules/ee/teams/project-teams/components/add-team";
import { TOrganizationTeam, TProjectTeam } from "@/modules/ee/teams/project-teams/types/teams";
import { ManageTeam } from "@/modules/ee/teams/project-teams/components/manage-team";
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { useTranslations } from "next-intl";
import { TProject } from "@formbricks/types/project";
interface AccessViewProps {
project: TProject;
teams: TProjectTeam[];
environmentId: string;
organizationTeams: TOrganizationTeam[];
isOwnerOrManager: boolean;
}
export const AccessView = ({
project,
teams,
organizationTeams,
environmentId,
isOwnerOrManager,
}: AccessViewProps) => {
export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessViewProps) => {
const t = useTranslations();
return (
<>
<SettingsCard
title={t("common.teams")}
title={t("common.team_access")}
description={t("environments.project.teams.team_settings_description")}>
<div className="flex justify-end gap-2">
{isOwnerOrManager && (
<AddTeam
organizationTeams={organizationTeams}
projectTeams={teams}
projectId={project.id}
organizationId={project.organizationId}
/>
)}
</div>
<div className="mt-2">
<AccessTable
teams={teams}
projectId={project.id}
environmentId={environmentId}
isOwnerOrManager={isOwnerOrManager}
/>
<div className="mb-4 flex justify-end">
<ManageTeam environmentId={environmentId} isOwnerOrManager={isOwnerOrManager} />
</div>
<AccessTable teams={teams} />
</SettingsCard>
</>
);

View File

@@ -1,93 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { addAccessAction } from "@/modules/ee/teams/project-teams/actions";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { H4 } from "@/modules/ui/components/typography";
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";
interface AddTeamModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamOptions: { label: string; value: string }[];
projectId: string;
}
export const AddTeamModal = ({ open, setOpen, teamOptions, projectId }: AddTeamModalProps) => {
const t = useTranslations();
const [isLoading, setIsLoading] = useState(false);
const [selectedTeams, setSelectedTeams] = useState<string[]>([]);
const router = useRouter();
const handleAddTeam = async (e) => {
e.preventDefault();
setIsLoading(true);
const addTeamActionResponse = await addAccessAction({ projectId, teamIds: selectedTeams });
if (addTeamActionResponse?.data) {
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(addTeamActionResponse);
toast.error(errorMessage);
}
setSelectedTeams([]);
setIsLoading(false);
setOpen(false);
};
return (
<Modal
noPadding
closeOnOutsideClick={true}
size="md"
open={open}
setOpen={setOpen}
className="overflow-visible">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<UsersIcon className="h-5 w-5" />
<H4>{t("environments.project.teams.add_existing_team")}</H4>
</div>
</div>
</div>
<form onSubmit={handleAddTeam}>
<div className="overflow-visible p-6">
<Label htmlFor="team-name" className="mb-1 text-sm font-medium text-slate-900">
{t("environments.project.teams.select_teams")}
</Label>
<MultiSelect
value={selectedTeams}
options={teamOptions}
onChange={(value) => {
setSelectedTeams(value);
}}
/>
</div>
<div className="flex items-end justify-end gap-2 p-6 pt-0">
<Button
variant="secondary"
type="button"
onClick={() => {
setOpen(false);
setSelectedTeams([]);
}}>
{t("common.cancel")}
</Button>
<Button disabled={isLoading} loading={isLoading} type="submit">
{t("common.add")}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -1,58 +0,0 @@
"use client";
import { addAccessAction } from "@/modules/ee/teams/project-teams/actions";
import { AddTeamModal } from "@/modules/ee/teams/project-teams/components/add-team-modal";
import { TOrganizationTeam, TProjectTeam } from "@/modules/ee/teams/project-teams/types/teams";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { Button } from "@/modules/ui/components/button";
import { useTranslations } from "next-intl";
import { useState } from "react";
interface AddTeamProps {
organizationTeams: TOrganizationTeam[];
projectTeams: TProjectTeam[];
projectId: string;
organizationId: string;
}
export const AddTeam = ({ organizationTeams, projectTeams, projectId, organizationId }: AddTeamProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState<boolean>(false);
const [addTeamModalOpen, setAddTeamModalOpen] = useState<boolean>(false);
const t = useTranslations();
const teams = organizationTeams
.filter((team) => !projectTeams.find((projectTeam) => projectTeam.id === team.id))
.map((team) => ({ label: team.name, value: team.id }));
const onCreate = async (teamId: string) => {
await addAccessAction({ projectId: projectId, teamIds: [teamId] });
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setCreateTeamModalOpen(true)}>
{t("environments.project.teams.create_new_team")}
</Button>
<Button size="sm" onClick={() => setAddTeamModalOpen(true)}>
{t("environments.project.teams.add_existing_team")}
</Button>
{addTeamModalOpen && (
<AddTeamModal
open={addTeamModalOpen}
setOpen={setAddTeamModalOpen}
teamOptions={teams}
projectId={projectId}
/>
)}
{createTeamModalOpen && (
<CreateTeamModal
open={createTeamModalOpen}
setOpen={setCreateTeamModalOpen}
organizationId={organizationId}
onCreate={onCreate}
/>
)}
</>
);
};

View File

@@ -0,0 +1,38 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
interface ManageTeamProps {
environmentId: string;
isOwnerOrManager: boolean;
}
export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps) => {
const t = useTranslations();
const router = useRouter();
const handleManageTeams = () => {
router.push(`/environments/${environmentId}/settings/teams`);
};
if (isOwnerOrManager) {
return (
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
{t("environments.project.teams.manage_teams")}
</Button>
);
}
return (
<TooltipRenderer
tooltipContent={t("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")}>
<Button variant="secondary" size="sm" disabled>
{t("environments.project.teams.manage_teams")}
</Button>
</TooltipRenderer>
);
};

View File

@@ -0,0 +1,77 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getTeamsByProjectId = reactCache(
async (projectId: string): Promise<TProjectTeam[] | null> =>
cache(
async () => {
validateInputs([projectId, ZId]);
try {
const project = await prisma.project.findUnique({
where: {
id: projectId,
},
});
if (!project) {
throw new ResourceNotFoundError("Project", projectId);
}
const teams = await prisma.team.findMany({
where: {
projectTeams: {
some: {
projectId: projectId,
},
},
},
select: {
id: true,
name: true,
projectTeams: {
where: {
projectId: projectId,
},
select: {
permission: true,
},
},
_count: {
select: {
teamUsers: true,
},
},
},
});
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
permission: team.projectTeams[0].permission,
memberCount: team._count.teamUsers,
}));
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByProjectId-${projectId}`],
{
tags: [teamCache.tag.byProjectId(projectId), projectCache.tag.byId(projectId)],
}
)()
);

View File

@@ -1,277 +0,0 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import {
TOrganizationTeam,
TProjectTeam,
TTeamPermission,
ZTeamPermission,
} from "@/modules/ee/teams/project-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 { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getTeamsByProjectId = reactCache(
async (projectId: string): Promise<TProjectTeam[] | null> =>
cache(
async () => {
validateInputs([projectId, ZId]);
try {
const project = await prisma.project.findUnique({
where: {
id: projectId,
},
});
if (!project) {
throw new ResourceNotFoundError("Project", projectId);
}
const teams = await prisma.team.findMany({
where: {
projectTeams: {
some: {
projectId: projectId,
},
},
},
select: {
id: true,
name: true,
projectTeams: {
where: {
projectId: projectId,
},
select: {
permission: true,
},
},
_count: {
select: {
teamUsers: true,
},
},
},
});
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
permission: team.projectTeams[0].permission,
memberCount: team._count.teamUsers,
}));
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByProjectId-${projectId}`],
{
tags: [teamCache.tag.byProjectId(projectId), projectCache.tag.byId(projectId)],
}
)()
);
export const removeTeamAccess = async (projectId: string, teamId: string): Promise<boolean> => {
validateInputs([projectId, ZId], [teamId, ZId]);
try {
const projectMembership = await prisma.projectTeam.findFirst({
where: {
projectId: projectId,
teamId,
},
});
if (!projectMembership) {
throw new AuthorizationError("Team does not have access to this project");
}
await prisma.projectTeam.deleteMany({
where: {
projectId: projectId,
teamId,
},
});
teamCache.revalidate({
id: teamId,
projectId: projectId,
});
projectCache.revalidate({
id: projectId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addTeamAccess = async (projectId: string, teamIds: string[]): Promise<boolean> => {
validateInputs([projectId, ZId], [teamIds, z.array(ZId)]);
try {
const project = await prisma.project.findUnique({
where: {
id: projectId,
},
});
if (!project) {
throw new ResourceNotFoundError("Project", projectId);
}
for (let teamId of teamIds) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
organizationId: project.organizationId,
},
});
if (!team) {
throw new ResourceNotFoundError("Team", teamId);
}
const projectTeam = await prisma.projectTeam.findFirst({
where: {
projectId,
teamId,
},
});
if (projectTeam) {
throw new AuthorizationError("Teams already have access to this project");
}
await prisma.projectTeam.create({
data: {
projectId,
teamId,
},
});
}
teamCache.revalidate({
projectId: projectId,
});
projectCache.revalidate({
id: projectId,
});
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(
async (organizationId: string): Promise<TOrganizationTeam[] | null> =>
cache(
async () => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
return projectTeams;
} 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 (
projectId: string,
teamId: string,
permission: TTeamPermission
): Promise<boolean> => {
validateInputs([projectId, ZId], [teamId, ZId], [permission, ZTeamPermission]);
try {
const projectMembership = await prisma.projectTeam.findUniqueOrThrow({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
});
if (!projectMembership) {
throw new AuthorizationError("Team does not have access to this project");
}
await prisma.projectTeam.update({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
data: {
permission,
},
});
teamCache.revalidate({
id: teamId,
projectId: projectId,
});
projectCache.revalidate({
id: projectId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -13,7 +13,7 @@ import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getTeamsByOrganizationId, getTeamsByProjectId } from "./lib/teams";
import { getTeamsByProjectId } from "./lib/team";
export const ProjectTeams = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslations();
@@ -46,12 +46,6 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
throw new Error(t("common.teams_not_found"));
}
const organizationTeams = await getTeamsByOrganizationId(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
}
const isOwnerOrManager = isOwner || isManager;
return (
@@ -64,13 +58,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<AccessView
environmentId={params.environmentId}
organizationTeams={organizationTeams}
teams={teams}
project={project}
isOwnerOrManager={isOwnerOrManager}
/>
<AccessView environmentId={params.environmentId} teams={teams} isOwnerOrManager={isOwnerOrManager} />
</PageContentWrapper>
);
};

View File

@@ -1,269 +0,0 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProjectId, getOrganizationIdFromTeamId } from "@/lib/utils/helper";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import {
addTeamMembers,
addTeamProjects,
deleteTeam,
removeTeamMember,
removeTeamProject,
updateTeamName,
updateTeamProjectPermission,
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 }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(organizationId);
return await updateTeamName(parsedInput.teamId, parsedInput.name);
});
const ZDeleteTeamAction = z.object({
teamId: ZId,
});
export const deleteTeamAction = authenticatedActionClient
.schema(ZDeleteTeamAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(organizationId);
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 }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "team",
teamId: parsedInput.teamId,
minPermission: "admin",
},
],
});
await checkRoleManagementPermission(organizationId);
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 organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromTeamId(parsedInput.teamId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "team",
teamId: parsedInput.teamId,
minPermission: "admin",
},
],
});
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
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 checkRoleManagementPermission(organizationId);
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 }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "team",
teamId: parsedInput.teamId,
minPermission: "admin",
},
],
});
await checkRoleManagementPermission(organizationId);
return await addTeamMembers(parsedInput.teamId, parsedInput.userIds);
});
const ZUpdateTeamProjectPermissionAction = z.object({
teamId: ZId,
projectId: ZId,
permission: ZTeamPermission,
});
export const updateTeamProjectPermissionAction = authenticatedActionClient
.schema(ZUpdateTeamProjectPermissionAction)
.action(async ({ ctx, parsedInput }) => {
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
if (teamOrganizationId !== projectOrganizationId) {
throw new Error("Team and Project must belong to the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(projectOrganizationId);
return await updateTeamProjectPermission(
parsedInput.teamId,
parsedInput.projectId,
parsedInput.permission
);
});
const ZRemoveTeamProjectAction = z.object({
teamId: ZId,
projectId: ZId,
});
export const removeTeamProjectAction = authenticatedActionClient
.schema(ZRemoveTeamProjectAction)
.action(async ({ ctx, parsedInput }) => {
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
if (teamOrganizationId !== projectOrganizationId) {
throw new Error("Team and Project must belong to the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(projectOrganizationId);
return await removeTeamProject(parsedInput.teamId, parsedInput.projectId);
});
const ZAddTeamProjectsAction = z.object({
teamId: ZId,
projectIds: z.array(ZId),
});
export const addTeamProjectsAction = authenticatedActionClient
.schema(ZAddTeamProjectsAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(organizationId);
return await addTeamProjects(parsedInput.teamId, parsedInput.projectIds);
});

View File

@@ -1,96 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { addTeamMembersAction } from "@/modules/ee/teams/team-details/actions";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { H4 } from "@/modules/ui/components/typography";
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";
interface AddTeamMemberModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamId: string;
organizationMemberOptions: { label: string; value: string }[];
}
export const AddTeamMemberModal = ({
open,
setOpen,
teamId,
organizationMemberOptions,
}: AddTeamMemberModalProps) => {
const t = useTranslations();
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
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 (
<Modal noPadding size="md" open={open} setOpen={setOpen} className="overflow-visible">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<UserIcon className="h-5 w-5" />
<H4>{t("environments.settings.teams.add_members")}</H4>
</div>
</div>
</div>
<form onSubmit={handleAddMembers}>
<div className="overflow-visible p-6">
<Label className="mb-1 text-sm font-medium text-slate-900">
{t("environments.settings.teams.organization_members")}
</Label>
<MultiSelect
value={selectedUsers}
options={organizationMemberOptions}
onChange={(value) => {
setSelectedUsers(value);
}}
/>
</div>
<div className="flex items-end justify-end gap-2 p-6 pt-0">
<Button
variant="secondary"
type="button"
onClick={() => {
setOpen(false);
setSelectedUsers([]);
}}>
{t("common.cancel")}
</Button>
<Button disabled={isLoading} loading={isLoading} type="submit">
{t("common.add")}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -1,92 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { addTeamProjectsAction } from "@/modules/ee/teams/team-details/actions";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { H4 } from "@/modules/ui/components/typography";
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";
interface AddTeamProjectModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamId: string;
projectOptions: { label: string; value: string }[];
}
export const AddTeamProjectModal = ({ open, setOpen, teamId, projectOptions }: AddTeamProjectModalProps) => {
const t = useTranslations();
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleAddProjects = async (e) => {
e.preventDefault();
setIsLoading(true);
const addMembersActionResponse = await addTeamProjectsAction({
teamId,
projectIds: selectedProjects,
});
if (addMembersActionResponse?.data) {
toast.success(t("environments.settings.teams.members_added_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(addMembersActionResponse);
toast.error(errorMessage);
}
setSelectedProjects([]);
setIsLoading(false);
setOpen(false);
};
return (
<Modal noPadding size="md" open={open} setOpen={setOpen} className="overflow-visible">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<UserIcon className="h-5 w-5" />
<H4>{t("environments.settings.teams.add_projects")}</H4>
</div>
</div>
</div>
<form onSubmit={handleAddProjects}>
<div className="overflow-visible p-6">
<Label className="mb-1 text-sm font-medium text-slate-900">
{t("environments.settings.teams.organization_projects")}
</Label>
<MultiSelect
value={selectedProjects}
options={projectOptions}
onChange={(value) => {
setSelectedProjects(value);
}}
/>
</div>
<div className="flex items-end justify-end gap-2 p-6 pt-0">
<Button
variant="secondary"
type="button"
onClick={() => {
setOpen(false);
setSelectedProjects([]);
}}>
Cancel
</Button>
<Button disabled={isLoading} loading={isLoading} type="submit">
{t("common.add")}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -1,87 +0,0 @@
"use client";
import { TeamMembers } from "@/modules/ee/teams/team-details/components/team-members";
import { TeamProjects } from "@/modules/ee/teams/team-details/components/team-projects";
import { TeamSettings } from "@/modules/ee/teams/team-details/components/team-settings";
import {
TOrganizationMember,
TOrganizationProject,
TTeam,
TTeamProject,
} from "@/modules/ee/teams/team-details/types/teams";
import { TTeamRole } from "@/modules/ee/teams/team-list/types/teams";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { H3 } from "@/modules/ui/components/typography";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface DetailsViewProps {
team: TTeam;
userId: string;
membershipRole?: TOrganizationRole;
organizationMembers: TOrganizationMember[];
teamRole: TTeamRole | null;
projects: TTeamProject[];
organizationProjects: TOrganizationProject[];
}
export const DetailsView = ({
team,
userId,
membershipRole,
organizationMembers,
teamRole,
projects,
organizationProjects,
}: DetailsViewProps) => {
const t = useTranslations();
const [activeId, setActiveId] = useState<"members" | "settings" | "projects">("members");
const navigation = [
{
id: "members",
label: t("common.members"),
onClick: () => setActiveId("members"),
},
{
id: "projects",
label: t("common.projects"),
onClick: () => setActiveId("projects"),
},
{
id: "settings",
label: t("common.settings"),
onClick: () => setActiveId("settings"),
},
];
return (
<div>
<H3>{team.name}</H3>
<div className="mt-2 border-b">
<SecondaryNavigation navigation={navigation} activeId={activeId} />
</div>
{activeId === "members" && (
<TeamMembers
members={team.teamUsers}
currentUserId={userId}
teamId={team.id}
organizationMembers={organizationMembers}
membershipRole={membershipRole}
teamRole={teamRole}
/>
)}
{activeId === "projects" && (
<TeamProjects
organizationProjects={organizationProjects}
membershipRole={membershipRole}
projects={projects}
teamId={team.id}
/>
)}
{activeId === "settings" && <TeamSettings team={team} membershipRole={membershipRole} />}
</div>
);
};

View File

@@ -1,106 +0,0 @@
"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 { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
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";
interface EditTeamNameProps {
team: TTeam;
membershipRole?: TOrganizationRole;
}
const ZEditTeamNameFormSchema = ZTeam.pick({ name: true });
type EditTeamNameForm = z.infer<typeof ZEditTeamNameFormSchema>;
export const EditTeamNameForm = ({ membershipRole, team }: EditTeamNameProps) => {
const t = useTranslations();
const form = useForm<EditTeamNameForm>({
defaultValues: {
name: team.name,
},
mode: "onChange",
resolver: zodResolver(ZEditTeamNameFormSchema),
});
const { isMember } = getAccessFlags(membershipRole);
const { isSubmitting, isDirty } = form.formState;
const handleUpdateTeamName: SubmitHandler<EditTeamNameForm> = 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 ? (
<p className="text-sm text-red-700">
{t("common.only_organization_owners_and_managers_can_access_this_setting")}
</p>
) : (
<FormProvider {...form}>
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(handleUpdateTeamName)}>
<FormField
control={form.control}
name="name"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>{t("environments.settings.teams.team_name")}</FormLabel>
<FormControl>
<Input
{...field}
id="team-name"
type="text"
isInvalid={!!fieldState.error?.message}
placeholder={t("environments.settings.teams.team_name")}
required
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
{t("common.update")}
</Button>
</form>
</FormProvider>
);
};

View File

@@ -1,238 +0,0 @@
"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 { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/modules/ui/components/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
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";
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<boolean>(false);
const [removeMemberModalOpen, setRemoveMemberModalOpen] = useState<boolean>(false);
const [selectedTeamMemberId, setSelectedTeamMemberId] = useState<string | null>(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 (
<>
<Card className="mt-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("environments.settings.teams.team_members")}</CardTitle>
<div className="flex gap-2">
{isOwnerOrManager && (
<Button variant="secondary" size="sm" asChild>
<Link href="../general">{t("environments.settings.teams.invite_member")}</Link>
</Button>
)}
{canPerformRoleManagement && (
<Button size="sm" onClick={() => setOpenAddMemberModal(true)}>
{t("environments.settings.teams.add_member")}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead>{t("common.member")}</TableHead>
<TableHead>{t("common.role")}</TableHead>
{canPerformRoleManagement && <TableHead>{t("common.actions")}</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center">
{t("environments.settings.teams.no_members_found")}
</TableCell>
</TableRow>
)}
{members.map((teamMember) => (
<TableRow key={teamMember.id}>
<TableCell>
<div>
<div className="font-semibold">{teamMember.name}</div>
<div className="text-sm text-gray-500">{teamMember.email}</div>
</div>
</TableCell>
<TableCell>
{canPerformRoleManagement &&
teamMember.isRoleEditable &&
currentUserId !== teamMember.id ? (
<Select
disabled={!teamMember.isRoleEditable}
value={teamMember.role}
onValueChange={(val: TTeamRole) => {
handleRoleChange(teamMember.id, val);
}}>
<SelectTrigger className="w-40">
<SelectValue
placeholder={t("environments.settings.teams.select_type")}
className="text-sm"
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamRole.Enum.admin}>
{t("environments.settings.teams.team_admin")}
</SelectItem>
<SelectItem value={ZTeamRole.Enum.contributor}>
{t("environments.settings.teams.contributor")}
</SelectItem>
</SelectContent>
</Select>
) : canPerformRoleManagement && currentUserId !== teamMember.id ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger className="flex items-center gap-2">
<p>{TeamRoleMapping[teamMember.role]}</p>
<InfoIcon className="h-4 w-4 text-gray-500" />
</TooltipTrigger>
<TooltipContent>
{t("environments.settings.teams.org_owner_and_managers_can_only_be_team_admin")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<p>{TeamRoleMapping[teamMember.role]}</p>
)}
</TableCell>
{canPerformRoleManagement && (
<TableCell>
{(teamMember.id !== currentUserId ||
(teamMember.id === currentUserId && isOwnerOrManager)) && (
<Button
variant="destructive"
size="sm"
onClick={() => {
setSelectedTeamMemberId(teamMember.id);
setRemoveMemberModalOpen(true);
}}>
{teamMember.id === currentUserId
? t("environments.settings.teams.leave")
: t("common.remove")}
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{openAddMemberModal && (
<AddTeamMemberModal
teamId={teamId}
open={openAddMemberModal}
setOpen={setOpenAddMemberModal}
organizationMemberOptions={organizationMemberOptions}
/>
)}
{removeMemberModalOpen && selectedTeamMemberId && (
<AlertDialog
open={removeMemberModalOpen}
setOpen={setRemoveMemberModalOpen}
headerText={t("environments.settings.teams.leave_team")}
mainText={
currentUserId === selectedTeamMemberId
? t("environments.settings.teams.leave_team_confirmation")
: t("environments.settings.teams.remove_member_confirmation")
}
confirmBtnLabel={t("common.confirm")}
onDecline={() => {
setSelectedTeamMemberId(null);
setRemoveMemberModalOpen(false);
}}
onConfirm={() => handleRemoveMember(selectedTeamMemberId)}
/>
)}
</>
);
};

View File

@@ -1,32 +0,0 @@
"use client";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/modules/ui/components/breadcrumb";
import { useTranslations } from "next-intl";
interface TeamsNavigationBreadcrumbsProps {
teamName: string;
}
export function TeamsNavigationBreadcrumbs({ teamName }: TeamsNavigationBreadcrumbsProps) {
const t = useTranslations();
return (
<Breadcrumb className="mt-3">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="./">{t("common.teams")}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{teamName}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -1,199 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { updateTeamProjectPermissionAction } from "@/modules/ee/teams/team-details/actions";
import { AddTeamProjectModal } from "@/modules/ee/teams/team-details/components/add-team-project-modal";
import { TOrganizationProject, TTeamProject } from "@/modules/ee/teams/team-details/types/teams";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/modules/ui/components/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
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 { removeTeamProjectAction } from "../actions";
interface TeamProjectsProps {
membershipRole?: TOrganizationRole;
projects: TTeamProject[];
teamId: string;
organizationProjects: TOrganizationProject[];
}
export const TeamProjects = ({
membershipRole,
projects,
teamId,
organizationProjects,
}: TeamProjectsProps) => {
const t = useTranslations();
const [openAddProjectModal, setOpenAddProjectModal] = useState<boolean>(false);
const [removeProjectModalOpen, setRemoveProjectModalOpen] = useState<boolean>(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const handleRemoveProject = async (projectId: string) => {
const removeProjectActionResponse = await removeTeamProjectAction({
teamId,
projectId: projectId,
});
if (removeProjectActionResponse?.data) {
toast.success(t("environments.settings.teams.project_removed_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(removeProjectActionResponse);
toast.error(errorMessage);
}
setRemoveProjectModalOpen(false);
};
const handlePermissionChange = async (projectId: string, permission: TTeamPermission) => {
const updateTeamPermissionResponse = await updateTeamProjectPermissionAction({
teamId,
projectId,
permission,
});
if (updateTeamPermissionResponse?.data) {
toast.success(t("environments.settings.teams.permission_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateTeamPermissionResponse);
toast.error(errorMessage);
}
};
const projectOptions = useMemo(
() =>
organizationProjects
.filter((project) => !projects.find((p) => p.id === project.id))
.map((project) => ({
label: project.name,
value: project.id,
})),
[organizationProjects, projects]
);
return (
<>
<Card className="mt-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("environments.settings.teams.team_projects")}</CardTitle>
<div className="flex gap-2">
{isOwnerOrManager && (
<Button size="sm" onClick={() => setOpenAddProjectModal(true)}>
{t("environments.settings.teams.add_project")}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead>{t("environments.settings.teams.project_name")}</TableHead>
<TableHead>{t("environments.settings.teams.permission")}</TableHead>
{isOwnerOrManager && <TableHead>{t("common.actions")}</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{projects.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center">
{t("environments.settings.teams.empty_project_message")}
</TableCell>
</TableRow>
)}
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-semibold">{project.name}</TableCell>
<TableCell>
{isOwnerOrManager ? (
<Select
value={project.permission}
onValueChange={(val: TTeamPermission) => {
handlePermissionChange(project.id, val);
}}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select type" className="text-sm" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamPermission.Enum.read}>
{t("environments.settings.teams.read")}
</SelectItem>
<SelectItem value={ZTeamPermission.Enum.readWrite}>
{t("environments.settings.teams.read_write")}
</SelectItem>
<SelectItem value={ZTeamPermission.Enum.manage}>
{t("environments.settings.teams.manage")}
</SelectItem>
</SelectContent>
</Select>
) : (
<p>{TeamPermissionMapping[project.permission]}</p>
)}
</TableCell>
{isOwnerOrManager && (
<TableCell>
<Button
disabled={!isOwnerOrManager}
variant="destructive"
size="sm"
onClick={() => {
setSelectedProjectId(project.id);
setRemoveProjectModalOpen(true);
}}>
{t("common.remove")}
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{openAddProjectModal && (
<AddTeamProjectModal
teamId={teamId}
open={openAddProjectModal}
setOpen={setOpenAddProjectModal}
projectOptions={projectOptions}
/>
)}
{removeProjectModalOpen && selectedProjectId && (
<AlertDialog
open={removeProjectModalOpen}
setOpen={setRemoveProjectModalOpen}
headerText={t("environments.settings.teams.remove_project")}
mainText={t("environments.settings.teams.remove_project_confirmation")}
confirmBtnLabel={t("common.confirm")}
onDecline={() => {
setSelectedProjectId(null);
setRemoveProjectModalOpen(false);
}}
onConfirm={() => handleRemoveProject(selectedProjectId)}
/>
)}
</>
);
};

View File

@@ -1,29 +0,0 @@
"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 (
<div>
<SettingsCard
title={t("environments.settings.teams.team_name")}
description={t("environments.settings.teams.team_name_description")}>
<EditTeamNameForm team={team} membershipRole={membershipRole} />
</SettingsCard>
<SettingsCard title={t("environments.settings.teams.delete_team")} description="">
<DeleteTeam teamId={team.id} membershipRole={membershipRole} />
</SettingsCard>
</div>
);
};

View File

@@ -1,697 +0,0 @@
import "server-only";
import { membershipCache } from "@/lib/cache/membership";
import { teamCache } from "@/lib/cache/team";
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import {
TOrganizationMember,
TOrganizationProject,
TTeam,
TTeamProject,
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 { projectCache } from "@formbricks/lib/project/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(
async (teamId: string): Promise<TTeam> =>
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,
projectTeams: {
select: {
projectId: true,
},
},
},
});
teamCache.revalidate({ id: teamId, organizationId: updatedTeam.organizationId });
for (const projectTeam of updatedTeam.projectTeams) {
teamCache.revalidate({ projectId: projectTeam.projectId });
}
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<boolean> => {
validateInputs([teamId, ZId]);
try {
const deletedTeam = await prisma.team.delete({
where: {
id: teamId,
},
select: {
organizationId: true,
projectTeams: {
select: {
projectId: true,
},
},
},
});
teamCache.revalidate({ id: teamId, organizationId: deletedTeam.organizationId });
for (const projectTeam of deletedTeam.projectTeams) {
teamCache.revalidate({ projectId: projectTeam.projectId });
}
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<boolean> => {
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<boolean> => {
validateInputs([teamId, ZId], [userId, ZId]);
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
organizationId: true,
projectTeams: {
where: {
teamId,
},
select: {
projectId: 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,
});
projectCache.revalidate({ userId });
for (const projectTeam of team.projectTeams) {
teamCache.revalidate({ projectId: projectTeam.projectId });
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getMembersByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationMember[]> =>
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<boolean> => {
validateInputs([teamId, ZId], [userIds, z.array(ZId)]);
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
organizationId: true,
organization: {
select: {
projects: {
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 });
projectCache.revalidate({ userId });
}
for (const project of team.organization.projects) {
teamCache.revalidate({ projectId: project.id });
}
projectCache.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 getTeamProjects = reactCache(
async (teamId: string): Promise<TTeamProject[]> =>
cache(
async () => {
validateInputs([teamId, ZId]);
try {
const projects = await prisma.projectTeam.findMany({
where: {
teamId,
},
select: {
project: {
select: {
id: true,
name: true,
},
},
permission: true,
},
});
return projects.map((project) => ({
id: project.project.id,
name: project.project.name,
permission: project.permission,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamProjects-${teamId}`],
{ tags: [teamCache.tag.byId(teamId)] }
)()
);
export const updateTeamProjectPermission = async (
teamId: string,
projectId: string,
permission: TTeamPermission
): Promise<boolean> => {
validateInputs([teamId, ZId], [projectId, ZId], [permission, ZTeamPermission]);
try {
const projectTeam = await prisma.projectTeam.findUnique({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
});
if (!projectTeam) {
throw new ResourceNotFoundError("projectTeam", null);
}
await prisma.projectTeam.update({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
data: {
permission,
},
});
teamCache.revalidate({ id: teamId, projectId: projectId });
projectCache.revalidate({ id: projectId });
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const removeTeamProject = async (teamId: string, projectId: string): Promise<boolean> => {
validateInputs([teamId, ZId], [projectId, ZId]);
try {
const project = await prisma.project.findUnique({
where: {
id: projectId,
},
select: {
id: true,
organizationId: true,
environments: {
select: {
id: true,
},
},
},
});
if (!project) {
throw new ResourceNotFoundError("project", projectId);
}
const projectTeam = await prisma.projectTeam.findUnique({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
});
if (!projectTeam) {
throw new ResourceNotFoundError("projectTeam", null);
}
await prisma.projectTeam.delete({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
});
teamCache.revalidate({ id: teamId, projectId: projectId });
projectCache.revalidate({ id: projectId, organizationId: project.organizationId });
for (const environment of project.environments) {
organizationCache.revalidate({ environmentId: environment.id });
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getProjectsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationProject[]> =>
cache(
async () => {
validateInputs([organizationId, ZString]);
try {
const projects = await prisma.project.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
return projects.map((project) => ({
id: project.id,
name: project.name,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw new UnknownError("Error while fetching projects");
}
},
[`getProjectsByOrganizationId-${organizationId}`],
{
tags: [projectCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const addTeamProjects = async (teamId: string, projectIds: string[]): Promise<boolean> => {
validateInputs([teamId, ZId], [projectIds, 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 projectId of projectIds) {
const project = await prisma.project.findUnique({
where: {
id: projectId,
organizationId: team.organizationId,
},
select: {
environments: {
select: {
id: true,
},
},
},
});
if (!project) {
throw new ResourceNotFoundError("project", projectId);
}
const projectTeam = await prisma.projectTeam.findUnique({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
});
if (projectTeam) {
continue;
}
await prisma.projectTeam.create({
data: {
projectId,
teamId,
permission: "read",
},
});
teamCache.revalidate({ id: teamId, projectId: projectId });
projectCache.revalidate({ id: projectId, organizationId: team.organizationId });
for (const environment of project.environments) {
organizationCache.revalidate({ environmentId: environment.id });
}
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,72 +0,0 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
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,
getProjectsByOrganizationId,
getTeam,
getTeamProjects,
} from "@/modules/ee/teams/team-details/lib/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
export const TeamDetails = async (props) => {
const params = await props.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 teamProjects = await getTeamProjects(params.teamId);
const organizationProjects = await getProjectsByOrganizationId(organization.id);
return (
<PageContentWrapper>
<TeamsNavigationBreadcrumbs teamName={team.name} />
<DetailsView
team={team}
organizationMembers={organizationMembers}
userId={userId}
membershipRole={currentUserMembership?.role}
teamRole={teamRole}
projects={teamProjects}
organizationProjects={organizationProjects}
/>
</PageContentWrapper>
);
};

View File

@@ -1,43 +0,0 @@
import { ZTeamPermission } from "@/modules/ee/teams/project-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<typeof ZTeamMember>;
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<typeof ZTeam>;
export const ZOrganizationMember = z.object({
id: z.string(),
name: z.string(),
});
export type TOrganizationMember = z.infer<typeof ZOrganizationMember>;
export const TTeamProject = z.object({
id: z.string(),
name: z.string(),
permission: ZTeamPermission,
});
export type TTeamProject = z.infer<typeof TTeamProject>;
export const ZOrganizationProject = z.object({
id: z.string(),
name: z.string(),
});
export type TOrganizationProject = z.infer<typeof ZOrganizationProject>;

View File

@@ -0,0 +1,134 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromTeamId } from "@/lib/utils/helper";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
import {
createTeam,
deleteTeam,
getTeamDetails,
updateTeamDetails,
} from "@/modules/ee/teams/team-list/lib/team";
import { ZTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
const ZCreateTeamAction = z.object({
organizationId: z.string().cuid(),
name: z.string().trim().min(1, "Team name is required"),
});
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"],
},
],
});
await checkRoleManagementPermission(parsedInput.organizationId);
return await createTeam(parsedInput.organizationId, parsedInput.name);
});
const ZGetTeamDetailsAction = z.object({
teamId: ZId,
});
export const getTeamDetailsAction = authenticatedActionClient
.schema(ZGetTeamDetailsAction)
.action(async ({ parsedInput, ctx }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
teamId: parsedInput.teamId,
type: "team",
minPermission: "admin",
},
],
});
await checkRoleManagementPermission(organizationId);
return await getTeamDetails(parsedInput.teamId);
});
const ZDeleteTeamAction = z.object({
teamId: ZId,
});
export const deleteTeamAction = authenticatedActionClient
.schema(ZDeleteTeamAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkRoleManagementPermission(organizationId);
return await deleteTeam(parsedInput.teamId);
});
const ZUpdateTeamAction = z.object({
teamId: ZId,
data: ZTeamSettingsFormSchema,
});
export const updateTeamDetailsAction = authenticatedActionClient
.schema(ZUpdateTeamAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "team",
teamId: parsedInput.teamId,
minPermission: "admin",
},
],
});
await checkRoleManagementPermission(organizationId);
return await updateTeamDetails(parsedInput.teamId, parsedInput.data);
});
const ZGetTeamRoleAction = z.object({
teamId: ZId,
});
export const getTeamRoleAction = authenticatedActionClient
.schema(ZGetTeamRoleAction)
.action(async ({ ctx, parsedInput }) => {
return await getTeamRoleByTeamIdUserId(parsedInput.teamId, ctx.user.id);
});

View File

@@ -1,30 +0,0 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { createTeam } from "@/modules/ee/teams/team-list/lib/teams";
import { z } from "zod";
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"],
},
],
});
await checkRoleManagementPermission(parsedInput.organizationId);
return await createTeam(parsedInput.organizationId, parsedInput.name);
});

View File

@@ -14,7 +14,7 @@ export const CreateTeamButton = ({ organizationId }: CreateTeamButtonProps) => {
const [openCreateTeamModal, setOpenCreateTeamModal] = useState<boolean>(false);
return (
<>
<Button size="sm" onClick={() => setOpenCreateTeamModal(true)}>
<Button size="sm" variant="secondary" onClick={() => setOpenCreateTeamModal(true)}>
{t("environments.settings.teams.create_new_team")}
</Button>
{openCreateTeamModal && (

View File

@@ -1,7 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { createTeamAction } from "@/modules/ee/teams/team-list/action";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";

View File

@@ -0,0 +1,22 @@
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslations } from "next-intl";
interface ManageTeamButtonProps {
onClick: () => void;
disabled: boolean;
}
export const ManageTeamButton = ({ onClick, disabled }: ManageTeamButtonProps) => {
const t = useTranslations();
return (
<TooltipRenderer
shouldRender={disabled}
tooltipContent={t("environments.settings.teams.manage_team_disabled")}>
<Button size="sm" variant="secondary" disabled={disabled} onClick={onClick}>
{t("environments.settings.teams.manage_team")}
</Button>
</TooltipRenderer>
);
};

View File

@@ -1,22 +1,23 @@
"use client";
import { deleteTeamAction } from "@/modules/ee/teams/team-details/actions";
import { TTeam } from "@/modules/ee/teams/team-details/types/teams";
import { deleteTeamAction } from "@/modules/ee/teams/team-list/action";
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
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";
interface DeleteTeamProps {
teamId: TTeam["id"];
membershipRole?: TOrganizationRole;
onDelete: () => void;
isOwnerOrManager: boolean;
}
export const DeleteTeam = ({ teamId, membershipRole }: DeleteTeamProps) => {
export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamProps) => {
const t = useTranslations();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -29,7 +30,8 @@ export const DeleteTeam = ({ teamId, membershipRole }: DeleteTeamProps) => {
const deleteTeamActionResponse = await deleteTeamAction({ teamId });
if (deleteTeamActionResponse?.data) {
toast.success(t("environments.settings.teams.team_deleted_successfully"));
router.push("./");
onDelete?.();
router.refresh();
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
}
@@ -38,31 +40,26 @@ export const DeleteTeam = ({ teamId, membershipRole }: DeleteTeamProps) => {
setIsDeleting(false);
};
const { isMember } = getAccessFlags(membershipRole);
const isDeleteDisabled = isMember;
return (
<div>
{isDeleteDisabled ? (
<p className="text-sm text-red-700">
{t("common.only_organization_owners_and_managers_can_access_this_setting")}
</p>
) : (
<div>
<p className="text-sm text-slate-900">
{t("environments.settings.teams.this_action_cannot_be_undone_if_it_s_gone_it_s_gone")}
</p>
<>
<div className="flex flex-col space-y-2">
<Label htmlFor="deleteTeamButton">{t("common.danger_zone")}</Label>
<TooltipRenderer
shouldRender={!isOwnerOrManager}
tooltipContent={t("environments.settings.teams.team_deletion_not_allowed")}
className="w-auto">
<Button
size="sm"
disabled={isDeleteDisabled}
variant="destructive"
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
size="sm"
type="button"
id="deleteTeamButton"
className="w-auto"
disabled={!isOwnerOrManager}
onClick={() => setIsDeleteDialogOpen(true)}>
{t("common.delete")}
{t("environments.settings.teams.delete_team")}
</Button>
</div>
)}
</TooltipRenderer>
</div>
{isDeleteDialogOpen && (
<DeleteDialog
@@ -74,6 +71,6 @@ export const DeleteTeam = ({ teamId, membershipRole }: DeleteTeamProps) => {
isDeleting={isDeleting}
/>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,532 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/action";
import { DeleteTeam } from "@/modules/ee/teams/team-list/components/team-settings/delete-team";
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
import {
TOrganizationMember,
TTeamDetails,
TTeamRole,
TTeamSettingsFormSchema,
ZTeamRole,
ZTeamSettingsFormSchema,
} from "@/modules/ee/teams/team-list/types/team";
import { getTeamAccessFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { H4, Muted } from "@/modules/ui/components/typography";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface TeamSettingsModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
team: TTeamDetails;
orgMembers: TOrganizationMember[];
orgProjects: TOrganizationProject[];
membershipRole?: TOrganizationRole;
userTeamRole: TTeamRole | undefined;
currentUserId: string;
}
export const TeamSettingsModal = ({
open,
setOpen,
team,
orgMembers,
orgProjects,
userTeamRole,
membershipRole,
currentUserId,
}: TeamSettingsModalProps) => {
const t = useTranslations();
const { isOwner, isManager, isMember } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const { isAdmin, isContributor } = getTeamAccessFlags(userTeamRole);
const isTeamAdminMember = isMember && isAdmin;
const isTeamContributorMember = isMember && isContributor;
const router = useRouter();
const initialMembers = useMemo(() => {
const members = team.members.map((member) => ({
userId: member.userId,
role: member.role,
}));
return members.length ? members : [{ userId: "", role: ZTeamRole.enum.contributor }];
}, [team.members]);
const initialProjects = useMemo(() => {
const projects = team.projects.map((project) => ({
projectId: project.projectId,
permission: project.permission,
}));
return projects.length ? projects : [{ projectId: "", permission: ZTeamPermission.enum.read }];
}, [team.projects]);
const form = useForm<TTeamSettingsFormSchema>({
defaultValues: {
name: team.name,
members: initialMembers,
projects: initialProjects,
},
mode: "onChange",
resolver: zodResolver(ZTeamSettingsFormSchema),
});
const {
control,
handleSubmit,
formState: { isSubmitting },
setValue,
watch,
} = form;
const closeSettingsModal = () => {
setOpen(false);
};
const handleUpdateTeam: SubmitHandler<TTeamSettingsFormSchema> = async (data) => {
const members = data.members.filter((m) => m.userId);
const projects = data.projects.filter((p) => p.projectId);
const updatedTeamActionResponse = await updateTeamDetailsAction({
teamId: team.id,
data: {
name: data.name,
members,
projects,
},
});
if (updatedTeamActionResponse?.data) {
toast.success(t("environments.settings.teams.team_updated_successfully"));
closeSettingsModal();
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedTeamActionResponse);
toast.error(errorMessage);
}
};
const watchMembers = watch("members");
const watchProjects = useWatch({ control, name: "projects" }) || [];
const handleAddMember = () => {
const newMembers = [...watchMembers, { userId: "", role: ZTeamRole.enum.contributor }];
setValue("members", newMembers);
};
const handleRemoveMember = (index: number) => {
setValue(
"members",
watchMembers.filter((_, i) => i !== index)
);
};
const handleAddProject = () => {
const newProjects = [...watchProjects, { projectId: "", permission: ZTeamPermission.enum.read }];
setValue("projects", newProjects);
};
const handleRemoveProject = (index: number) => {
setValue(
"projects",
watchProjects.filter((_, i) => i !== index)
);
};
const selectedMemberIds = watchMembers.map((m) => m.userId);
const selectedProjectIds = watchProjects.map((p) => p.projectId);
const getMemberOptionsForIndex = (index: number) => {
const currentMemberId = watchMembers[index]?.userId;
return orgMembers
.filter((om) => !selectedMemberIds.includes(om?.id) || om?.id === currentMemberId)
.map((om) => ({ label: om?.name, value: om?.id }));
};
const getProjectOptionsForIndex = (index: number) => {
const currentProjectId = watchProjects[index]?.projectId;
return orgProjects
.filter((op) => !selectedProjectIds.includes(op?.id) || op?.id === currentProjectId)
.map((op) => ({ label: op?.name, value: op?.id }));
};
const handleMemberSelectionChange = (index: number, userId: string) => {
setValue(`members.${index}.userId`, userId);
const chosenMember = orgMembers.find((m) => m.id === userId);
if (chosenMember) {
if (chosenMember.role === "owner" || chosenMember.role === "manager") {
setValue(`members.${index}.role`, "admin");
} else {
setValue(`members.${index}.role`, "contributor");
}
}
};
const hasEmptyMember = watchMembers.some((m) => !m.userId);
const hasEmptyProject = watchProjects.some((p) => !p.projectId);
return (
<Modal
open={open}
setOpen={setOpen}
noPadding
className="overflow-visible"
size="md"
hideCloseButton
closeOnOutsideClick={true}>
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={closeSettingsModal}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.team_settings_description")}
</Muted>
</div>
</div>
</div>
</div>
</div>
<FormProvider {...form}>
<form className="w-full" onSubmit={handleSubmit(handleUpdateTeam)}>
<div className="flex flex-col gap-6 p-6">
<div className="max-h-[500px] space-y-6 overflow-y-auto">
<FormField
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("common.team_name")}</FormLabel>
<FormControl>
<Input
type="text"
placeholder={t("common.team_name")}
{...field}
disabled={!isOwnerOrManager && !isTeamAdminMember}
/>
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
)}
/>
{/* Members Section */}
<div className="space-y-2">
<FormLabel>{t("common.members")}</FormLabel>
<FormField
control={control}
name={`members`}
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="max-h-40 space-y-2 overflow-y-auto p-1">
{watchMembers.map((member, index) => {
const memberOpts = getMemberOptionsForIndex(index);
return (
<div key={`member-${member.userId}-${index}`} className="flex gap-2.5">
<FormField
control={control}
name={`members.${index}.userId`}
render={({ field, fieldState: { error } }) => (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={!isOwnerOrManager && !isTeamAdminMember}
value={member.userId}>
<SelectTrigger>
<SelectValue placeholder="Select member" />
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
)}
/>
<FormField
control={control}
name={`members.${index}.role`}
render={({ field }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={member.role}
disabled={(() => {
const chosenMember = orgMembers.find(
(m) => m.id === watchMembers[index]?.userId
);
if (!chosenMember) return !isOwnerOrManager && !isTeamAdminMember;
return (
chosenMember.role === "owner" ||
chosenMember.role === "manager" ||
isTeamContributorMember ||
chosenMember.id === currentUserId
);
})()}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamRole.enum.admin}>
{t("environments.settings.teams.team_admin")}
</SelectItem>
<SelectItem value={ZTeamRole.enum.contributor}>
{t("environments.settings.teams.contributor")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{/* Delete Button for Member */}
{watchMembers.length > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={
!isOwnerOrManager &&
(!isTeamAdminMember || member.userId === currentUserId)
}
onClick={() => handleRemoveMember(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
);
})}
</div>
{error?.root?.message && (
<FormError className="text-left">{error.root.message}</FormError>
)}
</FormItem>
)}
/>
<TooltipRenderer
shouldRender={selectedMemberIds.length === orgMembers.length || hasEmptyMember}
tooltipContent={
hasEmptyMember
? t("environments.settings.teams.please_fill_all_member_fields")
: t("environments.settings.teams.all_members_added")
}>
<Button
size="default"
type="button"
variant="secondary"
onClick={handleAddMember}
disabled={
(!isOwnerOrManager && !isTeamAdminMember) ||
selectedMemberIds.length === orgMembers.length ||
hasEmptyMember
}>
<PlusIcon className="h-4 w-4" />
<span>Add member</span>
</Button>
</TooltipRenderer>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_members_description")}
</Muted>
</div>
{/* Projects Section */}
<div className="space-y-2">
<FormLabel>Projects</FormLabel>
<FormField
control={control}
name={`projects`}
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="max-h-40 space-y-2 overflow-y-auto p-1">
{watchProjects.map((project, index) => {
const projectOpts = getProjectOptionsForIndex(index);
return (
<div key={`project-${project.projectId}-${index}`} className="flex gap-2.5">
<FormField
control={control}
name={`projects.${index}.projectId`}
render={({ field, fieldState: { error } }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={!isOwnerOrManager}>
<SelectTrigger>
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
)}
/>
<FormField
control={control}
name={`projects.${index}.permission`}
render={({ field }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.permission}
disabled={!isOwnerOrManager}>
<SelectTrigger>
<SelectValue placeholder="Select project role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamPermission.enum.read}>
{t("environments.settings.teams.read")}
</SelectItem>
<SelectItem value={ZTeamPermission.enum.readWrite}>
{t("environments.settings.teams.read_write")}
</SelectItem>
<SelectItem value={ZTeamPermission.enum.manage}>
{t("environments.settings.teams.manage")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{watchProjects.length > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={!isOwnerOrManager}
onClick={() => handleRemoveProject(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
);
})}
</div>
{error?.root?.message && (
<FormError className="text-left">{error.root.message}</FormError>
)}
</FormItem>
)}
/>
<TooltipRenderer
shouldRender={selectedProjectIds.length === orgProjects.length || hasEmptyProject}
tooltipContent={
hasEmptyProject
? t("environments.settings.teams.please_fill_all_project_fields")
: t("environments.settings.teams.all_projects_added")
}>
<Button
size="default"
type="button"
variant="secondary"
onClick={handleAddProject}
disabled={
!isOwnerOrManager || selectedProjectIds.length === orgProjects.length || hasEmptyProject
}>
<PlusIcon className="h-4 w-4" />
<span>Add project</span>
</Button>
</TooltipRenderer>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_projects_description")}
</Muted>
</div>
<div className="w-max">
<DeleteTeam
teamId={team.id}
onDelete={closeSettingsModal}
isOwnerOrManager={isOwnerOrManager}
/>
</div>
</div>
<div className="flex justify-between">
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
{t("common.cancel")}
</Button>
<Button
type="submit"
size="default"
loading={isSubmitting}
disabled={!isOwnerOrManager && !isTeamAdminMember}>
{t("common.save")}
</Button>
</div>
</div>
</form>
</FormProvider>
</Modal>
);
};

View File

@@ -1,83 +1,154 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/action";
import { CreateTeamButton } from "@/modules/ee/teams/team-list/components/create-team-button";
import { TOtherTeam, TUserTeam } from "@/modules/ee/teams/team-list/types/teams";
import { ManageTeamButton } from "@/modules/ee/teams/team-list/components/manage-team-button";
import { TeamSettingsModal } from "@/modules/ee/teams/team-list/components/team-settings/team-settings-modal";
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
import {
TOrganizationMember,
TOtherTeam,
TTeamDetails,
TTeamRole,
TUserTeam,
} from "@/modules/ee/teams/team-list/types/team";
import { Badge } from "@/modules/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/modules/ui/components/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface YourTeamsProps {
interface TeamsTableProps {
teams: { userTeams: TUserTeam[]; otherTeams: TOtherTeam[] };
currentUserId: string;
isOwnerOrManager: boolean;
organizationId: string;
orgMembers: TOrganizationMember[];
orgProjects: TOrganizationProject[];
membershipRole?: TOrganizationRole;
currentUserId: string;
}
export const TeamsTable = ({ teams, organizationId, isOwnerOrManager }: YourTeamsProps) => {
export const TeamsTable = ({
teams,
organizationId,
orgMembers,
orgProjects,
membershipRole,
currentUserId,
}: TeamsTableProps) => {
const t = useTranslations();
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TTeamDetails>();
const [userTeamRole, setUserTeamRole] = useState<TTeamRole | undefined>();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const handleManageTeam = async (teamId: string) => {
const teamDetailsResponse = await getTeamDetailsAction({ teamId });
const teamRoleResult = await getTeamRoleAction({ teamId });
setUserTeamRole(teamRoleResult?.data ?? undefined);
if (teamDetailsResponse?.data) {
setSelectedTeam(teamDetailsResponse.data);
setOpenSettingsModal(true);
} else {
const errorMessage = getFormattedErrorMessage(teamDetailsResponse);
toast.error(errorMessage);
}
};
const { userTeams, otherTeams } = teams;
const allTeams = [...userTeams, ...otherTeams];
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("common.teams")}</CardTitle>
{isOwnerOrManager && <CreateTeamButton organizationId={organizationId} />}
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead>{t("common.name")}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allTeams.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center">
{t("environments.settings.teams.empty_teams_state")}
</TableCell>
</TableRow>
)}
{userTeams.map((team) => (
<TableRow key={team.id} id={team.name}>
<TableCell>
<Link href={`teams/${team.id}`} className="font-semibold hover:underline">
{team.name}
</Link>{" "}
({team.memberCount} {t("common.members")})
</TableCell>
<TableCell className="flex justify-end">
<Badge text={t("environments.settings.teams.your_team")} type="success" size={"tiny"} />
</TableCell>
</TableRow>
))}
{otherTeams.map((team) => (
<TableRow key={team.id} id={team.name}>
<TableCell>
{isOwnerOrManager ? (
<Link href={`teams/${team.id}`} className="font-semibold hover:underline">
{team.name}{" "}
</Link>
) : (
team.name
)}
({team.memberCount} {t("common.members")})
</TableCell>
<TableCell>-</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{isOwnerOrManager && (
<div className="mb-4 flex justify-end">
<CreateTeamButton organizationId={organizationId} />
</div>
)}
<div className="overflow-hidden rounded-lg" aria-label="Teams list">
<Table>
<TableHeader role="rowgroup">
<TableRow className="bg-slate-100" role="row">
<TableHead className="font-medium text-slate-500">
{t("environments.settings.teams.team_name")}
</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_tr:last-child]:border-b">
{allTeams.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center hover:bg-transparent">
{t("environments.settings.teams.empty_teams_state")}
</TableCell>
</TableRow>
)}
{userTeams.map((team) => (
<TableRow key={team.id} id={team.name} className="hover:bg-transparent">
<TableCell>{team.name}</TableCell>
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<Badge
type="success"
size={"tiny"}
text={t("environments.settings.teams.you_are_a_member")}
/>
</TableCell>
<TableCell className="flex justify-end">
<ManageTeamButton
disabled={!isOwnerOrManager && team.userRole !== "admin"}
onClick={() => {
handleManageTeam(team.id);
}}
/>
</TableCell>
</TableRow>
))}
{otherTeams.map((team) => (
<TableRow key={team.id} id={team.name} className="hover:bg-transparent">
<TableCell>{team.name}</TableCell>
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell></TableCell>
<TableCell className="flex justify-end">
<ManageTeamButton
disabled={!isOwnerOrManager}
onClick={() => {
handleManageTeam(team.id);
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{openSettingsModal && selectedTeam && (
<TeamSettingsModal
open={openSettingsModal}
setOpen={setOpenSettingsModal}
team={selectedTeam}
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole={membershipRole}
userTeamRole={userTeamRole}
currentUserId={currentUserId}
/>
)}
</>
);
};

View File

@@ -1,30 +1,73 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
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 { getProjectsByOrganizationId } from "@/modules/ee/teams/team-list/lib/project";
import { getTeams } from "@/modules/ee/teams/team-list/lib/team";
import { getMembersByOrganizationId } from "@/modules/organization/settings/teams/lib/membership";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslations } from "next-intl/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface TeamsViewProps {
organizationId: string;
teams: { userTeams: TUserTeam[]; otherTeams: TOtherTeam[] };
membershipRole?: TOrganizationRole;
currentUserId: string;
canDoRoleManagement: boolean;
environmentId: string;
}
export const TeamsView = ({ organizationId, teams, membershipRole, currentUserId }: TeamsViewProps) => {
const { isOwner, isManager } = getAccessFlags(membershipRole);
export const TeamsView = async ({
organizationId,
membershipRole,
currentUserId,
canDoRoleManagement,
environmentId,
}: TeamsViewProps) => {
const t = await getTranslations();
const isOwnerOrManager = isOwner || isManager;
const [teams, orgMembers, orgProjects] = await Promise.all([
getTeams(currentUserId, organizationId),
getMembersByOrganizationId(organizationId),
getProjectsByOrganizationId(organizationId),
]);
if (!teams) {
throw new Error(t("common.teams_not_found"));
}
const buttons: [ModalButton, ModalButton] = [
{
text: t("common.start_free_trial"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
},
{
text: t("common.learn_more"),
href: "https://formbricks.com/docs/self-hosting/license",
},
];
return (
<div className="space-y-4">
<div className="flex flex-col gap-6">
<SettingsCard
title={t("environments.settings.teams.teams")}
description={t("environments.settings.teams.teams_description")}>
{canDoRoleManagement ? (
<TeamsTable
teams={teams}
currentUserId={currentUserId}
isOwnerOrManager={isOwnerOrManager}
membershipRole={membershipRole}
organizationId={organizationId}
orgMembers={orgMembers}
orgProjects={orgProjects}
currentUserId={currentUserId}
/>
</div>
</div>
) : (
<UpgradePrompt
title={t("environments.settings.teams.unlock_teams_title")}
description={t("environments.settings.teams.unlock_teams_description")}
buttons={buttons}
/>
)}
</SettingsCard>
);
};

View File

@@ -0,0 +1,47 @@
import "server-only";
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
export const getProjectsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationProject[]> =>
cache(
async () => {
validateInputs([organizationId, ZString]);
try {
const projects = await prisma.project.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
return projects.map((project) => ({
id: project.id,
name: project.name,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw new UnknownError("Error while fetching projects");
}
},
[`getProjectsByOrganizationId-${organizationId}`],
{
tags: [projectCache.tag.byOrganizationId(organizationId)],
}
)()
);

View File

@@ -0,0 +1,493 @@
import "server-only";
import { organizationCache } from "@/lib/cache/organization";
import { teamCache } from "@/lib/cache/team";
import {
TOrganizationTeam,
TOtherTeam,
TTeamDetails,
TTeamSettingsFormSchema,
TUserTeam,
ZTeamSettingsFormSchema,
} from "@/modules/ee/teams/team-list/types/team";
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 { projectCache } from "@formbricks/lib/project/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";
export const getTeamsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationTeam[] | null> =>
cache(
async () => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByOrganizationId-${organizationId}`],
{
tags: [teamCache.tag.byOrganizationId(organizationId)],
}
)()
);
const getUserTeams = reactCache(
async (userId: string, organizationId: string): Promise<TUserTeam[]> =>
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: {
where: {
userId,
},
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}-${organizationId}`],
{
tags: [
teamCache.tag.byUserId(userId),
userCache.tag.byId(userId),
teamCache.tag.byOrganizationId(organizationId),
],
}
)()
);
export const getOtherTeams = reactCache(
async (userId: string, organizationId: string): Promise<TOtherTeam[]> =>
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}-${organizationId}`],
{
tags: [
teamCache.tag.byUserId(userId),
userCache.tag.byId(userId),
teamCache.tag.byOrganizationId(organizationId),
],
}
)()
);
export const getTeams = reactCache(
async (
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 };
},
[`getTeams-${userId}-${organizationId}`],
{
tags: [
teamCache.tag.byUserId(userId),
userCache.tag.byId(userId),
teamCache.tag.byOrganizationId(organizationId),
],
}
)()
);
export const createTeam = async (organizationId: string, name: string): Promise<string> => {
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;
}
};
export const getTeamDetails = reactCache(
async (teamId: string): Promise<TTeamDetails | null> =>
cache(
async () => {
validateInputs([teamId, ZId]);
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
id: true,
name: true,
organizationId: true,
teamUsers: {
select: {
userId: true,
role: true,
user: {
select: {
name: true,
},
},
},
},
projectTeams: {
select: {
projectId: true,
project: {
select: {
name: true,
},
},
permission: true,
},
},
},
});
if (!team) {
return null;
}
return {
id: team.id,
name: team.name,
organizationId: team.organizationId,
members: team.teamUsers.map((teamUser) => ({
userId: teamUser.userId,
name: teamUser.user.name,
role: teamUser.role,
})),
projects: team.projectTeams.map((projectTeam) => ({
projectId: projectTeam.projectId,
projectName: projectTeam.project.name,
permission: projectTeam.permission,
})),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamDetails-${teamId}`],
{
tags: [teamCache.tag.byId(teamId)],
}
)()
);
export const deleteTeam = async (teamId: string): Promise<boolean> => {
validateInputs([teamId, ZId]);
try {
const deletedTeam = await prisma.team.delete({
where: {
id: teamId,
},
select: {
organizationId: true,
projectTeams: {
select: {
projectId: true,
},
},
},
});
teamCache.revalidate({ id: teamId, organizationId: deletedTeam.organizationId });
for (const projectTeam of deletedTeam.projectTeams) {
teamCache.revalidate({ projectId: projectTeam.projectId });
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateTeamDetails = async (teamId: string, data: TTeamSettingsFormSchema): Promise<boolean> => {
validateInputs([teamId, ZId], [data, ZTeamSettingsFormSchema]);
try {
const { name, members, projects } = data;
const team = await prisma.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new ResourceNotFoundError("Team", teamId);
}
const currentTeamDetails = await getTeamDetails(teamId);
if (!currentTeamDetails) {
throw new Error("Team not found");
}
// Check that all users exist within the organization's membership.
const userIds = members.map((m) => m.userId);
if (userIds.length > 0) {
const orgUsersCount = await prisma.membership.count({
where: {
userId: { in: userIds },
organizationId: team.organizationId,
},
});
if (orgUsersCount !== userIds.length) {
throw new Error("Some specified users do not belong to the organization's membership.");
}
}
// Check that all specified projects belong to the same organization.
const projectIds = projects.map((p) => p.projectId);
if (projectIds.length > 0) {
const orgProjectsCount = await prisma.project.count({
where: {
id: { in: projectIds },
organizationId: team.organizationId,
},
});
if (orgProjectsCount !== projectIds.length) {
throw new Error("Some specified projects do not belong to the organization.");
}
}
// Arrays for tracking member changes
const deletedMembers: string[] = [];
// Arrays for tracking project changes
const deletedProjects: string[] = [];
// Determine deleted members (in current but not in new)
for (const cm of currentTeamDetails.members) {
if (!members.some((m) => m.userId === cm.userId)) {
deletedMembers.push(cm.userId);
}
}
// Determine deleted projects (in current but not in new)
for (const cp of currentTeamDetails.projects) {
if (!projects.some((p) => p.projectId === cp.projectId)) {
deletedProjects.push(cp.projectId);
}
}
// Now build the payload using the arrays computed above
const payload: Prisma.TeamUpdateInput = {
name: currentTeamDetails.name !== name ? name : undefined,
teamUsers: {
deleteMany: {
userId: { in: deletedMembers },
},
upsert: members.map((m) => ({
where: { teamId_userId: { teamId, userId: m.userId } },
update: { role: m.role },
create: { userId: m.userId, role: m.role },
})),
},
projectTeams: {
deleteMany: {
projectId: { in: deletedProjects },
},
upsert: projects.map((p) => ({
where: { projectId_teamId: { teamId, projectId: p.projectId } },
update: { permission: p.permission },
create: { projectId: p.projectId, permission: p.permission },
})),
},
};
await prisma.team.update({
where: { id: teamId },
data: payload,
});
const changedUserIds = [...members.map((m) => m.userId), ...deletedMembers];
const changedProjectIds = [...projects.map((p) => p.projectId), ...deletedProjects];
for (const userId of changedUserIds) {
teamCache.revalidate({ userId });
projectCache.revalidate({ userId });
}
for (const projectId of changedProjectIds) {
teamCache.revalidate({ projectId });
projectCache.revalidate({ id: projectId });
}
teamCache.revalidate({ id: teamId, organizationId: team.organizationId });
projectCache.revalidate({ organizationId: team.organizationId });
const changedEnvironmentIds = await prisma.environment.findMany({
where: {
projectId: {
in: changedProjectIds,
},
},
select: {
id: true,
},
});
for (const environment of changedEnvironmentIds) {
organizationCache.revalidate({ environmentId: environment.id });
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,201 +0,0 @@
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(
async (userId: string, organizationId: string): Promise<TUserTeam[]> =>
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(
async (userId: string, organizationId: string): Promise<TOtherTeam[]> =>
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(
async (
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<string> => {
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;
}
};

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZOrganizationProject = z.object({
id: z.string(),
name: z.string(),
});
export type TOrganizationProject = z.infer<typeof ZOrganizationProject>;

View File

@@ -0,0 +1,103 @@
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZOrganizationRole } from "@formbricks/types/memberships";
export const ZTeamRole = z.enum(["admin", "contributor"]);
export type TTeamRole = z.infer<typeof ZTeamRole>;
export const ZUserTeam = z.object({
id: ZId,
name: z.string(),
userRole: ZTeamRole,
memberCount: z.number(),
});
export type TUserTeam = z.infer<typeof ZUserTeam>;
export const ZOtherTeam = z.object({
id: ZId,
name: z.string(),
memberCount: z.number(),
});
export type TOtherTeam = z.infer<typeof ZOtherTeam>;
export const ZOrganizationTeam = z.object({
id: z.string().cuid2(),
name: z.string(),
});
export type TOrganizationTeam = z.infer<typeof ZOrganizationTeam>;
export const ZTeamDetails = z.object({
id: ZId,
name: z.string(),
organizationId: ZId,
members: z.array(
z.object({
userId: ZId,
name: z.string(),
role: ZTeamRole,
})
),
projects: z.array(
z.object({
projectId: ZId,
projectName: z.string(),
permission: ZTeamPermission,
})
),
});
export type TTeamDetails = z.infer<typeof ZTeamDetails>;
export const ZOrganizationMember = z.object({
id: ZId,
name: z.string(),
role: ZOrganizationRole,
});
export type TOrganizationMember = z.infer<typeof ZOrganizationMember>;
export const ZTeamSettingsFormSchema = z.object({
name: z.string().trim().min(1, "Team name is required"),
members: z
.array(
z.object({
userId: z.string().trim().min(1, "Please select a member"),
role: ZTeamRole,
})
)
.min(1, { message: "Please add at least one member" }),
projects: z
.array(
z.object({
projectId: z.string().trim().min(1, "Please select a project"),
permission: ZTeamPermission,
})
)
.min(1, { message: "Please add at least one project" }),
});
export type TTeamSettingsFormSchema = z.infer<typeof ZTeamSettingsFormSchema>;
export const ZTeamMember = z.object({
role: ZTeamRole,
id: z.string(),
name: z.string(),
email: z.string(),
isRoleEditable: z.boolean(),
});
export type TTeamMember = z.infer<typeof ZTeamMember>;
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<typeof ZTeam>;

View File

@@ -1,22 +0,0 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
export const ZTeamRole = z.enum(["admin", "contributor"]);
export type TTeamRole = z.infer<typeof ZTeamRole>;
export const ZUserTeam = z.object({
id: ZId,
name: z.string(),
userRole: ZTeamRole,
memberCount: z.number(),
});
export type TUserTeam = z.infer<typeof ZUserTeam>;
export const ZOtherTeam = z.object({
id: ZId,
name: z.string(),
memberCount: z.number(),
});
export type TOtherTeam = z.infer<typeof ZOtherTeam>;

View File

@@ -0,0 +1,69 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TInvite, ZInvite } from "@formbricks/types/invites";
export const createTeamMembership = async (invite: TInvite, userId: string): Promise<void> => {
validateInputs([invite, ZInvite], [userId, ZString]);
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
const { isOwner, isManager } = getAccessFlags(userMembershipRole);
const validTeamIds: string[] = [];
const validProjectIds: string[] = [];
const isOwnerOrManager = isOwner || isManager;
try {
for (const teamId of teamIds) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
if (team) {
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
validTeamIds.push(teamId);
validProjectIds.push(...team.projectTeams.map((pt) => pt.projectId));
}
}
for (const projectId of validProjectIds) {
teamCache.revalidate({ id: projectId });
}
for (const teamId of validTeamIds) {
teamCache.revalidate({ id: teamId });
}
teamCache.revalidate({ userId, organizationId: invite.organizationId });
projectCache.revalidate({ userId, organizationId: invite.organizationId });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,279 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { sendInviteMemberEmail } from "@/modules/email";
import {
deleteMembership,
getMembershipsByUserId,
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
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 { getAccessFlags } from "@formbricks/lib/membership/utils";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZOrganizationRole } from "@formbricks/types/memberships";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const deleteInviteAction = authenticatedActionClient
.schema(ZDeleteInviteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
return await deleteInvite(parsedInput.inviteId);
});
const ZCreateInviteTokenAction = z.object({
inviteId: ZUuid,
});
export const createInviteTokenAction = authenticatedActionClient
.schema(ZCreateInviteTokenAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const invite = await getInvite(parsedInput.inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
}
const inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
});
const ZDeleteMembershipAction = z.object({
userId: ZId,
organizationId: ZId,
});
export const deleteMembershipAction = authenticatedActionClient
.schema(ZDeleteMembershipAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
if (parsedInput.userId === ctx.user.id) {
throw new OperationNotAllowedError("You cannot delete yourself from the organization");
}
const currentMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
if (!currentMembership) {
throw new AuthenticationError("Not a member of this organization");
}
const membership = await getMembershipByUserIdOrganizationId(
parsedInput.userId,
parsedInput.organizationId
);
if (!membership) {
throw new AuthenticationError("Not a member of this organization");
}
const isOwner = membership.role === "owner";
if (currentMembership.role === "manager" && isOwner) {
throw new OperationNotAllowedError("You cannot delete the owner of the organization");
}
if (isOwner) {
const ownerCount = await getOrganizationOwnerCount(parsedInput.organizationId);
if (ownerCount <= 1) {
throw new ValidationError("You cannot delete the last owner of the organization");
}
}
return await deleteMembership(parsedInput.userId, parsedInput.organizationId);
});
const ZResendInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const resendInviteAction = authenticatedActionClient
.schema(ZResendInviteAction)
.action(async ({ parsedInput, ctx }) => {
if (INVITE_DISABLED) {
throw new OperationNotAllowedError("Invite are disabled");
}
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,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const invite = await getInvite(parsedInput.inviteId);
const updatedInvite = await resendInvite(parsedInput.inviteId);
await sendInviteMemberEmail(
parsedInput.inviteId,
updatedInvite.email,
invite?.creator.name ?? "",
updatedInvite.name ?? "",
undefined,
ctx.user.locale
);
});
const ZInviteUserAction = z.object({
organizationId: ZId,
email: z.string(),
name: z.string(),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
});
export const inviteUserAction = authenticatedActionClient
.schema(ZInviteUserAction)
.action(async ({ parsedInput, ctx }) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
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,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
if (parsedInput.role !== "owner" || parsedInput.teamIds.length > 0) {
await checkRoleManagementPermission(parsedInput.organizationId);
}
const invite = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
name: parsedInput.name,
role: parsedInput.role,
teamIds: parsedInput.teamIds,
},
currentUserId: ctx.user.id,
});
if (invite) {
await sendInviteMemberEmail(
invite.id,
parsedInput.email,
ctx.user.name ?? "",
parsedInput.name ?? "",
false,
undefined,
ctx.user.locale
);
}
return invite;
});
const ZLeaveOrganizationAction = z.object({
organizationId: ZId,
});
export const leaveOrganizationAction = authenticatedActionClient
.schema(ZLeaveOrganizationAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "billing", "member"],
},
],
});
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthenticationError("Not a member of this organization");
}
const { isOwner } = getAccessFlags(membership.role);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isOwner) {
throw new OperationNotAllowedError("You cannot leave an organization you own");
}
if (!isMultiOrgEnabled) {
throw new OperationNotAllowedError(
"You cannot leave the organization because you are the only owner and organization deletion is disabled"
);
}
const memberships = await getMembershipsByUserId(ctx.user.id);
if (!memberships || memberships?.length <= 1) {
throw new ValidationError("You cannot leave the only organization you are a member of");
}
return await deleteMembership(ctx.user.id, parsedInput.organizationId);
});

View File

@@ -1,31 +1,27 @@
import { MembersInfo } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MembersInfo";
import { getMembersByOrganizationId } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/lib/membership";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { MembersInfo } from "@/modules/organization/settings/teams/components/edit-memberships/members-info";
import { getMembershipByOrganizationId } from "@/modules/organization/settings/teams/lib/membership";
import { getTranslations } from "next-intl/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getInvitesByOrganizationId } from "@formbricks/lib/invite/service";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
type EditMembershipsProps = {
interface EditMembershipsProps {
organization: TOrganization;
currentUserId: string;
currentUserMembership: TMembership;
allMemberships: TMembership[];
};
role: TOrganizationRole;
canDoRoleManagement: boolean;
}
export const EditMemberships = async ({
organization,
currentUserId,
currentUserMembership: membership,
role,
canDoRoleManagement,
}: EditMembershipsProps) => {
const members = await getMembersByOrganizationId(organization.id);
const members = await getMembershipByOrganizationId(organization.id);
const invites = await getInvitesByOrganizationId(organization.id);
const t = await getTranslations();
const currentUserRole = membership?.role;
const isUserManagerOrOwner = membership?.role === "manager" || membership?.role === "owner";
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<div>
@@ -37,13 +33,13 @@ export const EditMemberships = async ({
<div className="col-span-5"></div>
</div>
{currentUserRole && (
{role && (
<MembersInfo
organization={organization}
currentUserId={currentUserId}
invites={invites ?? []}
members={members ?? []}
isUserManagerOrOwner={isUserManagerOrOwner}
currentUserRole={role}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>

View File

@@ -0,0 +1 @@
export { EditMemberships } from "./edit-memberships";

View File

@@ -1,15 +1,16 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
createInviteTokenAction,
deleteInviteAction,
deleteMembershipAction,
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";
} from "@/modules/organization/settings/teams/actions";
import { ShareInviteModal } from "@/modules/organization/settings/teams/components/invite-member/share-invite-modal";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { SendHorizonalIcon, ShareIcon, TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -19,12 +20,12 @@ import { TInvite } from "@formbricks/types/invites";
import { TMember } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
type MemberActionsProps = {
interface MemberActionsProps {
organization: TOrganization;
member?: TMember;
invite?: TInvite;
showDeleteButton?: boolean;
};
}
export const MemberActions = ({ organization, member, invite, showDeleteButton }: MemberActionsProps) => {
const router = useRouter();
@@ -101,46 +102,47 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
};
return (
<>
<div className="flex gap-2">
{showDeleteButton && (
<button type="button" id="deleteMemberButton" onClick={() => setDeleteMemberModalOpen(true)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
<>
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="secondary"
size="icon"
id="deleteMemberButton"
onClick={() => setDeleteMemberModalOpen(true)}>
<TrashIcon />
</Button>
</TooltipRenderer>
</>
)}
{invite && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
handleShareInvite();
}}
className="shareInviteButton">
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>
{t("environments.settings.general.share_invite_link")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
handleResendInvite();
}}
id="resendInviteButton">
<SendHorizonalIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>
{t("environments.settings.general.resend_invitation_email")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<>
<TooltipRenderer tooltipContent={t("environments.settings.general.share_invite_link")}>
<Button
variant="secondary"
size="icon"
id="shareInviteButton"
onClick={() => {
handleShareInvite();
}}>
<ShareIcon />
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("environments.settings.general.resend_invitation_email")}>
<Button
variant="secondary"
size="icon"
id="resendInviteButton"
onClick={() => {
handleResendInvite();
}}>
<SendHorizonalIcon />
</Button>
</TooltipRenderer>
</>
)}
<DeleteDialog
@@ -158,6 +160,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
setOpen={setShowShareInviteModal}
/>
)}
</>
</div>
);
};

View File

@@ -1,20 +1,21 @@
import { MemberActions } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditMemberships/MemberActions";
import { isInviteExpired } from "@/app/lib/utils";
import { EditMembershipRole } from "@/modules/ee/role-management/components/edit-membership-role";
import { MemberActions } from "@/modules/organization/settings/teams/components/edit-memberships/member-actions";
import { Badge } from "@/modules/ui/components/badge";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TInvite } from "@formbricks/types/invites";
import { TMember } from "@formbricks/types/memberships";
import { TMember, TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
type MembersInfoProps = {
interface MembersInfoProps {
organization: TOrganization;
members: TMember[];
invites: TInvite[];
isUserManagerOrOwner: boolean;
currentUserRole: TOrganizationRole;
currentUserId: string;
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
};
}
// Type guard to check if member is an invitee
const isInvitee = (member: TMember | TInvite): member is TInvite => {
@@ -24,7 +25,7 @@ const isInvitee = (member: TMember | TInvite): member is TInvite => {
export const MembersInfo = async ({
organization,
invites,
isUserManagerOrOwner,
currentUserRole,
members,
currentUserId,
canDoRoleManagement,
@@ -32,8 +33,35 @@ export const MembersInfo = async ({
}: MembersInfoProps) => {
const allMembers = [...members, ...invites];
const { isOwner, isManager } = getAccessFlags(currentUserRole);
const isOwnerOrManager = isOwner || isManager;
const doesOrgHaveMoreThanOneOwner = allMembers.filter((member) => member.role === "owner").length > 1;
const showDeleteButton = (member: TMember | TInvite) => {
if (isInvitee(member)) {
return isOwnerOrManager;
}
if (!isOwnerOrManager) {
return false;
}
if (member.userId === currentUserId) {
return false;
}
if (isManager) {
return member.role !== "owner";
}
if (member.role === "owner") {
return doesOrgHaveMoreThanOneOwner;
}
return true;
};
return (
<div className="grid-cols-20" id="membersInfoWrapper">
{allMembers.map((member) => (
@@ -50,7 +78,7 @@ export const MembersInfo = async ({
<div className="ph-no-capture col-span-5 flex flex-col items-start justify-center break-all">
{canDoRoleManagement && allMembers?.length > 0 && (
<EditMembershipRole
isUserManagerOrOwner={isUserManagerOrOwner}
currentUserRole={currentUserRole}
memberRole={member.role}
memberId={!isInvitee(member) ? member.userId : ""}
organizationId={organization.id}
@@ -66,21 +94,16 @@ export const MembersInfo = async ({
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
{isInvitee(member) &&
(isInviteExpired(member) ? (
<Badge className="mr-2" type="gray" text="Expired" size="tiny" />
<Badge className="mr-2" type="gray" size="tiny" text="Expired" />
) : (
<Badge className="mr-2" type="warning" text="Pending" size="tiny" />
<Badge className="mr-2" type="warning" size="tiny" text="Pending" />
))}
<MemberActions
organization={organization}
member={!isInvitee(member) ? member : undefined}
invite={isInvitee(member) ? member : undefined}
showDeleteButton={
isUserManagerOrOwner &&
(member as TMember).userId !== currentUserId &&
((member as TMember).role !== "owner" ||
((member as TMember).role === "owner" && doesOrgHaveMoreThanOneOwner))
}
showDeleteButton={showDeleteButton(member)}
/>
</div>
</div>

View File

@@ -1,11 +1,8 @@
"use client";
import {
inviteUserAction,
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 { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { inviteUserAction, leaveOrganizationAction } from "@/modules/organization/settings/teams/actions";
import { InviteMemberModal } from "@/modules/organization/settings/teams/components/invite-member/invite-member-modal";
import { Button } from "@/modules/ui/components/button";
import { CustomDialog } from "@/modules/ui/components/custom-dialog";
import { XIcon } from "lucide-react";
@@ -14,24 +11,27 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TInvitee } from "@formbricks/types/invites";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
type OrganizationActionsProps = {
role: string;
isUserManagerOrOwner: boolean;
interface OrganizationActionsProps {
role: TOrganizationRole;
isOwnerOrManager: boolean;
isLeaveOrganizationDisabled: boolean;
organization: TOrganization;
teams: TOrganizationTeam[];
isInviteDisabled: boolean;
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
environmentId: string;
isMultiOrgEnabled: boolean;
};
}
export const OrganizationActions = ({
isUserManagerOrOwner,
isOwnerOrManager,
role,
organization,
teams,
isLeaveOrganizationDisabled,
isInviteDisabled,
canDoRoleManagement,
@@ -42,8 +42,7 @@ export const OrganizationActions = ({
const router = useRouter();
const t = useTranslations();
const [isLeaveOrganizationModalOpen, setLeaveOrganizationModalOpen] = useState(false);
const [isCreateOrganizationModalOpen, setCreateOrganizationModalOpen] = useState(false);
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
const [isInviteMemberModalOpen, setInviteMemberModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleLeaveOrganization = async () => {
@@ -60,11 +59,11 @@ export const OrganizationActions = ({
}
};
const handleAddMembers = async (data: TInvitee[]) => {
const handleInviteMembers = async (data: TInvitee[]) => {
try {
await Promise.all(
data.map(async ({ name, email, role }) => {
await inviteUserAction({ organizationId: organization.id, email, name, role });
data.map(async ({ name, email, role, teamIds }) => {
await inviteUserAction({ organizationId: organization.id, email, name, role, teamIds });
})
);
toast.success(t("environments.settings.general.member_invited_successfully"));
@@ -82,37 +81,26 @@ export const OrganizationActions = ({
<XIcon />
</Button>
)}
{isMultiOrgEnabled && (
{!isInviteDisabled && isOwnerOrManager && (
<Button
size="sm"
variant="secondary"
size="sm"
onClick={() => {
setCreateOrganizationModalOpen(true);
setInviteMemberModalOpen(true);
}}>
{t("environments.settings.general.create_new_organization")}
</Button>
)}
{!isInviteDisabled && isUserManagerOrOwner && (
<Button
size="sm"
onClick={() => {
setAddMemberModalOpen(true);
}}>
{t("environments.settings.general.add_member")}
{t("environments.settings.teams.invite_member")}
</Button>
)}
</div>
<CreateOrganizationModal
open={isCreateOrganizationModalOpen}
setOpen={(val) => setCreateOrganizationModalOpen(val)}
/>
<AddMemberModal
open={isAddMemberModalOpen}
setOpen={setAddMemberModalOpen}
onSubmit={handleAddMembers}
<InviteMemberModal
open={isInviteMemberModalOpen}
setOpen={setInviteMemberModalOpen}
onSubmit={handleInviteMembers}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
environmentId={environmentId}
teams={teams}
/>
<CustomDialog

View File

@@ -2,11 +2,12 @@
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { UploadIcon, XIcon } from "lucide-react";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import Papa, { type ParseResult } from "papaparse";
import { useRef, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { ZInvitees } from "@formbricks/types/invites";
import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -25,14 +26,10 @@ export const BulkInviteTab = ({
isFormbricksCloud,
}: BulkInviteTabProps) => {
const t = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [csvFile, setCSVFile] = useState<File>();
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const file = e.target.files[0];
const onFileInputChange = (files: File[]) => {
const file = files[0];
setCSVFile(file);
};
@@ -56,6 +53,7 @@ export const BulkInviteTab = ({
name: name.trim(),
email: email.trim(),
role: orgRole as TOrganizationRole,
teamIds: [],
};
});
try {
@@ -70,34 +68,54 @@ export const BulkInviteTab = ({
});
};
const removeFile = (event: React.MouseEvent<SVGElement>) => {
event.stopPropagation();
const removeFile = () => {
setCSVFile(undefined);
// Reset the file input value to ensure it can detect the same file if re-selected
if (fileInputRef.current) {
fileInputRef.current.value = "";
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
const file = files[0];
if (!file.name.endsWith(".csv")) {
toast.error(t("common.invalid_file_type"));
return;
}
onFileInputChange(files);
};
return (
<div className="space-y-4">
<div
className="relative flex h-52 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-slate-300 bg-slate-50 transition-colors hover:bg-slate-100"
onClick={() => fileInputRef.current?.click()}>
{csvFile ? (
<XIcon
className="absolute right-4 top-4 h-6 w-6 cursor-pointer text-neutral-500"
onClick={removeFile}
/>
) : (
<UploadIcon className="h-6 w-6 text-neutral-500" />
<>
<div className="space-y-4">
<Uploader
allowedFileExtensions={["csv"]}
handleDragOver={handleDragOver}
handleDrop={handleDrop}
handleUpload={onFileInputChange}
id="bulk-invite"
multiple={false}
name="bulk-invite"
disabled={csvFile !== undefined}
uploaderClassName="h-20 bg-white border border-slate-200"
/>
{csvFile && (
<div className="flex items-center space-x-2">
<p className="text-sm font-semibold text-slate-900">{csvFile.name}</p>
<Button variant="secondary" size="sm" type="button" onClick={removeFile}>
<XIcon className="h-4 w-4" />
</Button>
</div>
)}
<span className="text-sm text-neutral-500">
{csvFile ? csvFile.name : t("common.click_here_to_upload")}
</span>
<input onChange={onFileInputChange} type="file" ref={fileInputRef} accept=".csv" hidden />
</div>
<div>
{!canDoRoleManagement && (
<Alert variant="default" className="mt-1.5 flex items-start bg-slate-50">
<AlertDescription className="ml-2">
@@ -109,22 +127,32 @@ export const BulkInviteTab = ({
</Alert>
)}
</div>
<div className="flex justify-end">
<div className="flex justify-between">
<Button
size="default"
type="button"
variant="outline"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<div className="flex space-x-2">
<Link
download
href="/sample-csv/formbricks-organization-members-template.csv"
target="_blank"
rel="noopener noreferrer">
<Button variant="ghost" size="sm">
<Button variant="secondary" size="default">
{t("common.download")} CSV template
</Button>
</Link>
<Button onClick={onImport} size="sm" disabled={!csvFile}>
<Button onClick={onImport} size="default" disabled={!csvFile}>
{t("common.import")}
</Button>
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,176 @@
"use client";
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { Small } from "@/modules/ui/components/typography";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { zodResolver } from "@hookform/resolvers/zod";
import { OrganizationRole } from "@prisma/client";
import { useTranslations } from "next-intl";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { ZUserName } from "@formbricks/types/user";
interface IndividualInviteTabProps {
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
teams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
environmentId: string;
}
export const IndividualInviteTab = ({
setOpen,
onSubmit,
teams,
canDoRoleManagement,
isFormbricksCloud,
environmentId,
}: IndividualInviteTabProps) => {
const ZFormSchema = z.object({
name: ZUserName,
email: z.string().min(1, { message: "Email is required" }).email({ message: "Invalid email" }),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
});
type TFormData = z.infer<typeof ZFormSchema>;
const t = useTranslations();
const form = useForm<TFormData>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
role: "owner",
teamIds: [],
},
});
const {
register,
getValues,
handleSubmit,
reset,
control,
watch,
formState: { isSubmitting, errors },
} = form;
const submitEventClass = async () => {
const data = getValues();
data.role = data.role || OrganizationRole.owner;
onSubmit([data]);
setOpen(false);
reset();
};
const teamOptions = teams.map((team) => ({
label: team.name,
value: team.id,
}));
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(submitEventClass)} className="flex flex-col gap-6">
<div className="flex flex-col space-y-2">
<Label htmlFor="memberNameInput">{t("common.full_name")}</Label>
<Input
id="memberNameInput"
placeholder="e.g. Bob"
{...register("name", { required: true, validate: (value) => value.trim() !== "" })}
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name.message}</p>}
</div>
<div className="flex flex-col space-y-2">
<Label htmlFor="memberEmailInput">{t("common.email")}</Label>
<Input
id="memberEmailInput"
type="email"
placeholder="e.g. bob@work.com"
{...register("email", { required: true })}
/>
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>}
</div>
<div>
<AddMemberRole
control={control}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
/>
{watch("role") === "member" && (
<Alert className="mt-2" variant="info">
<AlertDescription>{t("environments.settings.teams.member_role_info_message")}</AlertDescription>
</Alert>
)}
</div>
{canDoRoleManagement && (
<FormField
control={control}
name="teamIds"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>{t("common.add_to_team")} </FormLabel>
<div>
<MultiSelect
value={field.value}
options={teamOptions}
placeholder={t("environments.settings.teams.team_select_placeholder")}
disabled={!teamOptions.length}
onChange={(val) => field.onChange(val)}
/>
{!teamOptions.length && (
<Small className="italic">
{t("environments.settings.teams.create_first_team_message")}
</Small>
)}
</div>
</FormItem>
)}
/>
)}
{!canDoRoleManagement && (
<UpgradePrompt
title={t("environments.settings.general.upgrade_plan_notice_message")}
buttons={[
{
text: t("common.start_free_trial"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
)}
<div className="flex justify-between">
<Button
size="default"
type="button"
variant="outline"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" size="default" loading={isSubmitting}>
{t("common.invite")}
</Button>
</div>
</form>
</FormProvider>
);
};

View File

@@ -0,0 +1,106 @@
"use client";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { Modal } from "@/modules/ui/components/modal";
import { TabToggle } from "@/modules/ui/components/tab-toggle";
import { H4, Muted } from "@/modules/ui/components/typography";
import { XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { BulkInviteTab } from "./bulk-invite-tab";
import { IndividualInviteTab } from "./individual-invite-tab";
interface InviteMemberModalProps {
open: boolean;
setOpen: (v: boolean) => void;
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
teams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
environmentId: string;
}
export const InviteMemberModal = ({
open,
setOpen,
onSubmit,
teams,
canDoRoleManagement,
isFormbricksCloud,
environmentId,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
const t = useTranslations();
const tabs = {
individual: (
<IndividualInviteTab
setOpen={setOpen}
environmentId={environmentId}
onSubmit={onSubmit}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
teams={teams}
/>
),
bulk: (
<BulkInviteTab
setOpen={setOpen}
onSubmit={onSubmit}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
/>
),
};
return (
<Modal
open={open}
setOpen={setOpen}
noPadding
closeOnOutsideClick={false}
className="overflow-visible"
size="md"
hideCloseButton>
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={() => {
setOpen(false);
}}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>{t("environments.settings.teams.invite_member")}</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.invite_member_description")}
</Muted>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-6 p-6">
<TabToggle
id="type"
options={[
{ value: "individual", label: t("environments.settings.teams.individual") },
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
]}
onChange={(inviteType) => setType(inviteType)}
defaultSelected={type}
/>
{tabs[type]}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,90 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { EditMemberships } from "@/modules/organization/settings/teams/components/edit-memberships";
import { OrganizationActions } from "@/modules/organization/settings/teams/components/edit-memberships/organization-actions";
import { getMembershipsByUserId } from "@/modules/organization/settings/teams/lib/membership";
import { getTranslations } from "next-intl/server";
import { Suspense } from "react";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
interface MembersViewProps {
membershipRole?: TOrganizationRole;
organization: TOrganization;
currentUserId: string;
environmentId: string;
canDoRoleManagement: boolean;
}
const MembersLoading = () => (
<div className="px-2">
{Array.from(Array(2)).map((_, index) => (
<div key={index} className="mt-4">
<div className={`h-8 w-80 animate-pulse rounded-full bg-slate-200`} />
</div>
))}
</div>
);
export const MembersView = async ({
membershipRole,
organization,
currentUserId,
environmentId,
canDoRoleManagement,
}: MembersViewProps) => {
const t = await getTranslations();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
const userMemberships = await getMembershipsByUserId(currentUserId);
const isLeaveOrganizationDisabled = userMemberships.length <= 1;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
let teams: TOrganizationTeam[] = [];
if (canDoRoleManagement) {
teams = (await getTeamsByOrganizationId(organization.id)) ?? [];
if (!teams) {
throw new Error(t("common.teams_not_found"));
}
}
return (
<SettingsCard
title={t("environments.settings.general.manage_members")}
description={t("environments.settings.general.manage_members_description")}>
{membershipRole && (
<OrganizationActions
organization={organization}
isOwnerOrManager={isOwnerOrManager}
role={membershipRole}
isLeaveOrganizationDisabled={isLeaveOrganizationDisabled}
isInviteDisabled={INVITE_DISABLED}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
environmentId={environmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
/>
)}
{membershipRole && (
<Suspense fallback={<MembersLoading />}>
<EditMemberships
canDoRoleManagement={canDoRoleManagement}
organization={organization}
currentUserId={currentUserId}
role={membershipRole}
/>
</Suspense>
)}
</SettingsCard>
);
};

View File

@@ -2,6 +2,7 @@ import "server-only";
import { membershipCache } from "@/lib/cache/membership";
import { organizationCache } from "@/lib/cache/organization";
import { teamCache } from "@/lib/cache/team";
import { TOrganizationMember } from "@/modules/ee/teams/team-list/types/team";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
@@ -10,39 +11,10 @@ import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TMember, TMembership } from "@formbricks/types/memberships";
import { TMember } from "@formbricks/types/memberships";
import { TMembership } from "@formbricks/types/memberships";
export const getOrganizationOwnerCount = reactCache(
async (organizationId: string): Promise<number> =>
cache(
async () => {
validateInputs([organizationId, ZString]);
try {
const ownersCount = await prisma.membership.count({
where: {
organizationId,
role: "owner",
},
});
return ownersCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getOrganizationOwnerCount-${organizationId}`],
{
tags: [membershipCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const getMembersByOrganizationId = reactCache(
export const getMembershipByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TMember[]> =>
cache(
async () => {
@@ -86,7 +58,37 @@ export const getMembersByOrganizationId = reactCache(
throw new UnknownError("Error while fetching members");
}
},
[`getMembersByOrganizationId-${organizationId}-${page}`],
[`getMembershipByOrganizationId-${organizationId}-${page}`],
{
tags: [membershipCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const getOrganizationOwnerCount = reactCache(
async (organizationId: string): Promise<number> =>
cache(
async () => {
validateInputs([organizationId, ZString]);
try {
const ownersCount = await prisma.membership.count({
where: {
organizationId,
role: "owner",
},
});
return ownersCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getOrganizationOwnerCount-${organizationId}`],
{
tags: [membershipCache.tag.byOrganizationId(organizationId)],
}
@@ -194,3 +196,47 @@ export const getMembershipsByUserId = reactCache(
}
)()
);
export const getMembersByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationMember[]> =>
cache(
async () => {
validateInputs([organizationId, ZString]);
try {
const membersData = await prisma.membership.findMany({
where: { organizationId },
select: {
user: {
select: {
name: true,
},
},
role: true,
userId: true,
},
});
const members = membersData.map((member) => {
return {
id: member.userId,
name: member.user?.name || "",
role: member.role,
};
});
return members;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getMembersByOrganizationId-${organizationId}`],
{
tags: [membershipCache.tag.byOrganizationId(organizationId)],
}
)()
);

View File

@@ -2,15 +2,13 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getTeams } from "@/modules/ee/teams/team-list/lib/teams";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
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";
export const TeamsPage = async (props) => {
@@ -28,17 +26,6 @@ export const TeamsPage = async (props) => {
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(t("common.teams_not_found"));
}
return (
<PageContentWrapper>
@@ -48,14 +35,21 @@ export const TeamsPage = async (props) => {
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="teams"
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<MembersView
membershipRole={currentUserMembership?.role}
organization={organization}
currentUserId={session.user.id}
environmentId={params.environmentId}
canDoRoleManagement={canDoRoleManagement}
/>
<TeamsView
teams={teams}
organizationId={organization.id}
membershipRole={currentUserMembership?.role}
currentUserId={session.user.id}
canDoRoleManagement={canDoRoleManagement}
environmentId={params.environmentId}
/>
</PageContentWrapper>
);

View File

@@ -77,7 +77,7 @@ export const ProjectLookSettingsLoading = () => {
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.background_styling")}
</h2>
<Badge text={t("common.link_surveys")} type="gray" size="normal" />
<Badge type="gray" size="normal" text={t("common.link_surveys")} />
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")}

View File

@@ -79,25 +79,25 @@ export const FollowUpItem = ({
<div className="flex space-x-2">
<Badge
size="normal"
type="gray"
text={
followUp.trigger.type === "response"
? t("environments.surveys.edit.follow_ups_item_response_tag")
: t("environments.surveys.edit.follow_ups_item_ending_tag")
}
type="gray"
/>
<Badge
size="normal"
text={t("environments.surveys.edit.follow_ups_item_send_email_tag")}
type="gray"
text={t("environments.surveys.edit.follow_ups_item_send_email_tag")}
/>
{isEmailToInvalid || isEndingInvalid ? (
<Badge
size="normal"
text={t("environments.surveys.edit.follow_ups_item_issue_detected_tag")}
type="warning"
text={t("environments.surveys.edit.follow_ups_item_issue_detected_tag")}
/>
) : null}
</div>

View File

@@ -0,0 +1,30 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@formbricks/lib/cn";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -1,130 +1,159 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
import clsx from "clsx";
import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
"use client";
import { Command, CommandGroup, CommandItem, CommandList } from "@/modules/ui/components/command";
import { Badge } from "@/modules/ui/components/multi-select/badge";
import { Command as CommandPrimitive } from "cmdk";
import { X } from "lucide-react";
import * as React from "react";
interface TOption<T> {
label: string;
value: T;
label: string;
}
interface MultiSelectProps<T extends string, K extends TOption<T>["value"] | TOption<T>["value"][]> {
interface MultiSelectProps<T extends string, K extends TOption<T>["value"][]> {
options: TOption<T>[];
isMultiple?: boolean;
disabled?: boolean;
isDisabledComboBox?: boolean;
value?: K;
onChange: (value: K) => void;
onChange?: (selected: K) => void;
disabled?: boolean;
placeholder?: string;
}
export const MultiSelect = <T extends string, K extends TOption<T>["value"] | TOption<T>["value"][]>({
options,
isMultiple = true,
disabled,
isDisabledComboBox,
value,
onChange,
}: MultiSelectProps<T, K>) => {
const [open, setOpen] = useState(false);
const t = useTranslations();
const isOptionSelected = (optionValue: T) => {
if (Array.isArray(value)) {
return value.includes(optionValue);
}
return value === optionValue;
};
export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
props: MultiSelectProps<T, K>
) {
const { options, value, onChange, disabled = false, placeholder = "Select options..." } = props;
const handleSelect = (optionValue: T) => {
if (isMultiple) {
if (Array.isArray(value)) {
if (isOptionSelected(optionValue)) {
onChange(value.filter((v) => v !== optionValue) as K);
} else {
onChange([...value, optionValue] as K);
}
} else {
onChange([optionValue] as K);
}
} else {
onChange(optionValue as K);
}
setOpen(false);
};
const inputRef = React.useRef<HTMLInputElement>(null);
const filteredOptions = options.filter((option) => {
if (Array.isArray(value)) {
return !value.includes(option.value);
const [selected, setSelected] = React.useState<TOption<T>[]>(() => {
if (value) {
return value.map((val) => options.find((o) => o.value === val)).filter((o): o is TOption<T> => !!o);
}
return option.value !== value;
return [];
});
const commandRef = useRef<HTMLDivElement>(null);
useClickOutside(commandRef, () => setOpen(false));
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
React.useEffect(() => {
if (value) {
setSelected(
value.map((val) => options.find((o) => o.value === val)).filter((o): o is TOption<T> => !!o)
);
}
}, [value, options]);
const handleUnselect = React.useCallback(
(option: TOption<T>) => {
if (disabled) return;
setSelected((prev) => {
const newSelected = prev.filter((s) => s.value !== option.value);
onChange?.(newSelected.map((s) => s.value) as K);
return newSelected;
});
},
[onChange, disabled]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (!input || disabled) return;
if ((e.key === "Delete" || e.key === "Backspace") && input.value === "") {
setSelected((prev) => {
const newSelected = [...prev];
newSelected.pop();
onChange?.(newSelected.map((s) => s.value) as K);
return newSelected;
});
}
if (e.key === "Escape") {
input.blur();
}
},
[onChange, disabled]
);
const selectableOptions = React.useMemo(() => {
return options
.filter((o) => !selected.some((s) => s.value === o.value))
.filter((o) => {
if (!inputValue) return true;
return o.label.toLowerCase().includes(inputValue.toLowerCase());
});
}, [options, selected, inputValue]);
return (
<Command ref={commandRef} className="overflow-visible bg-transparent" id="multi-select-dropdown">
<Command
onKeyDown={handleKeyDown}
className={`overflow-visible bg-white ${disabled ? "cursor-not-allowed opacity-50" : ""}`}>
<div
onClick={() => !disabled && !isDisabledComboBox && setOpen((open) => !open)}
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none border bg-white px-3 py-2 text-sm",
disabled || isDisabledComboBox ? "opacity-50" : "cursor-pointer"
)}>
{value && (Array.isArray(value) ? value.length > 0 : !!value) ? (
!Array.isArray(value) ? (
<p className="text-slate-600">{options.find((option) => option.value === value)?.label}</p>
) : (
<div className="no-scrollbar flex gap-3 overflow-auto" onClick={(e) => e.stopPropagation()}>
{value.map((val) => (
<button
type="button"
key={val}
onClick={() => handleSelect(val)}
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{options.find((option) => option.value === val)?.label}
<XIcon width={14} height={14} className="ml-2" />
</button>
))}
</div>
)
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
<div>
{open ? (
<ChevronUpIcon className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4 opacity-50" />
)}
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${
disabled ? "pointer-events-none" : "focus-within:ring-ring"
}`}>
<div className="flex flex-wrap gap-1">
{selected.map((option) => (
<Badge key={option.value} className="rounded-md">
{option.label}
<button
className="ring-offset-background focus:ring-ring ml-1 rounded-full outline-none focus:ring-2 focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}>
<X className="text-muted-foreground hover:text-foreground h-3 w-3" />
</button>
</Badge>
))}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
className="placeholder:text-muted-foreground h-5 flex-1 border-0 bg-transparent pl-2 outline-none"
/>
</div>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md border bg-white outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((o, idx) => (
<div className="relative mt-2">
<CommandList>
{open && selectableOptions.length > 0 && !disabled && (
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full rounded-md border bg-white shadow-md outline-none">
<CommandGroup className="h-full overflow-auto">
{selectableOptions.map((option) => (
<CommandItem
key={o.value}
onSelect={() => handleSelect(o.value)}
className={cn("cursor-pointer", `option-${idx + 1}`)}>
{o.label}
key={option.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (disabled) return;
const newSelected = [...selected, option];
setSelected(newSelected);
onChange?.(newSelected.map((o) => o.value) as K);
setInputValue("");
}}
className="cursor-pointer">
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
</div>
)}
</CommandList>
</div>
</Command>
);
};
}

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>

View File

@@ -0,0 +1,27 @@
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { KeyIcon } from "lucide-react";
import Link from "next/link";
interface UpgradePlanNoticeProps {
message: string;
url: string;
textForUrl: string;
}
export const UpgradePlanNotice = ({ message, url, textForUrl }: UpgradePlanNoticeProps) => {
return (
<Alert className="flex gap-2 bg-slate-50 p-2 [&:has(svg)]:pl-3">
<div className="flex h-5 w-5 items-center justify-center rounded-sm border border-slate-200 bg-white">
<KeyIcon className="h-3 w-3 text-slate-900" />
</div>
<AlertDescription>
<span className="mr-1 text-slate-600">{message}</span>
<span className="underline">
<Link href={url} target="_blank">
{textForUrl}
</Link>
</span>
</AlertDescription>
</Alert>
);
};

View File

@@ -24,9 +24,11 @@ test.describe("Invite, accept and remove organization member", async () => {
await page.locator('[data-testid="members-loading-card"]:first-child').waitFor({ state: "hidden" });
await page.getByRole("link", { name: "Teams" }).click();
// Add member button
await expect(page.getByRole("button", { name: "Add member" })).toBeVisible();
await page.getByRole("button", { name: "Add member" }).click();
await expect(page.getByRole("button", { name: "Invite member" })).toBeVisible();
await page.getByRole("button", { name: "Invite member" }).click();
// Fill the member name and email form
await expect(page.getByLabel("Email")).toBeVisible();
@@ -35,7 +37,7 @@ test.describe("Invite, accept and remove organization member", async () => {
await expect(page.getByLabel("Email")).toBeVisible();
await page.getByLabel("Email").fill(invites.addMember.email);
await page.getByRole("button", { name: "Send Invitation", exact: true }).click();
await page.getByRole("button", { name: "Invite", exact: true }).click();
await page.waitForLoadState("networkidle");
@@ -49,10 +51,10 @@ test.describe("Invite, accept and remove organization member", async () => {
const lastMemberInfo = page.locator("#membersInfoWrapper > .singleMemberInfo:last-child");
await expect(lastMemberInfo).toBeVisible();
const pendingSpan = lastMemberInfo.locator("span").filter({ hasText: "Pending" });
const pendingSpan = lastMemberInfo.locator("div").filter({ hasText: "Pending" }).first();
await expect(pendingSpan).toBeVisible();
const shareInviteButton = page.locator(".shareInviteButton").last();
const shareInviteButton = page.locator("#shareInviteButton").last();
await expect(shareInviteButton).toBeVisible();
await shareInviteButton.click();
@@ -136,112 +138,46 @@ test.describe("Create, update and delete team", async () => {
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 page.waitForLoadState("networkidle");
await expect(page.getByText("Teams")).toBeVisible();
await page.getByText("Teams").click();
await page.waitForURL(/\/environments\/[^/]+\/settings\/teams/);
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 page.getByRole("button", { name: "Manage team" }).click();
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.getByPlaceholder("Team name").fill("E2E Updated");
await page.locator("#multi-select-dropdown").click();
await page.locator(".option-1").click();
await page.locator("button").filter({ hasText: "Select member" }).first().click();
await page.locator("#member-0-option").click();
await page.getByRole("button", { name: "Add" }).click();
await page.locator("button").filter({ hasText: "Select project" }).first().click();
await page.locator("#project-0-option").click();
await expect(page.getByRole("cell", { name: "No members found" })).toBeHidden();
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("button", { name: "Projects" })).toBeVisible();
await page.getByRole("button", { name: "Projects" }).click();
await page.waitForLoadState("networkidle");
await expect(
page.getByRole("cell", {
name: "You haven't added any projects yet. Assign a project to the team to grant access to its members.",
})
).toBeVisible();
await expect(page.getByRole("cell", { name: "E2E Updated" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add Project" })).toBeVisible();
await page.getByRole("button", { name: "Add Project" }).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 projects yet. Assign a project 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 page.getByRole("button", { name: "Manage team" }).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 page.locator("#deleteTeamButton").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 dont have any teams yet. Create your first team to manage project access for members of your organization.",
})
).toBeVisible();
await expect(page.getByRole("heading", { name: "Organization Settings" })).toBeVisible();
await expect(page.getByRole("cell", { name: "E2E Updated" })).not.toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More