fix: wrong invite message (#4470)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2024-12-18 19:54:16 +05:30
committed by GitHub
parent 4676b4cd25
commit 1c58ac3704
15 changed files with 167 additions and 75 deletions

View File

@@ -95,7 +95,7 @@ const Page = async (props) => {
</Button>
</ContentLayout>
);
} else if (user?.email !== email) {
} else if (user?.email?.toLowerCase() !== email?.toLowerCase()) {
return (
<ContentLayout
headline={t("auth.invite.email_does_not_match")}

View File

@@ -6,13 +6,14 @@ import { sendInviteMemberEmail } from "@/modules/email";
import { z } from "zod";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { inviteUser } from "@formbricks/lib/invite/service";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError } from "@formbricks/types/errors";
import { ZUserEmail, ZUserName } from "@formbricks/types/user";
const ZInviteOrganizationMemberAction = z.object({
email: z.string(),
email: ZUserEmail,
organizationId: ZId,
name: ZUserName,
});
export const inviteOrganizationMemberAction = authenticatedActionClient
@@ -33,14 +34,12 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
],
});
const organizations = await getOrganizationsByUserId(ctx.user.id);
const invite = await inviteUser({
organizationId: organizations[0].id,
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
name: "",
role: "manager",
name: parsedInput.name,
role: "owner",
teamIds: [],
},
currentUserId: ctx.user.id,

View File

@@ -31,22 +31,26 @@ export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMemb
const { isSubmitting } = form.formState;
const inviteTeamMembers = async (data: TInviteMembersFormSchema) => {
const emails = Object.values(data).filter((email) => email && email.trim());
if (!emails.length) {
const members = Object.values(data).filter((member) => member.email && member.email.trim());
if (!members.length) {
router.push("/");
return;
}
for (const email of emails) {
for (const member of members) {
try {
if (!email) continue;
await inviteOrganizationMemberAction({ email, organizationId });
if (!member.email) continue;
await inviteOrganizationMemberAction({
email: member.email.toLowerCase(),
name: member.name,
organizationId,
});
if (IS_SMTP_CONFIGURED) {
toast.success(`${t("setup.invite.invitation_sent_to")} ${email}!`);
toast.success(`${t("setup.invite.invitation_sent_to")} ${member.email}!`);
}
} catch (error) {
console.error("Failed to invite:", email, error);
toast.error(`${t("setup.invite.failed_to_invite")} ${email}.`);
console.error("Failed to invite:", member.email, error);
toast.error(`${t("setup.invite.failed_to_invite")} ${member.email}.`);
}
}
@@ -71,28 +75,50 @@ export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMemb
<p>{t("setup.invite.life_s_no_fun_alone")}</p>
{Array.from({ length: membersCount }).map((_, index) => (
<FormField
key={`member-${index}`}
control={form.control}
name={`member-${index}`}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormControl>
<div>
<div className="relative">
<Input
{...field}
placeholder={`user@example.com`}
className="w-80"
isInvalid={!!error?.message}
/>
<div key={`member-${index}`} className="space-y-2">
<FormField
control={form.control}
name={`member-${index}.email`}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormControl>
<div>
<div className="relative">
<Input
{...field}
placeholder={`user@example.com`}
className="w-80"
isInvalid={!!error?.message}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`member-${index}.name`}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormControl>
<div>
<div className="relative">
<Input
{...field}
placeholder={`Full Name (optional)`}
className="w-80"
isInvalid={!!error?.message}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
))}
<Button variant="ghost" onClick={() => setMembersCount((count) => count + 1)} type="button">

View File

@@ -26,11 +26,12 @@ export const EditMemberships = async ({
return (
<div>
<div className="rounded-lg border border-slate-200">
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-5">{t("common.full_name")}</div>
<div className="col-span-5">{t("common.email")}</div>
{canDoRoleManagement && <div className="col-span-5">{t("common.role")}</div>}
<div className="col-span-5"></div>
<div className="grid h-12 grid-cols-5 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1">{t("common.full_name")}</div>
<div className="col-span-1 text-center">{t("common.email")}</div>
{canDoRoleManagement && <div className="col-span-1 text-center">{t("common.role")}</div>}
<div className="col-span-1 text-center">{t("common.status")}</div>
<div className="col-span-1"></div>
</div>
{role && (

View File

@@ -2,7 +2,10 @@ 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 { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslations } from "next-intl";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
import { TInvite } from "@formbricks/types/invites";
import { TMember, TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
@@ -22,7 +25,7 @@ const isInvitee = (member: TMember | TInvite): member is TInvite => {
return (member as TInvite).expiresAt !== undefined;
};
export const MembersInfo = async ({
export const MembersInfo = ({
organization,
invites,
currentUserRole,
@@ -32,6 +35,24 @@ export const MembersInfo = async ({
isFormbricksCloud,
}: MembersInfoProps) => {
const allMembers = [...members, ...invites];
const t = useTranslations();
const getMembershipBadge = (member: TMember | TInvite) => {
if (isInvitee(member)) {
return isInviteExpired(member) ? (
<Badge type="gray" text="Expired" size="tiny" data-testid="expired-badge" />
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invited_on", {
date: getFormattedDateTimeString(member.createdAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>
);
}
return <Badge type="success" text="Active" size="tiny" />;
};
const { isOwner, isManager } = getAccessFlags(currentUserRole);
const isOwnerOrManager = isOwner || isManager;
@@ -63,19 +84,19 @@ export const MembersInfo = async ({
};
return (
<div className="grid-cols-20" id="membersInfoWrapper">
<div className="grid-cols-5" id="membersInfoWrapper">
{allMembers.map((member) => (
<div
className="singleMemberInfo grid-cols-20 grid h-auto w-full content-center rounded-lg px-4 py-3 text-left text-sm text-slate-900"
className="singleMemberInfo grid h-auto w-full grid-cols-5 content-center rounded-lg px-4 py-3 text-left text-sm text-slate-900"
key={member.email}>
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
<div className="ph-no-capture col-span-1 flex flex-col justify-center break-all">
<p>{member.name}</p>
</div>
<div className="ph-no-capture col-span-5 flex flex-col justify-center break-all">
<div className="ph-no-capture col-span-1 flex flex-col justify-center break-all text-center">
{member.email}
</div>
<div className="ph-no-capture col-span-5 flex flex-col items-start justify-center break-all">
<div className="ph-no-capture col-span-1 flex flex-col items-center justify-center break-all">
{canDoRoleManagement && allMembers?.length > 0 && (
<EditMembershipRole
currentUserRole={currentUserRole}
@@ -90,15 +111,8 @@ export const MembersInfo = async ({
/>
)}
</div>
<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" size="tiny" text="Expired" />
) : (
<Badge className="mr-2" type="warning" size="tiny" text="Pending" />
))}
<div className="col-span-1 flex items-center justify-center">{getMembershipBadge(member)}</div>
<div className="col-span-1 flex items-center justify-end gap-x-4 pr-4">
<MemberActions
organization={organization}
member={!isInvitee(member) ? member : undefined}

View File

@@ -1,5 +1,6 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
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";
@@ -59,16 +60,55 @@ export const OrganizationActions = ({
}
};
const handleInviteMembers = async (data: TInvitee[]) => {
try {
await Promise.all(
const handleAddMembers = async (data: TInvitee[]) => {
if (data.length === 1) {
// Individual invite
const inviteUserActionResult = await inviteUserAction({
organizationId: organization.id,
email: data[0].email.toLowerCase(),
name: data[0].name,
role: data[0].role,
teamIds: data[0].teamIds,
});
if (inviteUserActionResult?.data) {
toast.success(t("environments.settings.general.member_invited_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(inviteUserActionResult);
toast.error(errorMessage);
}
} else {
const invitePromises = await Promise.all(
data.map(async ({ name, email, role, teamIds }) => {
await inviteUserAction({ organizationId: organization.id, email, name, role, teamIds });
const inviteUserActionResult = await inviteUserAction({
organizationId: organization.id,
email: email.toLowerCase(),
name,
role,
teamIds: teamIds,
});
return {
email,
success: Boolean(inviteUserActionResult?.data),
};
})
);
toast.success(t("environments.settings.general.member_invited_successfully"));
} catch (err) {
toast.error(`${t("common.error")}: ${err.message}`);
let failedInvites: string[] = [];
let successInvites: string[] = [];
invitePromises.forEach((invite) => {
if (!invite.success) {
failedInvites.push(invite.email);
} else {
successInvites.push(invite.email);
}
});
if (failedInvites.length > 0) {
toast.error(`${failedInvites.length} ${t("environments.settings.general.invites_failed")}`);
}
if (successInvites.length > 0) {
toast.success(
`${successInvites.length} ${t("environments.settings.general.member_invited_successfully")}`
);
}
}
};
@@ -96,7 +136,7 @@ export const OrganizationActions = ({
<InviteMemberModal
open={isInviteMemberModalOpen}
setOpen={setInviteMemberModalOpen}
onSubmit={handleInviteMembers}
onSubmit={handleAddMembers}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
environmentId={environmentId}

View File

@@ -51,7 +51,7 @@ 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("div").filter({ hasText: "Pending" }).first();
const pendingSpan = lastMemberInfo.locator("span").locator("span").filter({ hasText: "Pending" });
await expect(pendingSpan).toBeVisible();
const shareInviteButton = page.locator("#shareInviteButton").last();

View File

@@ -1,5 +1,4 @@
/* eslint-disable no-constant-condition -- Required for the while loop */
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";

View File

@@ -1,9 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-call -- required for any type */
/* eslint-disable @typescript-eslint/no-unsafe-assignment -- required for any type */
/* eslint-disable @typescript-eslint/no-unsafe-member-access -- required for any type */
/* eslint-disable @typescript-eslint/no-explicit-any -- required for any type */
import type { MigrationScript } from "../../src/scripts/migration-runner";

View File

@@ -3,7 +3,12 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import {
TInvite,
TInviteUpdateInput,
@@ -220,7 +225,7 @@ export const inviteUser = async ({
const existingInvite = await prisma.invite.findFirst({ where: { email, organizationId } });
if (existingInvite) {
throw new ValidationError("Invite already exists");
throw new InvalidInputError("Invite already exists");
}
const user = await prisma.user.findUnique({ where: { email } });
@@ -229,7 +234,7 @@ export const inviteUser = async ({
const member = await getMembershipByUserIdOrganizationId(user.id, organizationId);
if (member) {
throw new ValidationError("User is already a member of this organization");
throw new InvalidInputError("User is already a member of this organization");
}
}

View File

@@ -1131,6 +1131,8 @@
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invite_organization_member": "Organisationsmitglied einladen",
"invited_on": "Eingeladen am {date}",
"invites_failed": "Einladungen fehlgeschlagen",
"leave_organization": "Organisation verlassen",
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",
"leave_organization_ok_btn_text": "Ja, Organisation verlassen",

View File

@@ -1131,6 +1131,8 @@
"invitation_sent_once_more": "Invitation sent once more.",
"invite_deleted_successfully": "Invite deleted successfully",
"invite_organization_member": "Invite Organization Member",
"invited_on": "Invited on {date}",
"invites_failed": "Invites failed",
"leave_organization": "Leave organization",
"leave_organization_description": "You wil leave this organization and loose access to all surveys and responses. You can only rejoin if you are invited again.",
"leave_organization_ok_btn_text": "Yes, leave organization",

View File

@@ -1131,6 +1131,8 @@
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
"invite_deleted_successfully": "Invitation supprimée avec succès",
"invite_organization_member": "Inviter un membre de l'organisation",
"invited_on": "Invité le {date}",
"invites_failed": "Invitations échouées",
"leave_organization": "Quitter l'organisation",
"leave_organization_description": "Vous quitterez cette organisation et perdrez l'accès à toutes les enquêtes et réponses. Vous ne pourrez revenir que si vous êtes de nouveau invité.",
"leave_organization_ok_btn_text": "Oui, quitter l'organisation",
@@ -1142,7 +1144,7 @@
"once_its_gone_its_gone": "Une fois que c'est parti, c'est parti.",
"only_org_owner_can_perform_action": "Seules les personnes ayant un rôle d'administrateur dans l'organisation peuvent accéder à ce paramètre.",
"organization_created_successfully": "Organisation créée avec succès !",
"organization_deleted_successfully": "",
"organization_deleted_successfully": "Organisation supprimée avec succès.",
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
"organization_name": "Nom de l'organisation",
"organization_name_description": "Donnez à votre organisation un nom descriptif.",

View File

@@ -1131,6 +1131,8 @@
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
"invite_organization_member": "Convidar Membro da Organização",
"invited_on": "Convidado em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Você vai sair dessa organização e perder acesso a todas as pesquisas e respostas. Você só pode voltar se for convidado de novo.",
"leave_organization_ok_btn_text": "Sim, sair da organização",

View File

@@ -38,7 +38,10 @@ export const ZInviteUpdateInput = z.object({
export type TInviteUpdateInput = z.infer<typeof ZInviteUpdateInput>;
export const ZInviteMembersFormSchema = z.record(
z.string().email("Invalid email address").optional().or(z.literal(""))
z.object({
email: z.string().email("Invalid email address").optional().or(z.literal("")),
name: ZUserName,
})
);
export type TInviteMembersFormSchema = z.infer<typeof ZInviteMembersFormSchema>;