From 0ecca6a312b15b3d4f028c1e35e5c3f54e74382b Mon Sep 17 00:00:00 2001 From: = Date: Mon, 20 Oct 2025 23:01:59 +0530 Subject: [PATCH] feat: added change in user invitation mail for suborg and updated list operation for sub org --- .../ee/services/sub-org/sub-org-service.ts | 4 +- .../org/org-membership-user-factory.ts | 73 +++++++++++-------- backend/src/services/org/org-service.ts | 10 +-- .../SubOrganizationInvitationTemplate.tsx | 50 +++++++++++++ backend/src/services/smtp/emails/index.ts | 1 + backend/src/services/smtp/smtp-service.ts | 5 +- .../OrganizationContext.tsx | 24 +++--- .../components/NavBar/Navbar.tsx | 4 +- .../OrgNameChangeSection.tsx | 28 ++++--- .../OrgProductSelectSection.tsx | 2 +- 10 files changed, 136 insertions(+), 65 deletions(-) create mode 100644 backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx diff --git a/backend/src/ee/services/sub-org/sub-org-service.ts b/backend/src/ee/services/sub-org/sub-org-service.ts index db5b89244..fca60bb9b 100644 --- a/backend/src/ee/services/sub-org/sub-org-service.ts +++ b/backend/src/ee/services/sub-org/sub-org-service.ts @@ -93,10 +93,10 @@ export const subOrgServiceFactory = ({ await permissionService.getOrgPermission({ actorId: permissionActor.id, actor: permissionActor.type, - orgId: permissionActor.parentOrgId, + orgId: permissionActor.rootOrgId, actorOrgId: permissionActor.rootOrgId, actorAuthMethod: permissionActor.authMethod, - scope: OrganizationActionScope.ParentOrganization + scope: OrganizationActionScope.Any }); const organizations = await orgDAL.listSubOrganizations({ diff --git a/backend/src/services/membership-user/org/org-membership-user-factory.ts b/backend/src/services/membership-user/org/org-membership-user-factory.ts index 7aff05220..2da263426 100644 --- a/backend/src/services/membership-user/org/org-membership-user-factory.ts +++ b/backend/src/services/membership-user/org/org-membership-user-factory.ts @@ -84,6 +84,7 @@ export const newOrgMembershipUserFactory = ({ message: "Failed to invite user due to org-level auth enforced for organization" }); } + if (org.rootOrgId) { const rootOrgMembership = await membershipUserDAL.find({ scope: AccessScope.Organization, @@ -120,40 +121,52 @@ export const newOrgMembershipUserFactory = ({ const signUpTokens: { email: string; link: string }[] = []; const orgDetails = await orgDAL.findById(dto.permission.orgId); + if (orgDetails.rootOrgId) { + const emails = newUsers.map((el) => el.email).filter(Boolean); + await smtpService.sendMail({ + template: SmtpTemplates.SubOrgInvite, + subjectLine: "Infisical sub-organization invitation", + recipients: emails as string[], + substitutions: { + subOrganizationName: orgDetails.slug, + callback_url: `${appCfg.SITE_URL}/organization/projects?${orgDetails.slug}` + } + }); + } else { + await Promise.allSettled( + newUsers.map(async (el) => { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: el.id, + orgId: dto.permission.orgId + }); - await Promise.allSettled( - newUsers.map(async (el) => { - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: el.id, - orgId: dto.permission.orgId - }); + if (el.email) { + if (!appCfg.isSmtpConfigured) { + signUpTokens.push({ + email: el.email, + link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${dto.permission.orgId}` + }); + } - if (el.email) { - if (!appCfg.isSmtpConfigured) { - signUpTokens.push({ - email: el.email, - link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${dto.permission.orgId}` + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [el.email], + substitutions: { + inviterFirstName: actorDetails?.firstName, + inviterUsername: actorDetails?.email, + organizationName: orgDetails?.name, + email: el.email, + organizationId: orgDetails?.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } }); } - - await smtpService.sendMail({ - template: SmtpTemplates.OrgInvite, - subjectLine: "Infisical organization invitation", - recipients: [el.email], - substitutions: { - inviterFirstName: actorDetails?.firstName, - inviterUsername: actorDetails?.email, - organizationName: orgDetails?.name, - email: el.email, - organizationId: orgDetails?.id.toString(), - token, - callback_url: `${appCfg.SITE_URL}/signupinvite` - } - }); - } - }) - ); + }) + ); + } return { signUpTokens }; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 76c1fb801..6334322eb 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -346,7 +346,7 @@ export const orgServiceFactory = ({ orgId, actorAuthMethod, actorOrgId, - scope: OrganizationActionScope.Any + scope: OrganizationActionScope.ParentOrganization }); if (!hasRole(OrgMembershipRole.Admin)) { @@ -418,7 +418,7 @@ export const orgServiceFactory = ({ orgId, actorAuthMethod, actorOrgId, - scope: OrganizationActionScope.Any + scope: OrganizationActionScope.ParentOrganization }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -878,7 +878,7 @@ export const orgServiceFactory = ({ orgId, actorAuthMethod, actorOrgId, - scope: OrganizationActionScope.Any + scope: OrganizationActionScope.ParentOrganization }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -1172,7 +1172,7 @@ export const orgServiceFactory = ({ orgId, actorAuthMethod, actorOrgId, - scope: OrganizationActionScope.Any + scope: OrganizationActionScope.ParentOrganization }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email }); @@ -1200,7 +1200,7 @@ export const orgServiceFactory = ({ orgId, actorAuthMethod, actorOrgId, - scope: OrganizationActionScope.Any + scope: OrganizationActionScope.ParentOrganization }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); diff --git a/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx b/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx new file mode 100644 index 000000000..e0b347dae --- /dev/null +++ b/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx @@ -0,0 +1,50 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SubOrganizationInvitationTemplateProps extends Omit { + callback_url: string; + subOrganizationName: string; +} + +export const SubOrganizationInvitationTemplate = ({ + callback_url, + subOrganizationName, + siteUrl +}: SubOrganizationInvitationTemplateProps) => { + return ( + + + You've been invited to join a suborganization on Infisical + +
+ + You've been invited to join the suborganization {subOrganizationName}. + +
+
+ Join Suborganization +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default SubOrganizationInvitationTemplate; + +SubOrganizationInvitationTemplate.PreviewProps = { + subOrganizationName: "Example Project", + siteUrl: "https://infisical.com", + callback_url: "https://app.infisical.com" +} as SubOrganizationInvitationTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 06ac31ab6..78e415832 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -32,3 +32,4 @@ export * from "./SecretSyncFailedTemplate"; export * from "./ServiceTokenExpiryNoticeTemplate"; export * from "./SignupEmailVerificationTemplate"; export * from "./UnlockAccountTemplate"; +export * from "./SubOrganizationInvitationTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 652f56567..e5f83f66c 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -40,7 +40,8 @@ import { SecretSyncFailedTemplate, ServiceTokenExpiryNoticeTemplate, SignupEmailVerificationTemplate, - UnlockAccountTemplate + UnlockAccountTemplate, + SubOrganizationInvitationTemplate } from "./emails"; export type TSmtpConfig = SMTPTransport.Options; @@ -65,6 +66,7 @@ export enum SmtpTemplates { // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? NewDeviceJoin = "newDevice", OrgInvite = "organizationInvitation", + SubOrgInvite = "subOrganizationInvitation", OrgAssignment = "organizationAssignment", OAuthPasswordReset = "oAuthPasswordReset", ResetPassword = "passwordReset", @@ -102,6 +104,7 @@ export enum SmtpHost { // eslint-disable-next-line @typescript-eslint/no-explicit-any const EmailTemplateMap: Record> = { [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, + [SmtpTemplates.SubOrgInvite]: SubOrganizationInvitationTemplate, [SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate, [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, diff --git a/frontend/src/context/OrganizationContext/OrganizationContext.tsx b/frontend/src/context/OrganizationContext/OrganizationContext.tsx index 79c004e1f..b657a827d 100644 --- a/frontend/src/context/OrganizationContext/OrganizationContext.tsx +++ b/frontend/src/context/OrganizationContext/OrganizationContext.tsx @@ -2,6 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useRouteContext, useSearch } from "@tanstack/react-router"; import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries"; +import { useMemo } from "react"; export const useOrganization = () => { const organizationId = useRouteContext({ @@ -20,13 +21,18 @@ export const useOrganization = () => { staleTime: Infinity }); - return { - currentOrg: { - ...currentOrg, - id: currentOrg?.subOrganization?.id || currentOrg?.id, - parentOrgId: currentOrg.id - }, - isSubOrganization: Boolean(currentOrg.subOrganization), - isRootOrganization: !currentOrg.subOrganization - }; + const org = useMemo( + () => ({ + currentOrg: { + ...currentOrg, + id: currentOrg?.subOrganization?.id || currentOrg?.id, + parentOrgId: currentOrg.id + }, + isSubOrganization: Boolean(currentOrg.subOrganization), + isRootOrganization: !currentOrg.subOrganization + }), + [currentOrg] + ); + + return org; }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 09bf59794..0bc4439ae 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -131,14 +131,14 @@ export const INFISICAL_SUPPORT_OPTIONS = [ export const Navbar = () => { const { user } = useUser(); const { subscription } = useSubscription(); - const { currentOrg, isSubOrganization } = useOrganization(); + const { currentOrg } = useOrganization(); const [showAdminsModal, setShowAdminsModal] = useState(false); const [showSubOrgForm, setShowSubOrgForm] = useState(false); const [showCardDeclinedModal, setShowCardDeclinedModal] = useState(false); const { data: subOrganizations = [] } = useQuery({ ...subOrganizationsQuery.list({ limit: 500 }), - enabled: Boolean(subscription.subOrganization) && !isSubOrganization + enabled: Boolean(subscription.subOrganization) }); useEffect(() => { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index 9b6323c2e..766a22158 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -41,21 +41,19 @@ export const OrgNameChangeSection = (): JSX.Element => { const [isFormInitialized, setIsFormInitialized] = useState(false); useEffect(() => { - if (currentOrg) { - reset({ - name: currentOrg.name, - slug: currentOrg.slug, - ...(canReadOrgRoles && - roles?.length && { - // will always be present, can't remove role if default - defaultMembershipRole: isCustomOrgRole(currentOrg.defaultMembershipRole) - ? roles?.find((role) => currentOrg.defaultMembershipRole === role.id)?.slug || "" - : currentOrg.defaultMembershipRole - }) - }); - setIsFormInitialized(true); - } - }, [currentOrg, roles]); + reset({ + name: currentOrg.name, + slug: currentOrg.slug, + ...(canReadOrgRoles && + roles?.length && { + // will always be present, can't remove role if default + defaultMembershipRole: isCustomOrgRole(currentOrg.defaultMembershipRole) + ? roles?.find((role) => currentOrg.defaultMembershipRole === role.id)?.slug || "" + : currentOrg.defaultMembershipRole + }) + }); + setIsFormInitialized(true); + }, [roles]); const onFormSubmit = async ({ name, slug, defaultMembershipRole }: FormData) => { try { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx index 61ee0c6e9..5be15edad 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx @@ -50,7 +50,7 @@ export const OrgProductSelectSection = () => { })); } }); - }, [currentOrg]); + }, [currentOrg?.id]); const onProductToggle = async (value: boolean, key: string) => { setIsLoading(true);