feat: renamed old frontend and fixed some minor bugs

This commit is contained in:
=
2025-01-06 14:56:49 +05:30
parent 8c902d7699
commit 54377a44d3
2577 changed files with 35240 additions and 35116 deletions
@@ -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 />
</>
);
};
@@ -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 />
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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 }
);
@@ -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>
</>
);
};
@@ -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);
}}
/>
</>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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 />
);
};
@@ -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>
);
};
@@ -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
});