ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: блокировка и удаление участников воркспейса в God Mode
This commit is contained in:
parent
7bf416ec1f
commit
4ba3aab02e
|
|
@ -39,7 +39,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||||
const getSidebarMenuItems = (align: "left" | "right" = "left") => (
|
const getSidebarMenuItems = (align: "left" | "right" = "left") => (
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
className={cn(
|
className={cn(
|
||||||
"nodedc-glass-popup-surface absolute z-20 mt-1.5 flex w-56 flex-col divide-y divide-white/6 px-2 py-2 text-11 outline-none",
|
"nodedc-glass-popup-surface absolute top-full z-[100] mt-2 flex w-56 flex-col divide-y divide-white/6 px-2 py-2 text-11 outline-none",
|
||||||
{
|
{
|
||||||
"left-0": align === "left",
|
"left-0": align === "left",
|
||||||
"right-0": align === "right",
|
"right-0": align === "right",
|
||||||
|
|
@ -84,13 +84,13 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 pt-4 pb-2">
|
<div className="px-3 pt-4 pb-2">
|
||||||
<div className="h-full w-full truncate">
|
<div className="relative h-full w-full overflow-visible">
|
||||||
<div
|
<div
|
||||||
className={`nodedc-admin-sidebar-profile flex flex-grow items-center gap-x-3 truncate px-3 py-3 ${
|
className={`nodedc-admin-sidebar-profile relative z-[80] flex flex-grow items-center gap-x-3 px-3 py-3 ${
|
||||||
isSidebarCollapsed ? "justify-center" : ""
|
isSidebarCollapsed ? "justify-center" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Menu as="div" className="flex-shrink-0">
|
<Menu as="div" className="relative z-[100] flex-shrink-0">
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
className={cn("grid place-items-center outline-none", {
|
className={cn("grid place-items-center outline-none", {
|
||||||
"cursor-default": !isSidebarCollapsed,
|
"cursor-default": !isSidebarCollapsed,
|
||||||
|
|
@ -123,7 +123,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSidebarCollapsed && currentUser && (
|
{!isSidebarCollapsed && currentUser && (
|
||||||
<Menu as="div" className="relative flex-shrink-0">
|
<Menu as="div" className="relative z-[100] flex-shrink-0">
|
||||||
<Menu.Button className="nodedc-admin-sidebar-action grid size-8 place-items-center outline-none">
|
<Menu.Button className="nodedc-admin-sidebar-action grid size-8 place-items-center outline-none">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={currentUser.display_name}
|
name={currentUser.display_name}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import { Fragment, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { Loader, ShieldCheck, Trash2, UsersRound, X } from "lucide-react";
|
import { Ban, CheckCircle2, Loader, ShieldCheck, Trash2, UsersRound, X } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||||
import { InstanceWorkspaceService } from "@plane/services";
|
import { InstanceWorkspaceService } from "@plane/services";
|
||||||
import type { TInstanceWorkspaceFeature, TInstanceWorkspaceMember } from "@plane/types";
|
import type { TInstanceWorkspaceFeature, TInstanceWorkspaceMember } from "@plane/types";
|
||||||
import { ToggleSwitch } from "@plane/ui";
|
import { CustomSelect, ToggleSwitch } from "@plane/ui";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useWorkspace } from "@/hooks/store";
|
import { useWorkspace } from "@/hooks/store";
|
||||||
|
|
@ -39,6 +39,13 @@ const ROLE_LABELS: Record<number, string> = {
|
||||||
20: "Администратор",
|
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<TInstanceWorkspaceFeature["access_mode"], string> = {
|
const ACCESS_MODE_LABELS: Record<TInstanceWorkspaceFeature["access_mode"], string> = {
|
||||||
all_workspace_members: "Весь воркспейс",
|
all_workspace_members: "Весь воркспейс",
|
||||||
admins_only: "Только админы",
|
admins_only: "Только админы",
|
||||||
|
|
@ -55,6 +62,162 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||||
return fallback;
|
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 (
|
||||||
|
<CustomSelect
|
||||||
|
value={value}
|
||||||
|
label={<span className="truncate">{ROLE_LABELS[value] ?? "Роль"}</span>}
|
||||||
|
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) => (
|
||||||
|
<CustomSelect.Option key={role.value} value={role.value} className="w-full">
|
||||||
|
<span className="text-12 font-medium">{role.label}</span>
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Transition.Root show={Boolean(action)} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={isLoading ? () => undefined : onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/45 backdrop-blur-xl" />
|
||||||
|
</Transition.Child>
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="translate-y-2 opacity-0 scale-95"
|
||||||
|
enterTo="translate-y-0 opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-150"
|
||||||
|
leaveFrom="translate-y-0 opacity-100 scale-100"
|
||||||
|
leaveTo="translate-y-2 opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="nodedc-glass-modal nodedc-technical-confirm-modal w-full max-w-[33rem] rounded-[1.75rem] p-6 text-left">
|
||||||
|
<Dialog.Title as="h3" className="text-17 font-semibold text-primary">
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
<p className="mt-2 text-13 leading-5 text-secondary">{description}</p>
|
||||||
|
|
||||||
|
{action?.type === "ban" && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="mb-2 text-12 font-medium text-tertiary">Срок блокировки</div>
|
||||||
|
<CustomSelect
|
||||||
|
value={banDuration}
|
||||||
|
label={BAN_DURATION_OPTIONS.find((option) => 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) => (
|
||||||
|
<CustomSelect.Option key={option.value} value={option.value} className="w-full">
|
||||||
|
{option.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-2.5">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="nodedc-settings-save-button min-w-[8.5rem] justify-center"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={isLoading}
|
||||||
|
className="nodedc-settings-secondary-button min-w-[8.5rem] justify-center"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: ReactNode; title: string; icon: ReactNode }) {
|
function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: ReactNode; title: string; icon: ReactNode }) {
|
||||||
const { children, icon, isOpen, onClose, title, workspaceId } = props;
|
const { children, icon, isOpen, onClose, title, workspaceId } = props;
|
||||||
const { getWorkspaceById } = useWorkspace();
|
const { getWorkspaceById } = useWorkspace();
|
||||||
|
|
@ -86,8 +249,8 @@ function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: Reac
|
||||||
leaveFrom="translate-y-0 opacity-100 scale-100"
|
leaveFrom="translate-y-0 opacity-100 scale-100"
|
||||||
leaveTo="translate-y-2 opacity-0 scale-95"
|
leaveTo="translate-y-2 opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="nodedc-glass-modal relative w-full max-w-5xl overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all">
|
<Dialog.Panel className="nodedc-glass-modal relative w-full max-w-[80rem] overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all">
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-subtle p-5">
|
<div className="flex items-start justify-between gap-4 p-5 pr-16">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-layer-2 text-accent-primary">
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-layer-2 text-accent-primary">
|
||||||
{icon}
|
{icon}
|
||||||
|
|
@ -104,10 +267,10 @@ function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: Reac
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="nodedc-settings-secondary-button flex h-10 min-h-10 w-10 shrink-0 items-center justify-center rounded-full p-0 !px-0"
|
className="absolute top-2 right-2 flex h-10 min-h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/6 p-0 text-primary transition hover:bg-white/10 hover:text-primary focus-visible:outline-none"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-5 w-5 stroke-[2.2]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[72vh] overflow-y-auto p-5">{children}</div>
|
<div className="max-h-[72vh] overflow-y-auto p-5">{children}</div>
|
||||||
|
|
@ -121,8 +284,10 @@ function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: Reac
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
|
export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
|
||||||
const { isOpen, onClose, workspaceId } = props;
|
const { isOpen, workspaceId } = props;
|
||||||
|
const [banDuration, setBanDuration] = useState("7d");
|
||||||
const [mutatingMemberId, setMutatingMemberId] = useState<string | null>(null);
|
const [mutatingMemberId, setMutatingMemberId] = useState<string | null>(null);
|
||||||
|
const [pendingAction, setPendingAction] = useState<TPendingMemberAction | null>(null);
|
||||||
const { data, isLoading, mutate } = useSWR(
|
const { data, isLoading, mutate } = useSWR(
|
||||||
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_MEMBERS", workspaceId] : null,
|
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_MEMBERS", workspaceId] : null,
|
||||||
() => instanceWorkspaceService.listMembers(workspaceId as string)
|
() => 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) => {
|
const handleRemove = async (workspaceMember: TInstanceWorkspaceMember) => {
|
||||||
if (!workspaceId) return;
|
if (!workspaceId) return;
|
||||||
const confirmed = window.confirm(`Отключить ${getMemberName(workspaceMember)} от этого воркспейса и его проектов?`);
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
setMutatingMemberId(workspaceMember.id);
|
setMutatingMemberId(workspaceMember.id);
|
||||||
try {
|
try {
|
||||||
await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id);
|
await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id);
|
||||||
await mutate();
|
await mutate();
|
||||||
setToast({ type: TOAST_TYPE.SUCCESS, title: "Доступ к воркспейсу отключен" });
|
setToast({ type: TOAST_TYPE.SUCCESS, title: "Участник удален из воркспейса" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Не удалось отключить участника",
|
title: "Не удалось удалить участника",
|
||||||
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
|
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setMutatingMemberId(null);
|
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 (
|
return (
|
||||||
<WorkspaceModalShell {...props} title="Участники воркспейса" icon={<UsersRound className="h-5 w-5" />}>
|
<WorkspaceModalShell {...props} title="Участники воркспейса" icon={<UsersRound className="h-5 w-5" />}>
|
||||||
|
<MemberActionConfirmModal
|
||||||
|
action={pendingAction}
|
||||||
|
banDuration={banDuration}
|
||||||
|
isLoading={Boolean(mutatingMemberId)}
|
||||||
|
onBanDurationChange={setBanDuration}
|
||||||
|
onClose={() => setPendingAction(null)}
|
||||||
|
onConfirm={handleConfirmAction}
|
||||||
|
/>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex min-h-40 items-center justify-center text-secondary">
|
<div className="flex min-h-40 items-center justify-center text-secondary">
|
||||||
<Loader className="h-5 w-5 animate-spin" />
|
<Loader className="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-[1.35rem] bg-layer-1">
|
<div className="overflow-hidden rounded-[1.35rem] bg-layer-1">
|
||||||
<div className="grid grid-cols-[minmax(14rem,1.7fr)_11rem_8rem_9rem_7rem] gap-3 border-b border-subtle px-4 py-3 text-11 font-semibold uppercase text-tertiary">
|
<div className="grid grid-cols-[minmax(14rem,1.45fr)_13rem_6rem_8rem_20rem] gap-3 border-b border-subtle px-4 py-3 text-11 font-semibold uppercase text-tertiary">
|
||||||
<div>Пользователь</div>
|
<div>Пользователь</div>
|
||||||
<div>Роль</div>
|
<div>Роль</div>
|
||||||
<div>Проекты</div>
|
<div>Проекты</div>
|
||||||
|
|
@ -186,41 +389,56 @@ export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
|
||||||
{(data ?? []).map((workspaceMember) => (
|
{(data ?? []).map((workspaceMember) => (
|
||||||
<div
|
<div
|
||||||
key={workspaceMember.id}
|
key={workspaceMember.id}
|
||||||
className="grid grid-cols-[minmax(14rem,1.7fr)_11rem_8rem_9rem_7rem] items-center gap-3 px-4 py-3 text-13"
|
className="grid grid-cols-[minmax(14rem,1.45fr)_13rem_6rem_8rem_20rem] items-center gap-3 px-4 py-3 text-13"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="truncate font-medium text-primary">{getMemberName(workspaceMember)}</div>
|
<div className="truncate font-medium text-primary">{getMemberName(workspaceMember)}</div>
|
||||||
|
{workspaceMember.is_banned && (
|
||||||
|
<span className="shrink-0 rounded-full bg-red-500/12 px-2 py-0.5 text-10 font-semibold uppercase text-red-200">
|
||||||
|
Блокировка {formatBanUntil(workspaceMember.banned_until)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="truncate text-12 text-tertiary">{workspaceMember.member.email}</div>
|
<div className="truncate text-12 text-tertiary">{workspaceMember.member.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<RoleSelect
|
||||||
className="nodedc-settings-select h-9 min-h-9 w-full px-3 text-12"
|
value={Number(workspaceMember.role)}
|
||||||
value={workspaceMember.role}
|
|
||||||
disabled={mutatingMemberId === workspaceMember.id}
|
disabled={mutatingMemberId === workspaceMember.id}
|
||||||
onChange={(event) => handleRoleChange(workspaceMember.id, Number(event.target.value))}
|
onChange={(role) => handleRoleChange(workspaceMember.id, role)}
|
||||||
aria-label={`Роль: ${getMemberName(workspaceMember)}`}
|
/>
|
||||||
>
|
|
||||||
{ROLE_OPTIONS.map((role) => (
|
|
||||||
<option key={role.value} value={role.value}>
|
|
||||||
{role.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="text-secondary">{workspaceMember.active_project_count}</div>
|
<div className="text-secondary">{workspaceMember.active_project_count}</div>
|
||||||
<div className="text-secondary">{workspaceMember.admin_project_count}</div>
|
<div className="text-secondary">{workspaceMember.admin_project_count}</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemove(workspaceMember)}
|
onClick={() => setPendingAction({ type: workspaceMember.is_banned ? "unban" : "ban", workspaceMember })}
|
||||||
disabled={mutatingMemberId === workspaceMember.id}
|
disabled={mutatingMemberId === workspaceMember.id}
|
||||||
className="nodedc-settings-secondary-button flex h-9 min-h-9 items-center gap-2 px-3 text-12"
|
className="nodedc-settings-secondary-button flex h-9 min-h-9 min-w-[9.5rem] items-center justify-center gap-2 whitespace-nowrap px-3 text-12"
|
||||||
aria-label={`Отключить доступ: ${getMemberName(workspaceMember)}`}
|
aria-label={`${workspaceMember.is_banned ? "Разблокировать" : "Заблокировать"}: ${getMemberName(workspaceMember)}`}
|
||||||
|
>
|
||||||
|
{mutatingMemberId === workspaceMember.id ? (
|
||||||
|
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : workspaceMember.is_banned ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Ban className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{workspaceMember.is_banned ? "Разблокировать" : "Заблокировать"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingAction({ type: "remove", workspaceMember })}
|
||||||
|
disabled={mutatingMemberId === workspaceMember.id}
|
||||||
|
className="nodedc-settings-secondary-button flex h-9 min-h-9 min-w-[7.5rem] items-center justify-center gap-2 whitespace-nowrap px-3 text-12"
|
||||||
|
aria-label={`Удалить участника: ${getMemberName(workspaceMember)}`}
|
||||||
>
|
>
|
||||||
{mutatingMemberId === workspaceMember.id ? (
|
{mutatingMemberId === workspaceMember.id ? (
|
||||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Отключить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFeaturesClick(workspaceId)}
|
onClick={() => onFeaturesClick(workspaceId)}
|
||||||
className="flex size-9 items-center justify-center rounded-xl border border-white/8 bg-white/[0.035] p-0 text-tertiary transition hover:border-white/14 hover:bg-white/8 hover:text-primary"
|
className="flex size-9 items-center justify-center rounded-xl p-0 text-tertiary transition hover:text-primary focus-visible:outline-none focus-visible:text-primary"
|
||||||
aria-label="Доступный функционал"
|
aria-label="Доступный функционал"
|
||||||
>
|
>
|
||||||
<Sparkles className="size-4" />
|
<Sparkles className="size-4" />
|
||||||
|
|
@ -99,7 +99,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onMembersClick(workspaceId)}
|
onClick={() => onMembersClick(workspaceId)}
|
||||||
className="flex size-9 items-center justify-center rounded-xl border border-white/8 bg-white/[0.035] p-0 text-tertiary transition hover:border-white/14 hover:bg-white/8 hover:text-primary"
|
className="flex size-9 items-center justify-center rounded-xl p-0 text-tertiary transition hover:text-primary focus-visible:outline-none focus-visible:text-primary"
|
||||||
aria-label="Участники воркспейса"
|
aria-label="Участники воркспейса"
|
||||||
>
|
>
|
||||||
<UsersRound className="size-4" />
|
<UsersRound className="size-4" />
|
||||||
|
|
@ -109,7 +109,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||||
<a
|
<a
|
||||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex size-9 items-center justify-center rounded-xl border border-white/8 bg-white/[0.035] p-0 text-tertiary transition hover:border-white/14 hover:bg-white/8 hover:text-primary"
|
className="flex size-9 items-center justify-center rounded-xl p-0 text-tertiary transition hover:text-primary focus-visible:outline-none focus-visible:text-primary"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
aria-label="Открыть воркспейс"
|
aria-label="Открыть воркспейс"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,18 @@ body {
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-technical-confirm-modal {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.014) 100%),
|
||||||
|
rgba(6, 6, 8, 0.76) !important;
|
||||||
|
-webkit-backdrop-filter: blur(54px) saturate(130%);
|
||||||
|
backdrop-filter: blur(54px) saturate(130%);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 76px rgba(0, 0, 0, 0.5),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.26),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.036);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-glass-popup-surface {
|
.nodedc-glass-popup-surface {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.93) !important;
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.93) !important;
|
||||||
|
|
@ -135,6 +147,41 @@ body {
|
||||||
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.36);
|
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.36);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-dropdown-surface {
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 1.25rem !important;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
|
||||||
|
rgba(8, 8, 11, 0.92) !important;
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
-webkit-backdrop-filter: blur(44px);
|
||||||
|
backdrop-filter: blur(44px);
|
||||||
|
box-shadow:
|
||||||
|
0 22px 60px rgba(0, 0, 0, 0.36),
|
||||||
|
0 6px 18px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 0.9rem !important;
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
color: rgba(255, 255, 255, 0.72) !important;
|
||||||
|
outline: none !important;
|
||||||
|
user-select: none;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-dropdown-option:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
color: var(--text-color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-admin-header {
|
.nodedc-admin-header {
|
||||||
min-height: 4.25rem;
|
min-height: 4.25rem;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from rest_framework import status
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from plane.utils.workspace_bans import release_expired_workspace_bans
|
||||||
|
|
||||||
|
|
||||||
class ROLE(Enum):
|
class ROLE(Enum):
|
||||||
ADMIN = 20
|
ADMIN = 20
|
||||||
|
|
@ -20,6 +22,9 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||||
def decorator(view_func):
|
def decorator(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def _wrapped_view(instance, request, *args, **kwargs):
|
def _wrapped_view(instance, request, *args, **kwargs):
|
||||||
|
if not request.user.is_anonymous and kwargs.get("slug"):
|
||||||
|
release_expired_workspace_bans(member=request.user, workspace_slug=kwargs["slug"])
|
||||||
|
|
||||||
# Check for creator if required
|
# Check for creator if required
|
||||||
if creator and model:
|
if creator and model:
|
||||||
# check if the user is part of the workspace or not
|
# check if the user is part of the workspace or not
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||||
# Module import
|
# Module import
|
||||||
from plane.db.models import ProjectMember, WorkspaceMember
|
from plane.db.models import ProjectMember, WorkspaceMember
|
||||||
from plane.db.models.project import ROLE
|
from plane.db.models.project import ROLE
|
||||||
|
from plane.utils.workspace_bans import release_expired_workspace_bans
|
||||||
|
|
||||||
|
|
||||||
|
def release_request_workspace_ban(request, view):
|
||||||
|
if hasattr(view, "workspace_slug") and view.workspace_slug:
|
||||||
|
release_expired_workspace_bans(member=request.user, workspace_slug=view.workspace_slug)
|
||||||
|
|
||||||
|
|
||||||
class ProjectBasePermission(BasePermission):
|
class ProjectBasePermission(BasePermission):
|
||||||
|
|
@ -15,6 +21,8 @@ class ProjectBasePermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
## Safe Methods -> Handle the filtering logic in queryset
|
## Safe Methods -> Handle the filtering logic in queryset
|
||||||
if request.method in SAFE_METHODS:
|
if request.method in SAFE_METHODS:
|
||||||
return WorkspaceMember.objects.filter(
|
return WorkspaceMember.objects.filter(
|
||||||
|
|
@ -58,6 +66,8 @@ class ProjectMemberPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
## Safe Methods -> Handle the filtering logic in queryset
|
## Safe Methods -> Handle the filtering logic in queryset
|
||||||
if request.method in SAFE_METHODS:
|
if request.method in SAFE_METHODS:
|
||||||
return ProjectMember.objects.filter(
|
return ProjectMember.objects.filter(
|
||||||
|
|
@ -87,6 +97,8 @@ class ProjectEntityPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
# Handle requests based on project__identifier
|
# Handle requests based on project__identifier
|
||||||
if hasattr(view, "project_identifier") and view.project_identifier:
|
if hasattr(view, "project_identifier") and view.project_identifier:
|
||||||
if request.method in SAFE_METHODS:
|
if request.method in SAFE_METHODS:
|
||||||
|
|
@ -121,6 +133,8 @@ class ProjectAdminPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
return ProjectMember.objects.filter(
|
return ProjectMember.objects.filter(
|
||||||
workspace__slug=view.workspace_slug,
|
workspace__slug=view.workspace_slug,
|
||||||
member=request.user,
|
member=request.user,
|
||||||
|
|
@ -135,6 +149,8 @@ class ProjectLitePermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
return ProjectMember.objects.filter(
|
return ProjectMember.objects.filter(
|
||||||
workspace__slug=view.workspace_slug,
|
workspace__slug=view.workspace_slug,
|
||||||
member=request.user,
|
member=request.user,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import WorkspaceMember
|
from plane.db.models import WorkspaceMember
|
||||||
|
from plane.utils.workspace_bans import release_expired_workspace_bans
|
||||||
|
|
||||||
|
|
||||||
# Permission Mappings
|
# Permission Mappings
|
||||||
|
|
@ -15,6 +16,11 @@ Member = 15
|
||||||
Guest = 5
|
Guest = 5
|
||||||
|
|
||||||
|
|
||||||
|
def release_request_workspace_ban(request, view):
|
||||||
|
if hasattr(view, "workspace_slug") and view.workspace_slug:
|
||||||
|
release_expired_workspace_bans(member=request.user, workspace_slug=view.workspace_slug)
|
||||||
|
|
||||||
|
|
||||||
# TODO: Move the below logic to python match - python v3.10
|
# TODO: Move the below logic to python match - python v3.10
|
||||||
class WorkSpaceBasePermission(BasePermission):
|
class WorkSpaceBasePermission(BasePermission):
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
|
@ -25,6 +31,8 @@ class WorkSpaceBasePermission(BasePermission):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
## Safe Methods
|
## Safe Methods
|
||||||
if request.method in SAFE_METHODS:
|
if request.method in SAFE_METHODS:
|
||||||
return True
|
return True
|
||||||
|
|
@ -53,6 +61,8 @@ class WorkspaceOwnerPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
return WorkspaceMember.objects.filter(
|
return WorkspaceMember.objects.filter(
|
||||||
workspace__slug=view.workspace_slug, member=request.user, role=Admin
|
workspace__slug=view.workspace_slug, member=request.user, role=Admin
|
||||||
).exists()
|
).exists()
|
||||||
|
|
@ -63,6 +73,8 @@ class WorkSpaceAdminPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
return WorkspaceMember.objects.filter(
|
return WorkspaceMember.objects.filter(
|
||||||
member=request.user,
|
member=request.user,
|
||||||
workspace__slug=view.workspace_slug,
|
workspace__slug=view.workspace_slug,
|
||||||
|
|
@ -76,6 +88,8 @@ class WorkspaceEntityPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
## Safe Methods -> Handle the filtering logic in queryset
|
## Safe Methods -> Handle the filtering logic in queryset
|
||||||
if request.method in SAFE_METHODS:
|
if request.method in SAFE_METHODS:
|
||||||
return WorkspaceMember.objects.filter(
|
return WorkspaceMember.objects.filter(
|
||||||
|
|
@ -95,6 +109,8 @@ class WorkspaceViewerPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
return WorkspaceMember.objects.filter(
|
return WorkspaceMember.objects.filter(
|
||||||
member=request.user, workspace__slug=view.workspace_slug, is_active=True
|
member=request.user, workspace__slug=view.workspace_slug, is_active=True
|
||||||
).exists()
|
).exists()
|
||||||
|
|
@ -105,6 +121,8 @@ class WorkspaceUserPermission(BasePermission):
|
||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
release_request_workspace_ban(request, view)
|
||||||
|
|
||||||
return WorkspaceMember.objects.filter(
|
return WorkspaceMember.objects.filter(
|
||||||
member=request.user, workspace__slug=view.workspace_slug, is_active=True
|
member=request.user, workspace__slug=view.workspace_slug, is_active=True
|
||||||
).exists()
|
).exists()
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from plane.db.models import (
|
||||||
)
|
)
|
||||||
from plane.db.models.project import ProjectNetwork
|
from plane.db.models.project import ProjectNetwork
|
||||||
from plane.utils.host import base_host
|
from plane.utils.host import base_host
|
||||||
|
from plane.utils.workspace_bans import is_workspace_member_currently_banned, release_workspace_member_ban
|
||||||
|
|
||||||
|
|
||||||
class ProjectInvitationsViewset(BaseViewSet):
|
class ProjectInvitationsViewset(BaseViewSet):
|
||||||
|
|
@ -195,7 +196,19 @@ class ProjectJoinEndpoint(BaseAPIView):
|
||||||
)
|
)
|
||||||
|
|
||||||
if project_invite.responded_at is None:
|
if project_invite.responded_at is None:
|
||||||
project_invite.accepted = request.data.get("accepted", False)
|
accepted = request.data.get("accepted", False)
|
||||||
|
if accepted:
|
||||||
|
user = User.objects.filter(email=email).first()
|
||||||
|
workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first()
|
||||||
|
if is_workspace_member_currently_banned(workspace_member):
|
||||||
|
return Response(
|
||||||
|
{"error": "You are banned from this workspace"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
if workspace_member is not None and workspace_member.is_banned:
|
||||||
|
release_workspace_member_ban(workspace_member)
|
||||||
|
|
||||||
|
project_invite.accepted = accepted
|
||||||
project_invite.responded_at = timezone.now()
|
project_invite.responded_at = timezone.now()
|
||||||
project_invite.save()
|
project_invite.save()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ from plane.bgtasks.event_tracking_task import track_event
|
||||||
from plane.utils.url import contains_url
|
from plane.utils.url import contains_url
|
||||||
from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED
|
from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED
|
||||||
from plane.utils.csv_utils import sanitize_csv_row
|
from plane.utils.csv_utils import sanitize_csv_row
|
||||||
|
from plane.utils.workspace_bans import release_expired_workspace_bans
|
||||||
|
|
||||||
|
|
||||||
class WorkSpaceViewSet(BaseViewSet):
|
class WorkSpaceViewSet(BaseViewSet):
|
||||||
|
|
@ -207,6 +208,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||||
use_read_replica = True
|
use_read_replica = True
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
release_expired_workspace_bans(member=request.user)
|
||||||
|
|
||||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||||
member_count = (
|
member_count = (
|
||||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True)
|
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInv
|
||||||
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
|
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
|
||||||
from plane.utils.host import base_host
|
from plane.utils.host import base_host
|
||||||
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
|
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
|
||||||
|
from plane.utils.workspace_bans import is_workspace_member_currently_banned, release_workspace_member_ban
|
||||||
from .. import BaseViewSet
|
from .. import BaseViewSet
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -85,6 +86,17 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
banned_workspace_members = WorkspaceMember.objects.filter(
|
||||||
|
workspace_id=workspace.id,
|
||||||
|
member__email__in=[email.get("email") for email in emails],
|
||||||
|
is_banned=True,
|
||||||
|
).select_related("member")
|
||||||
|
if any(is_workspace_member_currently_banned(workspace_member) for workspace_member in banned_workspace_members):
|
||||||
|
return Response(
|
||||||
|
{"error": "Some users are banned from this workspace"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
workspace_invitations = []
|
workspace_invitations = []
|
||||||
for email in emails:
|
for email in emails:
|
||||||
try:
|
try:
|
||||||
|
|
@ -174,7 +186,23 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||||
|
|
||||||
# If already responded then return error
|
# If already responded then return error
|
||||||
if workspace_invite.responded_at is None:
|
if workspace_invite.responded_at is None:
|
||||||
workspace_invite.accepted = request.data.get("accepted", False)
|
accepted = request.data.get("accepted", False)
|
||||||
|
if accepted:
|
||||||
|
user = User.objects.filter(email=workspace_invite.email).first()
|
||||||
|
workspace_member = (
|
||||||
|
WorkspaceMember.objects.filter(workspace=workspace_invite.workspace, member=user).first()
|
||||||
|
if user is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if is_workspace_member_currently_banned(workspace_member):
|
||||||
|
return Response(
|
||||||
|
{"error": "You are banned from this workspace"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
if workspace_member is not None and workspace_member.is_banned:
|
||||||
|
release_workspace_member_ban(workspace_member)
|
||||||
|
|
||||||
|
workspace_invite.accepted = accepted
|
||||||
workspace_invite.responded_at = timezone.now()
|
workspace_invite.responded_at = timezone.now()
|
||||||
workspace_invite.save()
|
workspace_invite.save()
|
||||||
|
|
||||||
|
|
@ -260,6 +288,17 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||||
|
|
||||||
# If the user is already a member of workspace and was deactivated then activate the user
|
# If the user is already a member of workspace and was deactivated then activate the user
|
||||||
for invitation in workspace_invitations:
|
for invitation in workspace_invitations:
|
||||||
|
workspace_member = WorkspaceMember.objects.filter(
|
||||||
|
workspace_id=invitation.workspace_id, member=request.user
|
||||||
|
).first()
|
||||||
|
if is_workspace_member_currently_banned(workspace_member):
|
||||||
|
return Response(
|
||||||
|
{"error": "You are banned from one or more invited workspaces"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
if workspace_member is not None and workspace_member.is_banned:
|
||||||
|
release_workspace_member_ban(workspace_member)
|
||||||
|
|
||||||
invalidate_cache_directly(
|
invalidate_cache_directly(
|
||||||
path=f"/api/workspaces/{invitation.workspace.slug}/members/",
|
path=f"/api/workspaces/{invitation.workspace.slug}/members/",
|
||||||
user=False,
|
user=False,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
# See the LICENSE file for details.
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
|
from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
|
||||||
|
from plane.utils.workspace_bans import release_expired_workspace_bans
|
||||||
|
|
||||||
|
|
||||||
def get_redirection_path(user):
|
def get_redirection_path(user):
|
||||||
|
release_expired_workspace_bans(member=user)
|
||||||
|
|
||||||
# Handle redirections
|
# Handle redirections
|
||||||
profile, _ = Profile.objects.get_or_create(user=user)
|
profile, _ = Profile.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Codex on 2026-04-29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("db", "0135_workspace_feature_entitlements"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspacemember",
|
||||||
|
name="is_banned",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspacemember",
|
||||||
|
name="banned_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspacemember",
|
||||||
|
name="banned_until",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspacemember",
|
||||||
|
name="ban_project_member_ids",
|
||||||
|
field=models.JSONField(default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -210,6 +210,10 @@ class WorkspaceMember(BaseModel):
|
||||||
default_props = models.JSONField(default=get_default_props)
|
default_props = models.JSONField(default=get_default_props)
|
||||||
issue_props = models.JSONField(default=get_issue_props)
|
issue_props = models.JSONField(default=get_issue_props)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
is_banned = models.BooleanField(default=False)
|
||||||
|
banned_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
banned_until = models.DateTimeField(null=True, blank=True)
|
||||||
|
ban_project_member_ids = models.JSONField(default=list)
|
||||||
getting_started_checklist = models.JSONField(default=dict)
|
getting_started_checklist = models.JSONField(default=dict)
|
||||||
tips = models.JSONField(default=dict)
|
tips = models.JSONField(default=dict)
|
||||||
explored_features = models.JSONField(default=dict)
|
explored_features = models.JSONField(default=dict)
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,6 @@ from .workspace import (
|
||||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||||
InstanceWorkSpaceEndpoint,
|
InstanceWorkSpaceEndpoint,
|
||||||
InstanceWorkSpaceFeatureEndpoint,
|
InstanceWorkSpaceFeatureEndpoint,
|
||||||
|
InstanceWorkSpaceMemberBanEndpoint,
|
||||||
InstanceWorkSpaceMemberEndpoint,
|
InstanceWorkSpaceMemberEndpoint,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from rest_framework import status
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Count, OuterRef, Func, F, Q
|
from django.db.models import Count, OuterRef, Func, F, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.views.base import BaseAPIView
|
from plane.app.views.base import BaseAPIView
|
||||||
|
|
@ -23,6 +24,7 @@ from plane.db.models import (
|
||||||
)
|
)
|
||||||
from plane.license.api.serializers import WorkspaceSerializer
|
from plane.license.api.serializers import WorkspaceSerializer
|
||||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||||
|
from plane.utils.workspace_bans import release_expired_workspace_bans, release_workspace_member_ban
|
||||||
|
|
||||||
|
|
||||||
WORKSPACE_ROLES = {5, 15, 20}
|
WORKSPACE_ROLES = {5, 15, 20}
|
||||||
|
|
@ -46,6 +48,9 @@ def serialize_instance_workspace_member(workspace_member):
|
||||||
},
|
},
|
||||||
"role": workspace_member.role,
|
"role": workspace_member.role,
|
||||||
"is_active": workspace_member.is_active,
|
"is_active": workspace_member.is_active,
|
||||||
|
"is_banned": workspace_member.is_banned,
|
||||||
|
"banned_at": workspace_member.banned_at,
|
||||||
|
"banned_until": workspace_member.banned_until,
|
||||||
"created_at": workspace_member.created_at,
|
"created_at": workspace_member.created_at,
|
||||||
"active_project_count": getattr(workspace_member, "active_project_count", 0),
|
"active_project_count": getattr(workspace_member, "active_project_count", 0),
|
||||||
"admin_project_count": getattr(workspace_member, "admin_project_count", 0),
|
"admin_project_count": getattr(workspace_member, "admin_project_count", 0),
|
||||||
|
|
@ -57,6 +62,7 @@ def has_other_workspace_admin(workspace_member):
|
||||||
workspace=workspace_member.workspace,
|
workspace=workspace_member.workspace,
|
||||||
role=20,
|
role=20,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
|
is_banned=False,
|
||||||
member__is_bot=False,
|
member__is_bot=False,
|
||||||
).exclude(pk=workspace_member.pk).exists()
|
).exclude(pk=workspace_member.pk).exists()
|
||||||
|
|
||||||
|
|
@ -87,8 +93,10 @@ def is_only_project_admin(workspace_member):
|
||||||
|
|
||||||
|
|
||||||
def get_workspace_member_queryset(workspace_id):
|
def get_workspace_member_queryset(workspace_id):
|
||||||
|
release_expired_workspace_bans(workspace_id=workspace_id)
|
||||||
return (
|
return (
|
||||||
WorkspaceMember.objects.filter(workspace_id=workspace_id, member__is_bot=False, is_active=True)
|
WorkspaceMember.objects.filter(workspace_id=workspace_id, member__is_bot=False)
|
||||||
|
.filter(Q(is_active=True) | Q(is_banned=True))
|
||||||
.select_related("workspace", "member", "member__avatar_asset")
|
.select_related("workspace", "member", "member__avatar_asset")
|
||||||
.annotate(
|
.annotate(
|
||||||
active_project_count=Count(
|
active_project_count=Count(
|
||||||
|
|
@ -276,6 +284,77 @@ class InstanceWorkSpaceMemberEndpoint(BaseAPIView):
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceWorkSpaceMemberBanEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [InstanceAdminPermission]
|
||||||
|
|
||||||
|
def patch(self, request, workspace_id, member_id):
|
||||||
|
workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first()
|
||||||
|
if not workspace_member:
|
||||||
|
return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
is_banned = request.data.get("is_banned")
|
||||||
|
if not isinstance(is_banned, bool):
|
||||||
|
return Response({"error": "is_banned must be a boolean"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
banned_until = None
|
||||||
|
if is_banned:
|
||||||
|
raw_banned_until = request.data.get("banned_until")
|
||||||
|
if raw_banned_until not in (None, ""):
|
||||||
|
banned_until = parse_datetime(str(raw_banned_until))
|
||||||
|
if banned_until is None:
|
||||||
|
return Response({"error": "banned_until must be an ISO datetime"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if timezone.is_naive(banned_until):
|
||||||
|
banned_until = timezone.make_aware(banned_until, timezone.get_current_timezone())
|
||||||
|
if banned_until <= timezone.now():
|
||||||
|
return Response({"error": "banned_until must be in the future"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if workspace_member.role == 20 and not has_other_workspace_admin(workspace_member):
|
||||||
|
return Response(
|
||||||
|
{"error": "Cannot ban the only workspace admin"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_only_project_admin(workspace_member):
|
||||||
|
return Response(
|
||||||
|
{"error": "Cannot ban a member who is the only admin in one or more projects"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
project_member_ids = list(
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
member_id=workspace_member.member_id,
|
||||||
|
is_active=True,
|
||||||
|
).values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
member_id=workspace_member.member_id,
|
||||||
|
is_active=True,
|
||||||
|
).update(is_active=False, updated_at=timezone.now())
|
||||||
|
|
||||||
|
workspace_member.is_banned = True
|
||||||
|
workspace_member.banned_at = timezone.now()
|
||||||
|
workspace_member.banned_until = banned_until
|
||||||
|
workspace_member.ban_project_member_ids = [str(project_member_id) for project_member_id in project_member_ids]
|
||||||
|
workspace_member.is_active = False
|
||||||
|
workspace_member.save(
|
||||||
|
update_fields=[
|
||||||
|
"is_banned",
|
||||||
|
"banned_at",
|
||||||
|
"banned_until",
|
||||||
|
"ban_project_member_ids",
|
||||||
|
"is_active",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
release_workspace_member_ban(workspace_member)
|
||||||
|
|
||||||
|
workspace_member = get_workspace_member_queryset(workspace_id).get(pk=member_id)
|
||||||
|
return Response(serialize_instance_workspace_member(workspace_member), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class InstanceWorkSpaceFeatureEndpoint(BaseAPIView):
|
class InstanceWorkSpaceFeatureEndpoint(BaseAPIView):
|
||||||
permission_classes = [InstanceAdminPermission]
|
permission_classes = [InstanceAdminPermission]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from plane.license.api.views import (
|
||||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||||
InstanceWorkSpaceEndpoint,
|
InstanceWorkSpaceEndpoint,
|
||||||
InstanceWorkSpaceFeatureEndpoint,
|
InstanceWorkSpaceFeatureEndpoint,
|
||||||
|
InstanceWorkSpaceMemberBanEndpoint,
|
||||||
InstanceWorkSpaceMemberEndpoint,
|
InstanceWorkSpaceMemberEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -83,6 +84,11 @@ urlpatterns = [
|
||||||
InstanceWorkSpaceMemberEndpoint.as_view(),
|
InstanceWorkSpaceMemberEndpoint.as_view(),
|
||||||
name="instance-workspace-member",
|
name="instance-workspace-member",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<uuid:workspace_id>/members/<uuid:member_id>/ban/",
|
||||||
|
InstanceWorkSpaceMemberBanEndpoint.as_view(),
|
||||||
|
name="instance-workspace-member-ban",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<uuid:workspace_id>/features/",
|
"workspaces/<uuid:workspace_id>/features/",
|
||||||
InstanceWorkSpaceFeatureEndpoint.as_view(),
|
InstanceWorkSpaceFeatureEndpoint.as_view(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from plane.db.models import ProjectMember, WorkspaceMember
|
||||||
|
|
||||||
|
|
||||||
|
def is_workspace_member_currently_banned(workspace_member):
|
||||||
|
return bool(
|
||||||
|
workspace_member
|
||||||
|
and workspace_member.is_banned
|
||||||
|
and (workspace_member.banned_until is None or workspace_member.banned_until > timezone.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def release_workspace_member_ban(workspace_member):
|
||||||
|
project_member_ids = workspace_member.ban_project_member_ids or []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
if project_member_ids:
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
id__in=project_member_ids,
|
||||||
|
workspace_id=workspace_member.workspace_id,
|
||||||
|
member_id=workspace_member.member_id,
|
||||||
|
).update(is_active=True, updated_at=timezone.now())
|
||||||
|
|
||||||
|
workspace_member.is_active = True
|
||||||
|
workspace_member.is_banned = False
|
||||||
|
workspace_member.banned_at = None
|
||||||
|
workspace_member.banned_until = None
|
||||||
|
workspace_member.ban_project_member_ids = []
|
||||||
|
workspace_member.save(
|
||||||
|
update_fields=[
|
||||||
|
"is_active",
|
||||||
|
"is_banned",
|
||||||
|
"banned_at",
|
||||||
|
"banned_until",
|
||||||
|
"ban_project_member_ids",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def release_expired_workspace_bans(member=None, workspace_slug=None, workspace_id=None):
|
||||||
|
queryset = WorkspaceMember.objects.filter(
|
||||||
|
is_banned=True,
|
||||||
|
banned_until__isnull=False,
|
||||||
|
banned_until__lte=timezone.now(),
|
||||||
|
)
|
||||||
|
if member is not None:
|
||||||
|
queryset = queryset.filter(member=member)
|
||||||
|
if workspace_slug is not None:
|
||||||
|
queryset = queryset.filter(workspace__slug=workspace_slug)
|
||||||
|
if workspace_id is not None:
|
||||||
|
queryset = queryset.filter(workspace_id=workspace_id)
|
||||||
|
|
||||||
|
for workspace_member in queryset:
|
||||||
|
release_workspace_member_ban(workspace_member)
|
||||||
|
|
@ -103,6 +103,18 @@ export class InstanceWorkspaceService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateMemberBan(
|
||||||
|
workspaceId: string,
|
||||||
|
workspaceMemberId: string,
|
||||||
|
payload: { is_banned: boolean; banned_until?: string | null }
|
||||||
|
): Promise<TInstanceWorkspaceMember> {
|
||||||
|
return this.patch(`/api/instances/workspaces/${workspaceId}/members/${workspaceMemberId}/ban/`, payload)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async retrieveFeatures(workspaceId: string): Promise<TInstanceWorkspaceFeaturesResponse> {
|
async retrieveFeatures(workspaceId: string): Promise<TInstanceWorkspaceFeaturesResponse> {
|
||||||
return this.get(`/api/instances/workspaces/${workspaceId}/features/`)
|
return this.get(`/api/instances/workspaces/${workspaceId}/features/`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,9 @@ export interface IWorkspaceMember {
|
||||||
export type TInstanceWorkspaceMember = IWorkspaceMember & {
|
export type TInstanceWorkspaceMember = IWorkspaceMember & {
|
||||||
active_project_count: number;
|
active_project_count: number;
|
||||||
admin_project_count: number;
|
admin_project_count: number;
|
||||||
|
is_banned: boolean;
|
||||||
|
banned_at: string | null;
|
||||||
|
banned_until: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TInstanceWorkspaceFeatureKey = "voice_tasker";
|
export type TInstanceWorkspaceFeatureKey = "voice_tasker";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue