From d48bb910fa6843e13b9839f602ca67105ba9c542 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 22 Aug 2024 08:11:18 +0400 Subject: [PATCH 01/29] JWT invite lifetime (1 day) --- backend/src/lib/config/env.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8a2b961e9..dea905aa2 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -74,6 +74,7 @@ const envSchema = z JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")), JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")), + JWT_INVITE_LIFETIME: zpStr(z.string().default("1d")), JWT_MFA_LIFETIME: zpStr(z.string().default("5m")), JWT_PROVIDER_AUTH_LIFETIME: zpStr(z.string().default("15m")), // Oauth From e231c531a60bbdd0b6c41e86be73ccb996659173 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 22 Aug 2024 08:11:29 +0400 Subject: [PATCH 02/29] Update index.ts --- backend/src/server/routes/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2454e92cb..d1ce11db1 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -477,9 +477,12 @@ export const registerRoutes = async ( orgRoleDAL, permissionService, orgDAL, + userGroupMembershipDAL, + projectBotDAL, incidentContactDAL, tokenService, projectUserAdditionalPrivilegeDAL, + projectUserMembershipRoleDAL, projectDAL, projectMembershipDAL, orgMembershipDAL, @@ -499,6 +502,8 @@ export const registerRoutes = async ( projectDAL, projectBotDAL, groupProjectDAL, + projectMembershipDAL, + projectUserMembershipRoleDAL, orgDAL, orgService, licenseService From 60df59c7f090fbb244b9fb572b913a4d5ba2155e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 22 Aug 2024 08:11:48 +0400 Subject: [PATCH 03/29] Multi-user organization invites structure --- .../src/server/routes/v1/invite-org-router.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 873710f10..be9fdbf74 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -16,23 +16,35 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { method: "POST", schema: { body: z.object({ - inviteeEmail: z.string().trim().email(), - organizationId: z.string().trim() + inviteeEmails: z.array(z.string().trim().email()), + organizationId: z.string().trim(), + projectIds: z.array(z.string().trim()).optional(), + organizationRoleSlug: z.string().trim().optional() }), response: { 200: z.object({ message: z.string(), - completeInviteLink: z.string().optional() + completeInviteLinks: z + .array( + z.object({ + email: z.string(), + link: z.string() + }) + ) + .optional() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const completeInviteLink = await server.services.org.inviteUserToOrganization({ + + const completeInviteLinks = await server.services.org.inviteUserToOrganization({ orgId: req.body.organizationId, userId: req.permission.id, - inviteeEmail: req.body.inviteeEmail, + inviteeEmails: req.body.inviteeEmails, + projectIds: req.body.projectIds, + organizationRoleSlug: req.body.organizationRoleSlug, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId }); @@ -41,14 +53,15 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { event: PostHogEventTypes.UserOrgInvitation, distinctId: getTelemetryDistinctId(req), properties: { - inviteeEmail: req.body.inviteeEmail, + inviteeEmails: req.body.inviteeEmails, + organizationRoleSlug: req.body.organizationRoleSlug, ...req.auditLogInfo } }); return { - completeInviteLink, - message: `Send an invite link to ${req.body.inviteeEmail}` + completeInviteLinks, + message: `Send an invite link to ${req.body.inviteeEmails.join(", ")}` }; } }); From 8ca70eec44d5e928e81760e3eee03e9a8996c3bd Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 22 Aug 2024 08:12:23 +0400 Subject: [PATCH 04/29] Refactor add users to org handlers --- .../OrgMembersSection/OrgMembersTable.tsx | 16 ++++++++-------- .../UserPage/components/UserDetailsSection.tsx | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 6c0c7566a..e5a67548b 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -33,7 +33,7 @@ import { useUser } from "@app/context"; import { - useAddUserToOrg, + useAddUsersToOrg, useFetchServerStatus, useGetOrgRoles, useGetOrgUsers, @@ -50,10 +50,10 @@ type Props = { description?: string; } ) => void; - setCompleteInviteLink: (link: string) => void; + setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void; }; -export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => { +export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => { const router = useRouter(); const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); @@ -68,7 +68,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop const { data: serverDetails } = useFetchServerStatus(); const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); - const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); + const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const onRoleChange = async (membershipId: string, role: string) => { @@ -106,14 +106,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop const onResendInvite = async (email: string) => { try { - const { data } = await addUserMutateAsync({ + const { data } = await addUsersMutateAsync({ organizationId: orgId, - inviteeEmail: email + inviteeEmails: [email] }); - setCompleteInviteLink(data?.completeInviteLink || ""); + setCompleteInviteLinks(data?.completeInviteLinks || null); - if (!data.completeInviteLink) { + if (!data.completeInviteLinks) { createNotification({ text: `Successfully resent invite to ${email}`, type: "success" diff --git a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx index 4311a48bb..cd886728c 100644 --- a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx @@ -18,7 +18,7 @@ import { } from "@app/context"; import { useTimedReset } from "@app/hooks"; import { - useAddUserToOrg, + useAddUsersToOrg, useFetchServerStatus, useGetOrgMembership, useGetOrgRoles @@ -44,18 +44,18 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => const { data: roles } = useGetOrgRoles(orgId); const { data: serverDetails } = useFetchServerStatus(); const { data: membership } = useGetOrgMembership(orgId, membershipId); - const { mutateAsync: inviteUser, isLoading } = useAddUserToOrg(); + const { mutateAsync: inviteUsers, isLoading } = useAddUsersToOrg(); const onResendInvite = async (email: string) => { try { - const { data } = await inviteUser({ + const { data } = await inviteUsers({ organizationId: orgId, - inviteeEmail: email + inviteeEmails: [email] }); // setCompleteInviteLink(data?.completeInviteLink || ""); - if (!data.completeInviteLink) { + if (!data.completeInviteLinks) { createNotification({ text: `Successfully resent invite to ${email}`, type: "success" From 8dfcef39004ea8037886db81898f417685a3caa9 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 22 Aug 2024 08:12:40 +0400 Subject: [PATCH 05/29] Seperate component for Org Invite Links --- .../OrgMembersSection/OrgInviteLink.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgInviteLink.tsx diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgInviteLink.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgInviteLink.tsx new file mode 100644 index 000000000..1c0980e18 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgInviteLink.tsx @@ -0,0 +1,49 @@ +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +type Props = { + invite: { email: string; link: string }; +}; + +export const OrgInviteLink = ({ invite }: Props) => { + const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); + + const copyTokenToClipboard = () => { + if (isInviteLinkCopied) return; + + navigator.clipboard.writeText(invite.link); + setInviteLinkCopied.timedToggle(); + + createNotification({ + type: "info", + text: "Copied invitation link to clipboard" + }); + }; + + return ( +
+

+ Invite for {invite.email} +

+
+

+ {invite.link} +

+ + copyTokenToClipboard()} + > + + + +
+
+ ); +}; From 2ab59326931dd3efbf956b9156101035cd5fd150 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 22 Aug 2024 08:12:50 +0400 Subject: [PATCH 06/29] Update OrgMembersSection.tsx --- .../OrgMembersSection/OrgMembersSection.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index c272145d5..a7d73345f 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -27,7 +27,10 @@ export const OrgMembersSection = () => { const { currentOrg } = useOrganization(); const orgId = currentOrg?.id ?? ""; - const [completeInviteLink, setCompleteInviteLink] = useState(""); + const [completeInviteLinks, setCompleteInviteLinks] = useState | null>(null); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addMember", @@ -132,13 +135,13 @@ export const OrgMembersSection = () => { Date: Thu, 22 Aug 2024 08:12:59 +0400 Subject: [PATCH 07/29] Update AddOrgMemberModal.tsx --- .../OrgMembersSection/AddOrgMemberModal.tsx | 252 ++++++++++++++---- 1 file changed, 198 insertions(+), 54 deletions(-) diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index aedeb369a..f0dfe5b77 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -1,65 +1,112 @@ import { Controller, useForm } from "react-hook-form"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, IconButton, Input, Modal, ModalContent } from "@app/components/v2"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Modal, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { useToggle } from "@app/hooks"; -import { useAddUserToOrg, useFetchServerStatus } from "@app/hooks/api"; +import { + useAddUsersToOrg, + useFetchServerStatus, + useGetOrgRoles, + useGetUserWorkspaces +} from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const addMemberFormSchema = yup.object({ - email: yup.string().email().required().label("Email").trim().lowercase() +import { OrgInviteLink } from "./OrgInviteLink"; + +const DEFAULT_ORG_MEMBER_ROLE_SLUG = "member"; + +const EmailSchema = z.string().email().min(1).trim().toLowerCase(); + +const addMemberFormSchema = z.object({ + emails: z.string().min(1).trim().toLowerCase(), + projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]), + organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_MEMBER_ROLE_SLUG) }); -type TAddMemberForm = yup.InferType; +type TAddMemberForm = z.infer; type Props = { popUp: UsePopUpState<["addMember"]>; handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void; - completeInviteLink: string; - setCompleteInviteLink: (link: string) => void; + completeInviteLinks: Array<{ + email: string; + link: string; + }> | null; + setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void; }; export const AddOrgMemberModal = ({ popUp, handlePopUpToggle, - completeInviteLink, - setCompleteInviteLink + completeInviteLinks, + setCompleteInviteLinks }: Props) => { - const { currentOrg } = useOrganization(); + const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? ""); const { data: serverDetails } = useFetchServerStatus(); - const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); - - const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); + const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); const { control, handleSubmit, + watch, reset, formState: { isSubmitting } - } = useForm({ resolver: yupResolver(addMemberFormSchema) }); + } = useForm({ resolver: zodResolver(addMemberFormSchema) }); - const onAddMember = async ({ email }: TAddMemberForm) => { + const onAddMembers = async ({ emails, organizationRoleSlug, projectIds }: TAddMemberForm) => { if (!currentOrg?.id) return; try { - const { data } = await addUserMutateAsync({ + const parsedEmails = emails + .replace(/\s/g, "") + .split(",") + .map((email) => { + if (EmailSchema.safeParse(email).success) { + return email.trim(); + } + + return null; + }); + + if (parsedEmails.includes(null)) { + createNotification({ + text: "Invalid email addresses provided.", + type: "error" + }); + return; + } + + const { data } = await addUsersMutateAsync({ organizationId: currentOrg?.id, - inviteeEmail: email + inviteeEmails: emails.split(",").map((email) => email.trim()), + organizationRoleSlug, + projectIds }); - setCompleteInviteLink(data?.completeInviteLink ?? ""); + setCompleteInviteLinks(data?.completeInviteLinks ?? null); // only show this notification when email is configured. // A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLink) { + if (!data.completeInviteLinks) { createNotification({ text: "Successfully invited user to the organization.", type: "success" @@ -80,47 +127,153 @@ export const AddOrgMemberModal = ({ reset(); }; - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(completeInviteLink as string); - setInviteLinkCopied.on(); - }; + const projectIds = watch("projectIds", []); + const { data: projects } = useGetUserWorkspaces(); return ( { handlePopUpToggle("addMember", isOpen); - setCompleteInviteLink(""); + setCompleteInviteLinks(null); }} > - {!completeInviteLink && ( -
- An invite is specific to an email address and expires after 1 day. -
- For security reasons, you will need to separately add members to projects. -
+ {!completeInviteLinks && ( +
An invite is specific to an email address and expires after 1 day.
)} - {completeInviteLink && + {completeInviteLinks && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} } > - {!completeInviteLink && ( -
+ {!completeInviteLinks && ( + ( - +