mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: added role modify popup
This commit is contained in:
@@ -4,5 +4,17 @@ enum OrgMembershipRole {
|
||||
NoAccess = "no-access"
|
||||
}
|
||||
|
||||
enum ProjectMemberRole {
|
||||
Admin = "admin",
|
||||
Member = "member",
|
||||
Viewer = "viewer",
|
||||
NoAccess = "no-access"
|
||||
}
|
||||
|
||||
export const isCustomOrgRole = (slug: string) =>
|
||||
!Object.values(OrgMembershipRole).includes(slug as OrgMembershipRole);
|
||||
|
||||
export const formatProjectRoleName = (name: string) => {
|
||||
if (name === ProjectMemberRole.Member) return "developer";
|
||||
return name;
|
||||
};
|
||||
|
||||
@@ -420,8 +420,11 @@ export const useUpdateUserWorkspaceRole = () => {
|
||||
);
|
||||
return membership;
|
||||
},
|
||||
onSuccess: (_, { workspaceId }) => {
|
||||
onSuccess: (_, { workspaceId, membershipId }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
|
||||
queryClient.invalidateQueries(
|
||||
workspaceKeys.getWorkspaceUserDetails(workspaceId, membershipId)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export const MemberDetailsPage = withProjectPermission(
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Project User Management</h3>
|
||||
<div>
|
||||
<Button colorSchema="danger" variant="outline_bg" size="xs">
|
||||
Remove
|
||||
Remove User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
IconButton,
|
||||
@@ -11,20 +12,27 @@ import {
|
||||
TableSkeleton,
|
||||
EmptyState,
|
||||
TBody,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Tag
|
||||
} from "@app/components/v2";
|
||||
import { useUser, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useDeleteProjectRole,
|
||||
useGetWorkspaceUserDetails,
|
||||
useUpdateUserWorkspaceRole
|
||||
} from "@app/hooks/api";
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { formatProjectRoleName } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useRouter } from "next/router";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { MemberRoleModify } from "./MemberRoleModify";
|
||||
|
||||
type Props = {
|
||||
membershipDetails: TWorkspaceUser;
|
||||
@@ -35,11 +43,11 @@ export const MemberRoleDetailsSection = ({
|
||||
membershipDetails,
|
||||
isMembershipDetailsLoading
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
const { user } = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deleteRole"
|
||||
"deleteRole",
|
||||
"modifyRole"
|
||||
] as const);
|
||||
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
|
||||
|
||||
@@ -67,14 +75,26 @@ export const MemberRoleDetailsSection = ({
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Project Roles</h3>
|
||||
{userId !== membershipDetails?.user?.id && membershipDetails?.status !== "invited" && (
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {}}
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
renderTooltip
|
||||
allowedLabel="Edit role"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("modifyRole");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
@@ -93,33 +113,76 @@ export const MemberRoleDetailsSection = ({
|
||||
)}
|
||||
{!isMembershipDetailsLoading &&
|
||||
membershipDetails?.roles?.map((roleDetails) => {
|
||||
const isTemporary = roleDetails?.isTemporary;
|
||||
const isExpired =
|
||||
roleDetails.isTemporary &&
|
||||
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
|
||||
|
||||
let text = "Permanent";
|
||||
let toolTipText = "Non expiry access";
|
||||
if (roleDetails.isTemporary) {
|
||||
if (isExpired) {
|
||||
text = "Access Expired";
|
||||
toolTipText = "Timed Access Expired";
|
||||
} else {
|
||||
text = formatDistance(
|
||||
new Date(roleDetails.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
);
|
||||
toolTipText = `Until ${format(
|
||||
new Date(roleDetails.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd hh:mm:ss aaa"
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr className="group h-10" key={`user-project-membership-${roleDetails?.id}`}>
|
||||
<Td>
|
||||
<Td className="capitalize">
|
||||
{roleDetails.role === "custom"
|
||||
? roleDetails.customRoleName
|
||||
: roleDetails.role}
|
||||
: formatProjectRoleName(roleDetails.role)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip asChild={false} content={toolTipText}>
|
||||
<Tag
|
||||
className={twMerge(
|
||||
"capitalize",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>{!roleDetails.isTemporary ? "permanent" : "temporary"}</Td>
|
||||
<Td>
|
||||
<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("deleteRole", {
|
||||
id: roleDetails?.id,
|
||||
slug: roleDetails?.customRoleSlug || roleDetails?.role
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
renderTooltip
|
||||
allowedLabel="Remove role"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", {
|
||||
id: roleDetails?.id,
|
||||
slug: roleDetails?.customRoleName || roleDetails?.role
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -139,6 +202,14 @@ export const MemberRoleDetailsSection = ({
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
|
||||
onDeleteApproved={() => handleRoleDelete()}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.modifyRole.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("modifyRole", isOpen)}
|
||||
>
|
||||
<ModalContent title="Roles" subTitle="Select one of the pre-defined or custom roles.">
|
||||
<MemberRoleModify projectMember={membershipDetails} onOpenUpgradeModal={() => {}} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
|
||||
export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isLoading) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId: projectMember.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
|
||||
containerClassName="w-1/2"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
|
||||
<div className="flex-grow">
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non expiry access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"w-full border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"cursor-default opacity-0",
|
||||
roleForm.formState.isDirty && "cursor-pointer opacity-100"
|
||||
)}
|
||||
isDisabled={!roleForm.formState.isDirty}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user