/** * Copyright (c) 2023-present Plane Software, Inc. and contributors * SPDX-License-Identifier: AGPL-3.0-only * See the LICENSE file for details. */ import { Fragment, useState } from "react"; import type { ReactNode } from "react"; import { Dialog, Transition } from "@headlessui/react"; import useSWR from "swr"; 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 { CustomSelect, ToggleSwitch } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store"; type TWorkspaceAdminModalProps = { isOpen: boolean; onClose: () => void; workspaceId: string | null; }; const instanceWorkspaceService = new InstanceWorkspaceService(); const ROLE_OPTIONS = [ { label: "Гость", value: 5 }, { label: "Участник", value: 15 }, { label: "Администратор", value: 20 }, ]; const ROLE_LABELS: Record = { 5: "Гость", 15: "Участник", 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: "Только админы", selected_projects: "По контурам", selected_members: "По людям", }; function getMemberName(member: TInstanceWorkspaceMember) { return member.member.display_name || member.member.email || "Пользователь"; } function getErrorMessage(error: unknown, fallback: string) { if (error && typeof error === "object" && "error" in error && typeof error.error === "string") return error.error; 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(); const workspace = workspaceId ? getWorkspaceById(workspaceId) : undefined; return (
{icon}
{title}
{workspace ? `${workspace.name} / [${workspace.slug}]` : "Воркспейс"}
{children}
); } export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) { 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) ); const handleRoleChange = async (workspaceMemberId: string, role: number) => { if (!workspaceId) return; setMutatingMemberId(workspaceMemberId); try { await instanceWorkspaceService.updateMemberRole(workspaceId, workspaceMemberId, role); await mutate(); setToast({ type: TOAST_TYPE.SUCCESS, title: "Роль участника обновлена" }); } catch (error) { setToast({ type: TOAST_TYPE.ERROR, title: "Не удалось обновить роль", message: getErrorMessage(error, "Проверьте ограничения по администраторам воркспейса и проектов."), }); } finally { setMutatingMemberId(null); } }; 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; setMutatingMemberId(workspaceMember.id); try { await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id); await mutate(); setToast({ type: TOAST_TYPE.SUCCESS, title: "Участник удален из воркспейса" }); } catch (error) { setToast({ type: TOAST_TYPE.ERROR, 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 ? (
) : (
Пользователь
Роль
Проекты
Админ проектов
Доступ
{(data ?? []).map((workspaceMember) => (
{getMemberName(workspaceMember)}
{workspaceMember.is_banned && ( Блокировка {formatBanUntil(workspaceMember.banned_until)} )}
{workspaceMember.member.email}
handleRoleChange(workspaceMember.id, role)} />
{workspaceMember.active_project_count}
{workspaceMember.admin_project_count}
))}
{(data ?? []).length === 0 &&
Активных участников нет
}
)}
); } export function WorkspaceFeaturesModal(props: TWorkspaceAdminModalProps) { const { isOpen, workspaceId } = props; const [mutatingFeatureKey, setMutatingFeatureKey] = useState(null); const { data, isLoading, mutate } = useSWR( isOpen && workspaceId ? ["INSTANCE_WORKSPACE_FEATURES", workspaceId] : null, () => instanceWorkspaceService.retrieveFeatures(workspaceId as string) ); const handleToggle = async (feature: TInstanceWorkspaceFeature) => { if (!workspaceId) return; setMutatingFeatureKey(feature.key); try { await instanceWorkspaceService.updateFeature(workspaceId, feature.key, !feature.is_enabled); await mutate(); setToast({ type: TOAST_TYPE.SUCCESS, title: feature.is_enabled ? "Фича отключена для воркспейса" : "Фича выдана воркспейсу", }); } catch (error) { setToast({ type: TOAST_TYPE.ERROR, title: "Не удалось обновить доступ", message: getErrorMessage(error, "Попробуйте еще раз."), }); } finally { setMutatingFeatureKey(null); } }; return ( }> {isLoading ? (
) : (
{(data?.features ?? []).map((feature) => (
{feature.title}
{feature.is_enabled ? "Доступ выдан" : "Не выдано"}
{feature.description}
Workspace: {feature.workspace_setting_enabled ? "включено" : "выключено"} Доступ: {ACCESS_MODE_LABELS[feature.access_mode]} OpenAI key: {feature.has_workspace_key ? "есть" : "нет"}
handleToggle(feature)} size="sm" disabled={mutatingFeatureKey === feature.key} />
))} {(data?.features ?? []).length === 0 &&
Функции не найдены
}
)}
); }