mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
fix: wrong invite message (#4470)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
committed by
GitHub
parent
4676b4cd25
commit
1c58ac3704
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user