ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: блокировка и удаление участников воркспейса в God Mode
This commit is contained in:
@@ -8,13 +8,13 @@ import { Fragment, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
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
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { TInstanceWorkspaceFeature, TInstanceWorkspaceMember } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { CustomSelect, ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
@@ -39,6 +39,13 @@ const ROLE_LABELS: Record<number, string> = {
|
||||
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> = {
|
||||
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 (
|
||||
<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 }) {
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-subtle p-5">
|
||||
<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 p-5 pr-16">
|
||||
<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">
|
||||
{icon}
|
||||
@@ -104,10 +267,10 @@ function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: Reac
|
||||
<button
|
||||
type="button"
|
||||
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="Закрыть"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-5 w-5 stroke-[2.2]" />
|
||||
</button>
|
||||
</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) {
|
||||
const { isOpen, onClose, workspaceId } = props;
|
||||
const { isOpen, workspaceId } = props;
|
||||
const [banDuration, setBanDuration] = useState("7d");
|
||||
const [mutatingMemberId, setMutatingMemberId] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<TPendingMemberAction | null>(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 (
|
||||
<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 ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-secondary">
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
@@ -186,41 +389,56 @@ export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
|
||||
{(data ?? []).map((workspaceMember) => (
|
||||
<div
|
||||
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="truncate font-medium text-primary">{getMemberName(workspaceMember)}</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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>
|
||||
<select
|
||||
className="nodedc-settings-select h-9 min-h-9 w-full px-3 text-12"
|
||||
value={workspaceMember.role}
|
||||
<RoleSelect
|
||||
value={Number(workspaceMember.role)}
|
||||
disabled={mutatingMemberId === workspaceMember.id}
|
||||
onChange={(event) => handleRoleChange(workspaceMember.id, Number(event.target.value))}
|
||||
aria-label={`Роль: ${getMemberName(workspaceMember)}`}
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(role) => handleRoleChange(workspaceMember.id, role)}
|
||||
/>
|
||||
<div className="text-secondary">{workspaceMember.active_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
|
||||
type="button"
|
||||
onClick={() => handleRemove(workspaceMember)}
|
||||
onClick={() => setPendingAction({ type: workspaceMember.is_banned ? "unban" : "ban", workspaceMember })}
|
||||
disabled={mutatingMemberId === workspaceMember.id}
|
||||
className="nodedc-settings-secondary-button flex h-9 min-h-9 items-center gap-2 px-3 text-12"
|
||||
aria-label={`Отключить доступ: ${getMemberName(workspaceMember)}`}
|
||||
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={`${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 ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Отключить
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||
<button
|
||||
type="button"
|
||||
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="Доступный функционал"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
@@ -99,7 +99,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||
<button
|
||||
type="button"
|
||||
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="Участники воркспейса"
|
||||
>
|
||||
<UsersRound className="size-4" />
|
||||
@@ -109,7 +109,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||
<a
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
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"
|
||||
aria-label="Открыть воркспейс"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user