mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 23:25:26 +00:00
feat: renamed old frontend and fixed some minor bugs
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useUser
|
||||
} from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import {
|
||||
useDeleteOrgMembership,
|
||||
useGetOrgMembership,
|
||||
useUpdateOrgMembership
|
||||
} from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
import { OrgAccessControlTabSections } from "@app/types/org";
|
||||
|
||||
import { UserAuditLogsSection } from "./components/UserProjectsSection/UserAuditLogsSection";
|
||||
import { UserGroupsSection } from "./components/UserProjectsSection/UserGroupsSection";
|
||||
import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components";
|
||||
|
||||
const Page = withPermission(
|
||||
() => {
|
||||
const navigate = useNavigate();
|
||||
const search = useParams({
|
||||
from: ROUTE_PATHS.Organization.UserDetailsByIDPage.id
|
||||
});
|
||||
const membershipId = search.membershipId as string;
|
||||
const { user } = useUser();
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const userId = user?.id || "";
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: membership } = useGetOrgMembership(orgId, membershipId);
|
||||
|
||||
const { mutateAsync: deleteOrgMembership } = useDeleteOrgMembership();
|
||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"removeMember",
|
||||
"orgMembership",
|
||||
"deactivateMember",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onDeactivateMemberSubmit = async (orgMembershipId: string) => {
|
||||
try {
|
||||
await updateOrgMembership({
|
||||
organizationId: orgId,
|
||||
membershipId: orgMembershipId,
|
||||
isActive: false
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deactivated user in organization",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to deactivate user in organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("deactivateMember");
|
||||
};
|
||||
|
||||
const onRemoveMemberSubmit = async (orgMembershipId: string) => {
|
||||
try {
|
||||
await deleteOrgMembership({
|
||||
orgId,
|
||||
membershipId: orgMembershipId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed user from org",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("removeMember");
|
||||
navigate({
|
||||
to: "/organization/access-management" as const,
|
||||
search: {
|
||||
selectedTab: OrgAccessControlTabSections.Member
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to remove user from the organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("removeMember");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
{membership && (
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/organization/access-management" as const,
|
||||
search: {
|
||||
selectedTab: OrgAccessControlTabSections.Member
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
Users
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-3xl font-semibold text-white">
|
||||
{membership.user.firstName || membership.user.lastName
|
||||
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
|
||||
: "-"}
|
||||
</p>
|
||||
{userId !== membership.user.id && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<Tooltip content="More options">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role,
|
||||
roleId: membership.roleId
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Edit User
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={
|
||||
membership.isActive
|
||||
? twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)
|
||||
: ""
|
||||
}
|
||||
onClick={async () => {
|
||||
if (currentOrg?.scimEnabled) {
|
||||
createNotification({
|
||||
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!membership.isActive) {
|
||||
// activate user
|
||||
await updateOrgMembership({
|
||||
organizationId: orgId,
|
||||
membershipId,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// deactivate user
|
||||
handlePopUpOpen("deactivateMember", {
|
||||
orgMembershipId: membershipId,
|
||||
username: membership.user.username
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
{`${membership.isActive ? "Deactivate" : "Activate"} User`}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (currentOrg?.scimEnabled) {
|
||||
createNotification({
|
||||
text: "You cannot manage users from Infisical when SCIM is enabled for your organization",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("removeMember", {
|
||||
orgMembershipId: membershipId,
|
||||
username: membership.user.username
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Remove User
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-4 w-96">
|
||||
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<div className="w-full space-y-4">
|
||||
<UserProjectsSection membershipId={membershipId} />
|
||||
<UserGroupsSection orgMembership={membership} />
|
||||
<UserAuditLogsSection orgMembership={membership} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
title={`Are you sure want to remove member with username ${
|
||||
(popUp?.removeMember?.data as { username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveMemberSubmit(
|
||||
(popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deactivateMember.isOpen}
|
||||
title={`Are you sure want to deactivate member with username ${
|
||||
(popUp?.deactivateMember?.data as { username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deactivateMember", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onDeactivateMemberSubmit(
|
||||
(popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId
|
||||
)
|
||||
}
|
||||
buttonText="Deactivate"
|
||||
/>
|
||||
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
<UserOrgMembershipModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: OrgPermissionActions.Read,
|
||||
subject: OrgPermissionSubjects.Member
|
||||
}
|
||||
);
|
||||
|
||||
export const UserDetailsByIDPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<Page />
|
||||
</>
|
||||
);
|
||||
};
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faCircleXmark,
|
||||
faCopy,
|
||||
faKey,
|
||||
faPencil
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, IconButton, Tag, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useUser
|
||||
} from "@app/context";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useFetchServerStatus,
|
||||
useGetOrgMembership,
|
||||
useGetOrgRoles
|
||||
} from "@app/hooks/api";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
membershipId: string;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["orgMembership"]>, data?: object) => void;
|
||||
};
|
||||
|
||||
export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => {
|
||||
const [copyTextUsername, isCopyingUsername, setCopyTextUsername] = useTimedReset<string>({
|
||||
initialState: "Copy username to clipboard"
|
||||
});
|
||||
|
||||
const { user } = useUser();
|
||||
const { currentOrg } = useOrganization();
|
||||
const userId = user?.id || "";
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const { data: membership } = useGetOrgMembership(orgId, membershipId);
|
||||
const { mutateAsync: inviteUsers, isPending } = useAddUsersToOrg();
|
||||
|
||||
const onResendInvite = async (email: string) => {
|
||||
try {
|
||||
const { data } = await inviteUsers({
|
||||
organizationId: orgId,
|
||||
inviteeEmails: [email],
|
||||
organizationRoleSlug: "member"
|
||||
});
|
||||
|
||||
// setCompleteInviteLink(data?.completeInviteLink || "");
|
||||
|
||||
if (!data.completeInviteLinks) {
|
||||
createNotification({
|
||||
text: `Successfully resent invite to ${email}`,
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to resend invite to ${email}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = (m: OrgUser) => {
|
||||
if (!m.isActive) {
|
||||
return "Deactivated";
|
||||
}
|
||||
|
||||
return m.status === "invited" ? "Invited" : "Active";
|
||||
};
|
||||
|
||||
const roleName = roles?.find((r) => r.slug === membership?.role)?.name;
|
||||
|
||||
return membership ? (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">User Details</h3>
|
||||
{userId !== membership.user.id && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip content="Edit Membership">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role,
|
||||
roleId: membership.roleId,
|
||||
metadata: membership.metadata
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{membership.user.firstName || membership.user.lastName
|
||||
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Username</p>
|
||||
<div className="group flex align-top">
|
||||
<p className="text-sm text-mineshaft-300">{membership.user.username}</p>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content={copyTextUsername}>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText("");
|
||||
setCopyTextUsername("Copied");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingUsername ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Email</p>
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 text-sm text-mineshaft-300">{membership.user.email ?? "-"}</p>
|
||||
<Tooltip
|
||||
content={
|
||||
membership.user.isEmailVerified
|
||||
? "Email has been verified"
|
||||
: "Email has not been verified"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
size="sm"
|
||||
icon={membership.user.isEmailVerified ? faCheckCircle : faCircleXmark}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
|
||||
<p className="text-sm text-mineshaft-300">{roleName ?? "-"}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
|
||||
<p className="text-sm text-mineshaft-300">{getStatus(membership)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Metadata</p>
|
||||
{membership?.metadata?.length ? (
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
|
||||
{membership.metadata?.map((el) => (
|
||||
<div key={el.id} className="flex items-center">
|
||||
<Tag
|
||||
size="xs"
|
||||
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
|
||||
<div>{el.key}</div>
|
||||
</Tag>
|
||||
<Tag
|
||||
size="xs"
|
||||
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
|
||||
>
|
||||
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.value}
|
||||
</div>
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-mineshaft-300">-</p>
|
||||
)}
|
||||
</div>
|
||||
{membership.isActive &&
|
||||
(membership.status === "invited" || membership.status === "verified") &&
|
||||
membership.user.email &&
|
||||
serverDetails?.emailConfigured && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
className="mt-4 w-full"
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
onClick={() => {
|
||||
onResendInvite(membership.user.email as string);
|
||||
}}
|
||||
>
|
||||
Resend Invite
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
);
|
||||
};
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
role: z.object({ name: z.string(), slug: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string().trim().min(1)
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["orgMembership"]>;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>, data?: object) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["orgMembership"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: roles = [] } = useGetOrgRoles(orgId);
|
||||
|
||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const metadataFormFields = useFieldArray({
|
||||
control,
|
||||
name: "metadata"
|
||||
});
|
||||
|
||||
const popUpData = popUp?.orgMembership?.data as {
|
||||
membershipId: string;
|
||||
role: string;
|
||||
roleId?: string;
|
||||
metadata: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!roles?.length) return;
|
||||
|
||||
if (popUpData) {
|
||||
reset({
|
||||
role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role),
|
||||
metadata: popUpData.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!)
|
||||
});
|
||||
}
|
||||
}, [popUp?.orgMembership?.data, roles]);
|
||||
|
||||
const onFormSubmit = async ({ role, metadata }: FormData) => {
|
||||
try {
|
||||
if (!orgId) return;
|
||||
|
||||
await updateOrgMembership({
|
||||
organizationId: orgId,
|
||||
membershipId: popUpData.membershipId,
|
||||
role: role.slug,
|
||||
metadata
|
||||
});
|
||||
|
||||
handlePopUpToggle("orgMembership", false);
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated user organization role",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to update user organization role";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.orgMembership?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("orgMembership", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent bodyClassName="overflow-visible" title="Update Membership">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Update Organization Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={roles}
|
||||
onChange={(newValue) => {
|
||||
const role = newValue as SingleValue<(typeof roles)[number]>;
|
||||
|
||||
if (!role) return;
|
||||
|
||||
const isCustomRole = isCustomOrgRole(role.slug);
|
||||
|
||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description:
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(role);
|
||||
}}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel label="Metadata" />
|
||||
</div>
|
||||
<div className="mb-3 flex flex-col space-y-2">
|
||||
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
|
||||
<div key={metadataFieldId} className="flex items-end space-x-2">
|
||||
<div className="flex-grow">
|
||||
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`metadata.${i}.key`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
{i === 0 && (
|
||||
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name={`metadata.${i}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
ariaLabel="delete key"
|
||||
className="bottom-0.5 h-9"
|
||||
variant="outline_bg"
|
||||
onClick={() => metadataFormFields.remove(i)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() => metadataFormFields.append({ key: "", value: "" })}
|
||||
>
|
||||
Add Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("orgMembership", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { useMemo } 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 { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useAddUserToWsNonE2EE,
|
||||
useGetOrgMembershipProjectMemberships,
|
||||
useGetUserWorkspaces
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
projectId: z.string()
|
||||
})
|
||||
.required();
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
membershipId: string;
|
||||
popUp: UsePopUpState<["addUserToProject"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["addUserToProject"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
const UserAddToProjectModalChild = ({ membershipId, popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { data: workspaces = [] } = useGetUserWorkspaces();
|
||||
|
||||
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
|
||||
|
||||
const popupData = popUp.addUserToProject.data as {
|
||||
username: string;
|
||||
};
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const { data: projectMemberships } = useGetOrgMembershipProjectMemberships(orgId, membershipId);
|
||||
|
||||
const filteredWorkspaces = useMemo(() => {
|
||||
const wsWorkspaceIds = new Map();
|
||||
|
||||
projectMemberships?.forEach((projectMembership) => {
|
||||
wsWorkspaceIds.set(projectMembership.project.id, true);
|
||||
});
|
||||
|
||||
return (workspaces || []).filter(
|
||||
({ id, orgId: projectOrgId, version }) =>
|
||||
!wsWorkspaceIds.has(id) && projectOrgId === currentOrg?.id && version !== ProjectVersion.V1
|
||||
);
|
||||
}, [workspaces, projectMemberships]);
|
||||
|
||||
const onFormSubmit = async ({ projectId }: FormData) => {
|
||||
try {
|
||||
await addUserToWorkspaceNonE2EE({
|
||||
projectId,
|
||||
usernames: [popupData.username],
|
||||
orgId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully added user to project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
handlePopUpToggle("addUserToProject", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to add identity to project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectId"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Project" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(filteredWorkspaces || []).map(({ id, name }) => (
|
||||
<SelectItem value={id} key={`project-${id}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("addUserToProject", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserAddToProjectModal = (props: Props) => {
|
||||
const { popUp, handlePopUpToggle } = props;
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addUserToProject?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addUserToProject", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Add User to Project">
|
||||
<UserAddToProjectModalChild {...props} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components";
|
||||
|
||||
type Props = {
|
||||
orgMembership: OrgUser;
|
||||
};
|
||||
|
||||
export const UserAuditLogsSection = withPermission(
|
||||
({ orgMembership }: Props) => {
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
return (
|
||||
subscription?.auditLogs && (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<p className="text-lg font-semibold text-gray-200">Audit Logs</p>
|
||||
|
||||
<Tooltip content="Show audit log filters">
|
||||
<IconButton
|
||||
colorSchema="primary"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>Filter</p>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</div>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<LogsSection
|
||||
showFilters={showFilter}
|
||||
filterClassName="bg-mineshaft-900 static"
|
||||
presets={{
|
||||
actorId: orgMembership.user.id
|
||||
}}
|
||||
isOrgAuditLogs
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member }
|
||||
);
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
group: TGroupWithProjectMemberships;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: object) => void;
|
||||
};
|
||||
|
||||
export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
key={`user-project-membership-${group.id}`}
|
||||
>
|
||||
<Td>{group.name}</Td>
|
||||
<Td>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content="Unassign user from group">
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeUserFromGroup", {
|
||||
groupId: group.id,
|
||||
groupSlug: group.slug
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { useRemoveUserFromGroup } from "@app/hooks/api";
|
||||
import { OrgUser } from "@app/hooks/api/users/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { UserGroupsTable } from "./UserGroupsTable";
|
||||
|
||||
type Props = {
|
||||
orgMembership: OrgUser;
|
||||
};
|
||||
|
||||
export const UserGroupsSection = ({ orgMembership }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"removeUserFromGroup"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: removeUserFromGroup } = useRemoveUserFromGroup();
|
||||
|
||||
const handleRemoveUserFromGroup = useCallback(async (groupId: string, groupSlug: string) => {
|
||||
try {
|
||||
await removeUserFromGroup({
|
||||
groupId,
|
||||
slug: groupSlug,
|
||||
username: orgMembership.user.username
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "User removed from group successfully"
|
||||
});
|
||||
|
||||
handlePopUpClose("removeUserFromGroup");
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to remove user from group"
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Groups</h3>
|
||||
</div>
|
||||
|
||||
<UserGroupsTable orgMembership={orgMembership} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUserFromGroup.isOpen}
|
||||
title="Are you sure want to unassign user from group?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUserFromGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => {
|
||||
const popupData = popUp?.removeUserFromGroup?.data as {
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
};
|
||||
|
||||
return handleRemoveUserFromGroup(popupData.groupId, popupData.groupSlug);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faUser
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { UserGroupsRow } from "./UserGroupsRow";
|
||||
|
||||
type Props = {
|
||||
orgMembership: OrgUser;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: object) => void;
|
||||
};
|
||||
|
||||
enum UserGroupsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
|
||||
const { data: groupMemberships = [], isPending } = useListUserGroupMemberships(
|
||||
orgMembership.user.username
|
||||
);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const filteredGroupMemberships = useMemo(
|
||||
() =>
|
||||
groupMemberships
|
||||
.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase());
|
||||
}),
|
||||
[groupMemberships, orderDirection, search]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search groups..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-full">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredGroupMemberships.slice(offset, perPage * page).map((group) => (
|
||||
<UserGroupsRow
|
||||
key={`user-group-${group.id}`}
|
||||
group={group}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships.length
|
||||
? "No groups match search..."
|
||||
: "This user has not been assigned to any groups"
|
||||
}
|
||||
icon={groupMemberships.length ? faSearch : faUser}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from "react";
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IconButton, Tag, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { OrgAccessControlTabSections } from "@app/types/org";
|
||||
|
||||
type Props = {
|
||||
membership: TWorkspaceUser;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeUserFromProject"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Admin) return "Admin";
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.Viewer) return "Viewer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No Access";
|
||||
return role;
|
||||
};
|
||||
|
||||
export const UserProjectRow = ({
|
||||
membership: { id, project, user, roles },
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
const { data: workspaces = [] } = useGetUserWorkspaces();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isAccessible = useMemo(() => {
|
||||
const workspaceIds = new Map();
|
||||
|
||||
workspaces?.forEach((workspace) => {
|
||||
workspaceIds.set(workspace.id, true);
|
||||
});
|
||||
|
||||
return workspaceIds.has(project.id);
|
||||
}, [workspaces, project]);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
key={`user-project-membership-${id}`}
|
||||
onClick={() => {
|
||||
if (isAccessible) {
|
||||
navigate({
|
||||
to: `/${project.type}/$projectId/access-management` as const,
|
||||
params: {
|
||||
projectId: project.id
|
||||
},
|
||||
search: {
|
||||
selectedTab: OrgAccessControlTabSections.Member
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Unable to access project",
|
||||
type: "error"
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Td className="max-w-0 truncate">{project.name}</Td>
|
||||
<Td>
|
||||
<Tag size="xs">{project.type}</Tag>
|
||||
</Td>
|
||||
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
|
||||
roles.length > 1 ? ` (+${roles.length - 1})` : ""
|
||||
}`}</Td>
|
||||
<Td>
|
||||
{isAccessible && (
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content="Remove">
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeUserFromProject", {
|
||||
username: user.username,
|
||||
projectId: project.id,
|
||||
projectName: project.name
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, IconButton } from "@app/components/v2";
|
||||
import { useOrganization, useUser } from "@app/context";
|
||||
import { useDeleteUserFromWorkspace, useGetOrgMembership } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { UserAddToProjectModal } from "./UserAddToProjectModal";
|
||||
import { UserProjectsTable } from "./UserProjectsTable";
|
||||
|
||||
type Props = {
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
export const UserProjectsSection = ({ membershipId }: Props) => {
|
||||
const { user } = useUser();
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const userId = user?.id || "";
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: membership } = useGetOrgMembership(orgId, membershipId);
|
||||
|
||||
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"addUserToProject",
|
||||
"removeUserFromProject"
|
||||
] as const);
|
||||
|
||||
const handleRemoveUser = async (projectId: string, username: string) => {
|
||||
try {
|
||||
await removeUserFromWorkspace({ workspaceId: projectId, usernames: [username], orgId });
|
||||
createNotification({
|
||||
text: "Successfully removed user from project",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove user from the project",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
handlePopUpClose("removeUserFromProject");
|
||||
};
|
||||
|
||||
return membership ? (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Projects</h3>
|
||||
{userId !== membership.user.id && membership.status !== "invited" && (
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addUserToProject", {
|
||||
username: membership.user.username
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<UserProjectsTable membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
<UserAddToProjectModal
|
||||
membershipId={membershipId}
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUserFromProject.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Do you want to remove this user from ${
|
||||
(popUp?.removeUserFromProject?.data as { projectName: string })?.projectName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUserFromProject", isOpen)}
|
||||
onDeleteApproved={() => {
|
||||
const popupData = popUp?.removeUserFromProject?.data as {
|
||||
username: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
};
|
||||
|
||||
return handleRemoveUser(popupData.projectId, popupData.username);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
);
|
||||
};
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { UserProjectRow } from "./UserProjectRow";
|
||||
|
||||
type Props = {
|
||||
membershipId: string;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeUserFromProject"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum UserProjectsOrderBy {
|
||||
Name = "Name"
|
||||
}
|
||||
|
||||
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(UserProjectsOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const { data: projectMemberships = [], isPending } = useGetOrgMembershipProjectMemberships(
|
||||
orgId,
|
||||
membershipId
|
||||
);
|
||||
|
||||
const filteredProjectMemberships = useMemo(
|
||||
() =>
|
||||
projectMemberships
|
||||
?.filter((membership) =>
|
||||
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
return membershipOne.project.name
|
||||
.toLowerCase()
|
||||
.localeCompare(membershipTwo.project.name.toLowerCase());
|
||||
}),
|
||||
[projectMemberships, orderDirection, search]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredProjectMemberships.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search projects..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-2/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Type</Th>
|
||||
<Th>Role</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
|
||||
{!isPending &&
|
||||
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
|
||||
return (
|
||||
<UserProjectRow
|
||||
key={`user-project-membership-${membership.id}`}
|
||||
membership={membership}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredProjectMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredProjectMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredProjectMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
projectMemberships.length
|
||||
? "No projects match search..."
|
||||
: "This user has not been assigned to any projects"
|
||||
}
|
||||
icon={projectMemberships.length ? faSearch : faFolder}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { UserProjectsSection } from "./UserProjectsSection";
|
||||
@@ -0,0 +1,3 @@
|
||||
export { UserDetailsSection } from "./UserDetailsSection";
|
||||
export { UserOrgMembershipModal } from "./UserOrgMembershipModal";
|
||||
export { UserProjectsSection } from "./UserProjectsSection";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { UserDetailsByIDPage } from "./UserDetailsByIDPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/organization/_layout/members/$membershipId"
|
||||
)({
|
||||
component: UserDetailsByIDPage
|
||||
});
|
||||
Reference in New Issue
Block a user