fix: refresh invite expiration when sharing link (#7198)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Johannes
2026-02-04 05:28:25 -08:00
committed by GitHub
parent 47fe3c73dd
commit 1143f58ba5
20 changed files with 210 additions and 88 deletions

View File

@@ -929,7 +929,7 @@ checksums:
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
environments/settings/general/invited_on: 83476ce4bcdfc3ccf524d1cd91b758a8
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
environments/settings/general/invites_failed: 180ffb8db417050227cc2b2ea74b7aae
environments/settings/general/leave_organization: e74132cb4a0dc98c41e61ea3b2dd268b
environments/settings/general/leave_organization_description: 2d0cd65e4e78a9b2835cf88c4de407fb

View File

@@ -990,7 +990,7 @@
"from_your_organization": "von deiner Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invited_on": "Eingeladen am {date}",
"invite_expires_on": "Einladung läuft ab 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.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "from your organization",
"invitation_sent_once_more": "Invitation sent once more.",
"invite_deleted_successfully": "Invite deleted successfully",
"invited_on": "Invited on {date}",
"invite_expires_on": "Invite expires 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.",
@@ -1268,14 +1268,13 @@
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"display_type": "Display type",
"dropdown": "Dropdown",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
"display_number_of_responses_for_survey": "Display number of responses for survey",
"display_type": "Display type",
"divide": "Divide /",
"does_not_contain": "Does not contain",
"does_not_end_with": "Does not end with",
@@ -1283,6 +1282,7 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"dropdown": "Dropdown",
"duplicate_block": "Duplicate block",
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
@@ -1415,11 +1415,11 @@
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
"limit_upload_file_size_to": "Limit upload file size to",
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
"list": "List",
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"logo_settings": "Logo settings",
"list": "List",
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
@@ -3095,4 +3095,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}
}

View File

@@ -990,7 +990,7 @@
"from_your_organization": "de tu organización",
"invitation_sent_once_more": "Invitación enviada una vez más.",
"invite_deleted_successfully": "Invitación eliminada correctamente",
"invited_on": "Invitado el {date}",
"invite_expires_on": "La invitación expira el {date}",
"invites_failed": "Las invitaciones fallaron",
"leave_organization": "Abandonar organización",
"leave_organization_description": "Abandonarás esta organización y perderás acceso a todas las encuestas y respuestas. Solo podrás volver a unirte si te invitan de nuevo.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "de votre organisation",
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
"invite_deleted_successfully": "Invitation supprimée avec succès",
"invited_on": "Invité le {date}",
"invite_expires_on": "L'invitation expire 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é.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "a szervezetétől",
"invitation_sent_once_more": "A meghívó még egyszer elküldve.",
"invite_deleted_successfully": "A meghívó sikeresen törölve",
"invited_on": "Meghívva ekkor: {date}",
"invite_expires_on": "A meghívó lejár: {date}",
"invites_failed": "A meghívás sikertelen",
"leave_organization": "Szervezet elhagyása",
"leave_organization_description": "Elhagyja ezt a szervezetet, és elveszíti az összes kérdőívhez és válaszhoz való hozzáférését. Csak akkor tud ismét csatlakozni, ha újra meghívják.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "あなたの組織から",
"invitation_sent_once_more": "招待状を再度送信しました。",
"invite_deleted_successfully": "招待を正常に削除しました",
"invited_on": "{date}に招待",
"invite_expires_on": "招待は{date}に期限切れ",
"invites_failed": "招待に失敗しました",
"leave_organization": "組織を離れる",
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "vanuit uw organisatie",
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
"invited_on": "Uitgenodigd op {date}",
"invite_expires_on": "Uitnodiging verloopt op {date}",
"invites_failed": "Uitnodigingen zijn mislukt",
"leave_organization": "Verlaat de organisatie",
"leave_organization_description": "U verlaat deze organisatie en verliest de toegang tot alle enquêtes en reacties. Je kunt alleen weer meedoen als je opnieuw wordt uitgenodigd.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
"invited_on": "Convidado em {date}",
"invite_expires_on": "O convite expira 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.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado mais uma vez.",
"invite_deleted_successfully": "Convite eliminado com sucesso",
"invited_on": "Convidado em {date}",
"invite_expires_on": "O convite expira em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Vai sair desta organização e perder o acesso a todos os inquéritos e respostas. Só pode voltar a juntar-se se for convidado novamente.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "din organizația ta",
"invitation_sent_once_more": "Invitație trimisă din nou.",
"invite_deleted_successfully": "Invitație ștearsă cu succes",
"invited_on": "Invitat pe {date}",
"invite_expires_on": "Invitația expiră pe {date}",
"invites_failed": "Invitații eșuate",
"leave_organization": "Părăsește organizația",
"leave_organization_description": "Vei părăsi această organizație și vei pierde accesul la toate sondajele și răspunsurile. Poți să te alături din nou doar dacă ești invitat.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "из вашей организации",
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
"invite_deleted_successfully": "Приглашение успешно удалено",
"invited_on": "Приглашён {date}",
"invite_expires_on": "Приглашение истекает {date}",
"invites_failed": "Не удалось отправить приглашения",
"leave_organization": "Покинуть организацию",
"leave_organization_description": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "från din organisation",
"invitation_sent_once_more": "Inbjudan skickad igen.",
"invite_deleted_successfully": "Inbjudan borttagen",
"invited_on": "Inbjuden den {date}",
"invite_expires_on": "Inbjudan går ut den {date}",
"invites_failed": "Inbjudningar misslyckades",
"leave_organization": "Lämna organisation",
"leave_organization_description": "Du kommer att lämna denna organisation och förlora åtkomst till alla enkäter och svar. Du kan endast återansluta om du blir inbjuden igen.",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "来自你的组织",
"invitation_sent_once_more": "再次发送邀请。",
"invite_deleted_successfully": "邀请 删除 成功",
"invited_on": "邀于 {date}",
"invite_expires_on": "邀请将于 {date} 过期",
"invites_failed": "邀请失败",
"leave_organization": "离开 组织",
"leave_organization_description": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",

View File

@@ -990,7 +990,7 @@
"from_your_organization": "來自您的組織",
"invitation_sent_once_more": "已再次發送邀請。",
"invite_deleted_successfully": "邀請已成功刪除",
"invited_on": "邀請於 '{'date'}'",
"invite_expires_on": "邀請於 '{'date'}' 過期",
"invites_failed": "邀請失敗",
"leave_organization": "離開組織",
"leave_organization_description": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",

View File

@@ -2,6 +2,7 @@
import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
@@ -23,7 +24,7 @@ import {
getMembershipsByUserId,
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInvite } from "./lib/invite";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -57,30 +58,57 @@ 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"],
},
],
});
export const createInviteTokenAction = authenticatedActionClient.schema(ZCreateInviteTokenAction).action(
withAuditLogging(
"updated",
"invite",
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateInviteTokenAction>;
}) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
const invite = await getInvite(parsedInput.inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
// Get old expiresAt for audit logging before update
const oldInvite = await prisma.invite.findUnique({
where: { id: parsedInput.inviteId },
select: { email: true, expiresAt: true },
});
if (!oldInvite) {
throw new ValidationError("Invite not found");
}
// Refresh the invitation expiration
const updatedInvite = await refreshInviteExpiration(parsedInput.inviteId);
// Set audit context
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { expiresAt: oldInvite.expiresAt };
ctx.auditLoggingCtx.newObject = { expiresAt: updatedInvite.expiresAt };
const inviteToken = createInviteToken(parsedInput.inviteId, updatedInvite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
}
const inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
});
)
);
const ZDeleteMembershipAction = z.object({
userId: ZId,
@@ -191,6 +219,7 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
invite?.creator?.name ?? "",
updatedInvite.name ?? ""
);
return updatedInvite;
}
)

View File

@@ -80,6 +80,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (createInviteTokenResponse?.data) {
setShareInviteToken(createInviteTokenResponse.data.inviteToken);
setShowShareInviteModal(true);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(createInviteTokenResponse);
toast.error(errorMessage);
@@ -99,6 +100,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
});
if (resendInviteResponse?.data) {
toast.success(t("environments.settings.general.invitation_sent_once_more"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(resendInviteResponse);
toast.error(errorMessage);

View File

@@ -47,8 +47,8 @@ export const MembersInfo = ({
<Badge type="gray" text="Expired" size="tiny" data-testid="expired-badge" />
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invited_on", {
date: getFormattedDateTimeString(member.createdAt),
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
date: getFormattedDateTimeString(member.expiresAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>

View File

@@ -9,7 +9,14 @@ import {
} from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { TInvitee } from "../types/invites";
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
import {
deleteInvite,
getInvite,
getInvitesByOrganizationId,
inviteUser,
refreshInviteExpiration,
resendInvite,
} from "./invite";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -46,32 +53,129 @@ const mockInvite: Invite = {
teamIds: [],
};
describe("resendInvite", () => {
describe("refreshInviteExpiration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns email and name if invite exists", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue({ ...mockInvite, creator: {} });
vi.mocked(prisma.invite.update).mockResolvedValue({ ...mockInvite, organizationId: "org-1" });
const result = await resendInvite("invite-1");
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
test("updates expiresAt to approximately 7 days from now", async () => {
const now = Date.now();
const expectedExpiresAt = new Date(now + 1000 * 60 * 60 * 24 * 7);
vi.mocked(prisma.invite.update).mockResolvedValue({
...mockInvite,
expiresAt: expectedExpiresAt,
});
const result = await refreshInviteExpiration("invite-1");
expect(prisma.invite.update).toHaveBeenCalledWith({
where: { id: "invite-1" },
data: {
expiresAt: expect.any(Date),
},
});
expect(result.expiresAt.getTime()).toBeGreaterThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 - 1000);
expect(result.expiresAt.getTime()).toBeLessThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 + 1000);
});
test("throws ResourceNotFoundError if invite not found", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
test("throws ResourceNotFoundError if invite not found (P2025)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on prisma error", async () => {
test("throws DatabaseError on other prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if non-prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.update).mockRejectedValue(error);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow("db");
});
test("returns full invite object with all fields", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
const result = await refreshInviteExpiration("invite-1");
expect(result).toEqual(updatedInvite);
expect(result.id).toBe("invite-1");
expect(result.email).toBe("test@example.com");
expect(result.name).toBe("Test User");
});
});
describe("resendInvite", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns email and name after updating expiration", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
const result = await resendInvite("invite-1");
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
expect(prisma.invite.update).toHaveBeenCalledWith({
where: { id: "invite-1" },
data: {
expiresAt: expect.any(Date),
},
});
});
test("calls refreshInviteExpiration helper", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
await resendInvite("invite-1");
expect(prisma.invite.update).toHaveBeenCalledTimes(1);
});
test("throws ResourceNotFoundError if invite not found", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on other prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if prisma error", async () => {
test("throws error if non-prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
vi.mocked(prisma.invite.update).mockRejectedValue(error);
await expect(resendInvite("invite-1")).rejects.toThrow("db");
});
});

View File

@@ -13,44 +13,21 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
export const refreshInviteExpiration = async (inviteId: string): Promise<Invite> => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
name: true,
creator: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
const updatedInvite = await prisma.invite.update({
where: {
id: inviteId,
},
where: { id: inviteId },
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
},
select: {
id: true,
email: true,
name: true,
organizationId: true,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
},
});
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
return updatedInvite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
throw new ResourceNotFoundError("Invite", inviteId);
}
throw new DatabaseError(error.message);
}
@@ -58,6 +35,16 @@ export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "emai
}
};
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
// Refresh expiration and return the updated invite (single query)
const updatedInvite = await refreshInviteExpiration(inviteId);
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
};
export const getInvitesByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TInvite[]> => {
validateInputs([organizationId, z.string()], [page, z.number().optional()]);