diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 898040458..66556f5fa 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -657,7 +657,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { tx?: Knex ) => { try { - const query = (tx || db.replicaNode())(TableName.Identity) + const query = (tx || db.replicaNode())(TableName.Membership) .where(`${TableName.Membership}.scope`, AccessScope.Organization) .whereNotNull(`${TableName.Membership}.actorIdentityId`) .where(filter) diff --git a/backend/src/services/membership-user/membership-user-dal.ts b/backend/src/services/membership-user/membership-user-dal.ts index 221228465..17970f16f 100644 --- a/backend/src/services/membership-user/membership-user-dal.ts +++ b/backend/src/services/membership-user/membership-user-dal.ts @@ -308,7 +308,7 @@ export const membershipUserDALFactory = (db: TDbClient) => { .where(`${TableName.Users}.isGhost`, false) .whereNotNull(`${TableName.Membership}.actorUserId`) .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) - .whereNot(`${TableName.Membership}.actorUserId`, usersConnectedToOrg) + .whereNotIn(`${TableName.Membership}.actorUserId`, usersConnectedToOrg) .select( db.ref("id").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx index 368b8e64b..869ad00b1 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx @@ -73,7 +73,7 @@ export const NewSubOrganizationForm = ({ onClose }: ContentProps) => { errorText={error?.message} label="Name" > - + )} control={control} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddSubOrgMemberModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddSubOrgMemberModal.tsx new file mode 100644 index 000000000..290f8db4b --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddSubOrgMemberModal.tsx @@ -0,0 +1,280 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { RoleOption } from "@app/components/roles"; +import { Button, FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { findOrgMembershipRole } from "@app/helpers/roles"; +import { + useAddUsersToOrg, + useAddUserToWsNonE2EE, + useGetOrgRoles, + useGetUserProjects +} from "@app/hooks/api"; +import { useGetAvailableOrgUsers } from "@app/hooks/api/organization/queries"; +import { ProjectType, ProjectVersion } from "@app/hooks/api/projects/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; + +const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member"; + +const addMemberFormSchema = z.object({ + users: z + .array( + z.object({ + username: z.string().trim(), + email: z.string().trim() + }) + ) + .min(1), + projects: z + .array( + z.object({ + name: z.string(), + id: z.string(), + slug: z.string(), + version: z.nativeEnum(ProjectVersion) + }) + ) + .default([]), + projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), + organizationRole: z.object({ + name: z.string(), + slug: z.string(), + description: z.string().optional() + }) +}); + +type TAddMemberForm = z.infer; + +type Props = { + onClose: () => void; +}; + +export const AddSubOrgMemberModal = ({ onClose }: Props) => { + const { currentOrg } = useOrganization(); + + const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? ""); + const { data: members = [], isPending: isMembersPending } = useGetAvailableOrgUsers(); + + const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); + const { mutateAsync: addUserToProject } = useAddUserToWsNonE2EE(); + + const { data: projects, isPending: isProjectsLoading } = useGetUserProjects({ + includeRoles: true + }); + + const { + control, + handleSubmit, + watch, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(addMemberFormSchema) + }); + + // set initial form role based off org default role + useEffect(() => { + if (organizationRoles) { + reset({ + organizationRole: findOrgMembershipRole(organizationRoles, currentOrg.defaultMembershipRole) + }); + } + }, [organizationRoles]); + + const onAddMembers = async ({ + users, + organizationRole, + projects: selectedProjects, + projectRoleSlug + }: TAddMemberForm) => { + if (!currentOrg?.id) return; + + if (selectedProjects?.length) { + // eslint-disable-next-line no-restricted-syntax + for (const project of selectedProjects) { + if (project.version !== ProjectVersion.V3) { + createNotification({ + type: "error", + text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.` + }); + return; + } + } + } + + try { + const usernames = users.map((el) => el.username); + await addUsersMutateAsync({ + organizationId: currentOrg?.id, + inviteeEmails: usernames, + organizationRoleSlug: organizationRole.slug + }); + + await Promise.allSettled( + selectedProjects.map((el) => + addUserToProject({ + orgId: currentOrg.id, + projectId: el.id, + roleSlugs: [projectRoleSlug], + usernames + }) + ) + ); + onClose(); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to add user to suborganization", + type: "error" + }); + } + }; + + const getGroupHeaderLabel = (type: ProjectType) => { + switch (type) { + case ProjectType.SecretManager: + return "Secrets"; + case ProjectType.CertificateManager: + return "PKI"; + case ProjectType.KMS: + return "KMS"; + case ProjectType.SSH: + return "SSH"; + default: + return "Other"; + } + }; + + return ( + + ( + + option.username} + getOptionLabel={(option) => option.username} + /* eslint-disable-next-line react/no-unstable-nested-components */ + noOptionsMessage={() => ( + All root organization users are already assigned to this project + )} + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + value={value} + onChange={onChange} + components={{ Option: RoleOption }} + /> + + )} + /> + + + + ( + + project.name} + getOptionValue={(project) => project.id} + options={projects} + groupBy="type" + getGroupHeaderLabel={getGroupHeaderLabel} + placeholder="Select projects..." + /> + + )} + /> + + + ( + + + field.onChange(val)} + > + {Object.entries(ProjectMembershipRole).map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ([_, slug]) => + slug !== "custom" && ( + + {slug.replace("-", " ")} + + ) + )} + + + + )} + /> + + + + + + Add Member + + + Cancel + + + + ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index 010a12b38..823d0e0ce 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -11,6 +11,8 @@ import { Button, DeleteActionModal, EmailServiceSetupModal, + Modal, + ModalContent, Tooltip } from "@app/components/v2"; import { @@ -27,10 +29,11 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { AddOrgMemberModal } from "./AddOrgMemberModal"; import { OrgMembersTable } from "./OrgMembersTable"; +import { AddSubOrgMemberModal } from "./AddSubOrgMemberModal"; export const OrgMembersSection = () => { const { subscription } = useSubscription(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); const orgId = currentOrg?.id ?? ""; const { user } = useUser(); const userId = user?.id || ""; @@ -41,6 +44,7 @@ export const OrgMembersSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addMember", + "addMemberToSubOrg", "removeMember", "deactivateMember", "upgradePlan", @@ -210,7 +214,9 @@ export const OrgMembersSection = () => { colorSchema="secondary" type="submit" leftIcon={} - onClick={() => handleAddMemberModal()} + onClick={() => + isSubOrganization ? handlePopUpOpen("addMemberToSubOrg") : handleAddMemberModal() + } isDisabled={!isAllowed} > Add Member @@ -230,6 +236,14 @@ export const OrgMembersSection = () => { completeInviteLinks={completeInviteLinks} setCompleteInviteLinks={setCompleteInviteLinks} /> + handlePopUpToggle("addMemberToSubOrg", isOpen)} + > + + handlePopUpClose("addMemberToSubOrg")} /> + + { const navigate = useNavigate(); const { subscription } = useSubscription(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); const { user } = useUser(); const userId = user?.id || ""; const orgId = currentOrg?.id || ""; @@ -586,6 +586,7 @@ export const OrgMembersTable = ({ {isActive && (status === "invited" || status === "verified") && email && + !isSubOrganization && serverDetails?.emailConfigured && (
All root organization users are already assigned to this project