diff --git a/plane-src/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx b/plane-src/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx index 32818be..6d98415 100644 --- a/plane-src/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx +++ b/plane-src/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx @@ -39,7 +39,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { const getSidebarMenuItems = (align: "left" | "right" = "left") => ( -
+
- + + = { 20: "Администратор", }; +const BAN_DURATION_OPTIONS = [ + { label: "На 24 часа", value: "1d", hours: 24 }, + { label: "На 7 дней", value: "7d", hours: 24 * 7 }, + { label: "На 30 дней", value: "30d", hours: 24 * 30 }, + { label: "До ручного разбана", value: "manual", hours: null }, +]; + const ACCESS_MODE_LABELS: Record = { all_workspace_members: "Весь воркспейс", admins_only: "Только админы", @@ -55,6 +62,162 @@ function getErrorMessage(error: unknown, fallback: string) { return fallback; } +function getBanUntilIso(duration: string) { + const option = BAN_DURATION_OPTIONS.find((item) => item.value === duration); + if (!option?.hours) return null; + + return new Date(Date.now() + option.hours * 60 * 60 * 1000).toISOString(); +} + +function formatBanUntil(bannedUntil?: string | null) { + if (!bannedUntil) return "до ручного разбана"; + + return `до ${new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(bannedUntil))}`; +} + +function RoleSelect(props: { disabled: boolean; onChange: (role: number) => void; value: number }) { + const { disabled, onChange, value } = props; + + return ( + {ROLE_LABELS[value] ?? "Роль"}} + onChange={(role: number) => onChange(Number(role))} + buttonClassName="nodedc-settings-select h-10 min-h-10 w-full justify-between px-3 text-12" + className="w-full" + disabled={disabled} + input + maxHeight="sm" + optionsClassName="z-[80] min-w-[12rem]" + placement="bottom-start" + > + {ROLE_OPTIONS.map((role) => ( + + {role.label} + + ))} + + ); +} + +type TPendingMemberAction = { + type: "ban" | "remove" | "unban"; + workspaceMember: TInstanceWorkspaceMember; +}; + +function MemberActionConfirmModal(props: { + action: TPendingMemberAction | null; + banDuration: string; + isLoading: boolean; + onBanDurationChange: (duration: string) => void; + onClose: () => void; + onConfirm: () => void; +}) { + const { action, banDuration, isLoading, onBanDurationChange, onClose, onConfirm } = props; + const memberName = action ? getMemberName(action.workspaceMember) : ""; + const title = + action?.type === "ban" + ? "Заблокировать участника" + : action?.type === "unban" + ? "Разблокировать участника" + : "Удалить участника"; + const description = + action?.type === "ban" + ? `${memberName} не сможет открыть этот воркспейс и принять новые приглашения до снятия блокировки.` + : action?.type === "unban" + ? `${memberName} снова сможет войти в воркспейс. Проектные доступы после блокировки выдаются отдельно.` + : `${memberName} будет удален из воркспейса и всех его проектов. Это крайний вариант удаления доступа.`; + const confirmLabel = action?.type === "ban" ? "Заблокировать" : action?.type === "unban" ? "Разблокировать" : "Удалить"; + + return ( + + undefined : onClose}> + +
+ +
+
+ + + + {title} + +

{description}

+ + {action?.type === "ban" && ( +
+
Срок блокировки
+ option.value === banDuration)?.label} + onChange={(duration: string) => onBanDurationChange(duration)} + buttonClassName="nodedc-settings-select h-11 min-h-11 w-full justify-between px-4 text-13" + className="w-full" + disabled={isLoading} + input + maxHeight="sm" + optionsClassName="z-[90]" + > + {BAN_DURATION_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+ )} + +
+ + +
+
+
+
+
+
+
+ ); +} + function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: ReactNode; title: string; icon: ReactNode }) { const { children, icon, isOpen, onClose, title, workspaceId } = props; const { getWorkspaceById } = useWorkspace(); @@ -86,8 +249,8 @@ function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: Reac leaveFrom="translate-y-0 opacity-100 scale-100" leaveTo="translate-y-2 opacity-0 scale-95" > - -
+ +
{icon} @@ -104,10 +267,10 @@ function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: Reac
{children}
@@ -121,8 +284,10 @@ function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: Reac } export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) { - const { isOpen, onClose, workspaceId } = props; + const { isOpen, workspaceId } = props; + const [banDuration, setBanDuration] = useState("7d"); const [mutatingMemberId, setMutatingMemberId] = useState(null); + const [pendingAction, setPendingAction] = useState(null); const { data, isLoading, mutate } = useSWR( isOpen && workspaceId ? ["INSTANCE_WORKSPACE_MEMBERS", workspaceId] : null, () => instanceWorkspaceService.listMembers(workspaceId as string) @@ -146,36 +311,74 @@ export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) { } }; + const handleMemberBan = async (workspaceMember: TInstanceWorkspaceMember, isBanned: boolean) => { + if (!workspaceId) return; + + setMutatingMemberId(workspaceMember.id); + try { + await instanceWorkspaceService.updateMemberBan(workspaceId, workspaceMember.id, { + is_banned: isBanned, + banned_until: isBanned ? getBanUntilIso(banDuration) : null, + }); + await mutate(); + setToast({ type: TOAST_TYPE.SUCCESS, title: isBanned ? "Участник заблокирован" : "Участник разблокирован" }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: isBanned ? "Не удалось заблокировать участника" : "Не удалось разблокировать участника", + message: getErrorMessage(error, "Пользователь может быть единственным администратором."), + }); + } finally { + setMutatingMemberId(null); + setPendingAction(null); + } + }; + const handleRemove = async (workspaceMember: TInstanceWorkspaceMember) => { if (!workspaceId) return; - const confirmed = window.confirm(`Отключить ${getMemberName(workspaceMember)} от этого воркспейса и его проектов?`); - if (!confirmed) return; setMutatingMemberId(workspaceMember.id); try { await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id); await mutate(); - setToast({ type: TOAST_TYPE.SUCCESS, title: "Доступ к воркспейсу отключен" }); + setToast({ type: TOAST_TYPE.SUCCESS, title: "Участник удален из воркспейса" }); } catch (error) { setToast({ type: TOAST_TYPE.ERROR, - title: "Не удалось отключить участника", + title: "Не удалось удалить участника", message: getErrorMessage(error, "Пользователь может быть единственным администратором."), }); } finally { setMutatingMemberId(null); + setPendingAction(null); } }; + const handleConfirmAction = () => { + if (!pendingAction) return; + + if (pendingAction.type === "ban") return handleMemberBan(pendingAction.workspaceMember, true); + if (pendingAction.type === "unban") return handleMemberBan(pendingAction.workspaceMember, false); + return handleRemove(pendingAction.workspaceMember); + }; + return ( }> + setPendingAction(null)} + onConfirm={handleConfirmAction} + /> {isLoading ? (
) : (
-
+
Пользователь
Роль
Проекты
@@ -186,41 +389,56 @@ export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) { {(data ?? []).map((workspaceMember) => (
-
{getMemberName(workspaceMember)}
+
+
{getMemberName(workspaceMember)}
+ {workspaceMember.is_banned && ( + + Блокировка {formatBanUntil(workspaceMember.banned_until)} + + )} +
{workspaceMember.member.email}
- + onChange={(role) => handleRoleChange(workspaceMember.id, role)} + />
{workspaceMember.active_project_count}
{workspaceMember.admin_project_count}
-
+
+
diff --git a/plane-src/apps/admin/components/workspace/list-item.tsx b/plane-src/apps/admin/components/workspace/list-item.tsx index 039e366..67fd82f 100644 --- a/plane-src/apps/admin/components/workspace/list-item.tsx +++ b/plane-src/apps/admin/components/workspace/list-item.tsx @@ -89,7 +89,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({