mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
// export { EditMembershipsClient } from "./EditMembershipscClient";
|
||||
export { EditMemberships } from "./EditMemberships";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { TeamDetails } from "@/modules/ee/teams/team-details/page";
|
||||
|
||||
export default TeamDetails;
|
||||
@@ -1,3 +1,3 @@
|
||||
import { TeamsPage } from "@/modules/ee/teams/team-list/page";
|
||||
import { TeamsPage } from "@/modules/organization/settings/teams/page";
|
||||
|
||||
export default TeamsPage;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -41,6 +41,7 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
|
||||
email: parsedInput.email,
|
||||
name: "",
|
||||
role: "manager",
|
||||
teamIds: [],
|
||||
},
|
||||
currentUserId: ctx.user.id,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
77
apps/web/modules/ee/teams/project-teams/lib/team.ts
Normal file
77
apps/web/modules/ee/teams/project-teams/lib/team.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
134
apps/web/modules/ee/teams/team-list/action.ts
Normal file
134
apps/web/modules/ee/teams/team-list/action.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
47
apps/web/modules/ee/teams/team-list/lib/project.ts
Normal file
47
apps/web/modules/ee/teams/team-list/lib/project.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
493
apps/web/modules/ee/teams/team-list/lib/team.ts
Normal file
493
apps/web/modules/ee/teams/team-list/lib/team.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
8
apps/web/modules/ee/teams/team-list/types/project.ts
Normal file
8
apps/web/modules/ee/teams/team-list/types/project.ts
Normal 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>;
|
||||
103
apps/web/modules/ee/teams/team-list/types/team.ts
Normal file
103
apps/web/modules/ee/teams/team-list/types/team.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
69
apps/web/modules/invite/lib/team.ts
Normal file
69
apps/web/modules/invite/lib/team.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
279
apps/web/modules/organization/settings/teams/actions.ts
Normal file
279
apps/web/modules/organization/settings/teams/actions.ts
Normal 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);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
export { EditMemberships } from "./edit-memberships";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
30
apps/web/modules/ui/components/multi-select/badge.tsx
Normal file
30
apps/web/modules/ui/components/multi-select/badge.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
27
apps/web/modules/ui/components/upgrade-plan-notice/index.tsx
Normal file
27
apps/web/modules/ui/components/upgrade-plan-notice/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 don’t 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
Reference in New Issue
Block a user