From 4ba3aab02eb88ee8335d71816d53c9e81706f2af Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 29 Apr 2026 01:15:17 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B8=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B2=D0=BE=D1=80=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=B9=D1=81=D0=B0=20=D0=B2=20God=20Mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(all)/(dashboard)/sidebar-dropdown.tsx | 10 +- .../components/workspace/admin-modals.tsx | 280 ++++++++++++++++-- .../admin/components/workspace/list-item.tsx | 6 +- plane-src/apps/admin/styles/globals.css | 47 +++ .../apps/api/plane/app/permissions/base.py | 5 + .../apps/api/plane/app/permissions/project.py | 16 + .../api/plane/app/permissions/workspace.py | 18 ++ .../api/plane/app/views/project/invite.py | 15 +- .../api/plane/app/views/workspace/base.py | 3 + .../api/plane/app/views/workspace/invite.py | 41 ++- .../authentication/utils/redirection_path.py | 3 + .../migrations/0136_workspace_member_ban.py | 32 ++ .../apps/api/plane/db/models/workspace.py | 4 + .../api/plane/license/api/views/__init__.py | 1 + .../api/plane/license/api/views/workspace.py | 81 ++++- plane-src/apps/api/plane/license/urls.py | 6 + .../apps/api/plane/utils/workspace_bans.py | 57 ++++ .../workspace/instance-workspace.service.ts | 12 + plane-src/packages/types/src/workspace.ts | 3 + 19 files changed, 598 insertions(+), 42 deletions(-) create mode 100644 plane-src/apps/api/plane/db/migrations/0136_workspace_member_ban.py create mode 100644 plane-src/apps/api/plane/utils/workspace_bans.py 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({