ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: администрирование воркспейсов и feature-gates в God Mode

This commit is contained in:
DCCONSTRUCTIONS
2026-04-29 00:38:11 +03:00
parent 7f47b85c36
commit 7bf416ec1f
15 changed files with 923 additions and 40 deletions
@@ -17,6 +17,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
import { WorkspaceFeaturesModal, WorkspaceMembersModal } from "@/components/workspace/admin-modals";
import { WorkspaceListItem } from "@/components/workspace/list-item";
// hooks
import { useInstance, useWorkspace } from "@/hooks/store";
@@ -26,6 +27,8 @@ import type { Route } from "./+types/page";
const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) {
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [membersWorkspaceId, setMembersWorkspaceId] = useState<string | null>(null);
const [featuresWorkspaceId, setFeaturesWorkspaceId] = useState<string | null>(null);
// store
const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance();
const {
@@ -53,14 +56,14 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving configuration",
loading: "Сохранение конфигурации",
success: {
title: "Success",
message: () => "Configuration saved successfully",
title: "Сохранено",
message: () => "Конфигурация обновлена",
},
error: {
title: "Error",
message: () => "Failed to save configuration",
title: "Ошибка",
message: () => "Не удалось сохранить конфигурацию",
},
});
@@ -77,8 +80,8 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
return (
<PageWrapper
header={{
title: "Workspaces on this instance",
description: "See all workspaces and control who can create them.",
title: "Воркспейсы инстанса",
description: "Просматривайте все рабочие пространства и управляйте правом создания новых.",
}}
>
<div className="space-y-3">
@@ -86,9 +89,9 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="pb-1 text-16 font-medium">Prevent anyone else from creating a workspace.</div>
<div className="pb-1 text-16 font-medium">Запретить пользователям создавать воркспейсы</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
Если включить, создавать рабочие пространства сможет только администратор инстанса.
</div>
</div>
</div>
@@ -119,25 +122,30 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
<div className="flex items-center justify-between gap-2 pt-6">
<div className="flex flex-col items-start gap-x-2">
<div className="flex items-center gap-2 text-16 font-medium">
All workspaces on this instance <span className="text-tertiary"> {workspaceIds.length}</span>
Все воркспейсы инстанса <span className="text-tertiary"> {workspaceIds.length}</span>
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
<LoaderIcon className="h-4 w-4 animate-spin" />
)}
</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
You can&apos;t yet delete workspaces and you can only go to the workspace if you are an Admin or a
Member.
Удаление пока недоступно. Открыть воркспейс можно только при наличии роли администратора или
участника.
</div>
</div>
<div className="flex items-center gap-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "base")}>
Create workspace
Создать воркспейс
</Link>
</div>
</div>
<div className="flex flex-col gap-4 py-2">
{workspaceIds.map((workspaceId) => (
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
<WorkspaceListItem
key={workspaceId}
workspaceId={workspaceId}
onMembersClick={setMembersWorkspaceId}
onFeaturesClick={setFeaturesWorkspaceId}
/>
))}
</div>
{hasNextPage && (
@@ -148,7 +156,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
onClick={() => fetchNextWorkspaces()}
disabled={workspaceLoader === "pagination"}
>
Load more
Загрузить еще
{workspaceLoader === "pagination" && <LoaderIcon className="h-3 w-3 animate-spin" />}
</Button>
</div>
@@ -162,11 +170,21 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
<Loader.Item height="92px" width="100%" />
</Loader>
)}
<WorkspaceMembersModal
isOpen={!!membersWorkspaceId}
workspaceId={membersWorkspaceId}
onClose={() => setMembersWorkspaceId(null)}
/>
<WorkspaceFeaturesModal
isOpen={!!featuresWorkspaceId}
workspaceId={featuresWorkspaceId}
onClose={() => setFeaturesWorkspaceId(null)}
/>
</div>
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Workspace Management - God Mode" }];
export const meta: Route.MetaFunction = () => [{ title: "Воркспейсы - NODE.DC" }];
export default WorkspaceManagementPage;
@@ -0,0 +1,318 @@
/**
* 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 { 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 { 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<number, string> = {
5: "Гость",
15: "Участник",
20: "Администратор",
};
const ACCESS_MODE_LABELS: Record<TInstanceWorkspaceFeature["access_mode"], string> = {
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 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 (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-40" onClose={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/70 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 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 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">
<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}
</span>
<div className="min-w-0">
<Dialog.Title as="h3" className="truncate text-18 font-semibold text-primary">
{title}
</Dialog.Title>
<div className="mt-1 truncate text-13 text-secondary">
{workspace ? `${workspace.name} / [${workspace.slug}]` : "Воркспейс"}
</div>
</div>
</div>
<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"
aria-label="Закрыть"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[72vh] overflow-y-auto p-5">{children}</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}
export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
const { isOpen, onClose, workspaceId } = props;
const [mutatingMemberId, setMutatingMemberId] = useState<string | null>(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 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: "Доступ к воркспейсу отключен" });
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось отключить участника",
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
});
} finally {
setMutatingMemberId(null);
}
};
return (
<WorkspaceModalShell {...props} title="Участники воркспейса" icon={<UsersRound className="h-5 w-5" />}>
{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>Пользователь</div>
<div>Роль</div>
<div>Проекты</div>
<div>Админ проектов</div>
<div className="text-right">Доступ</div>
</div>
<div className="divide-y divide-subtle">
{(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"
>
<div className="min-w-0">
<div className="truncate font-medium text-primary">{getMemberName(workspaceMember)}</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}
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>
<div className="text-secondary">{workspaceMember.active_project_count}</div>
<div className="text-secondary">{workspaceMember.admin_project_count}</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => handleRemove(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)}`}
>
{mutatingMemberId === workspaceMember.id ? (
<Loader className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Отключить
</button>
</div>
</div>
))}
</div>
{(data ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Активных участников нет</div>}
</div>
)}
</WorkspaceModalShell>
);
}
export function WorkspaceFeaturesModal(props: TWorkspaceAdminModalProps) {
const { isOpen, workspaceId } = props;
const [mutatingFeatureKey, setMutatingFeatureKey] = useState<string | null>(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 (
<WorkspaceModalShell {...props} title="Функции воркспейса" icon={<ShieldCheck className="h-5 w-5" />}>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-secondary">
<Loader className="h-5 w-5 animate-spin" />
</div>
) : (
<div className="space-y-3">
{(data?.features ?? []).map((feature) => (
<div key={feature.key} className="nodedc-settings-card flex items-center justify-between gap-5 p-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-15 font-semibold text-primary">{feature.title}</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-11 font-medium",
feature.is_enabled ? "bg-accent-primary text-on-color" : "bg-layer-2 text-tertiary"
)}
>
{feature.is_enabled ? "Доступ выдан" : "Не выдано"}
</span>
</div>
<div className="mt-1 text-13 text-secondary">{feature.description}</div>
<div className="mt-3 flex flex-wrap gap-2 text-11 text-tertiary">
<span className="rounded-full bg-layer-2 px-2 py-1">
Workspace: {feature.workspace_setting_enabled ? "включено" : "выключено"}
</span>
<span className="rounded-full bg-layer-2 px-2 py-1">Доступ: {ACCESS_MODE_LABELS[feature.access_mode]}</span>
<span className="rounded-full bg-layer-2 px-2 py-1">
OpenAI key: {feature.has_workspace_key ? "есть" : "нет"}
</span>
</div>
</div>
<div className="shrink-0">
<ToggleSwitch
value={feature.is_enabled}
onChange={() => handleToggle(feature)}
size="sm"
disabled={mutatingFeatureKey === feature.key}
/>
</div>
</div>
))}
{(data?.features ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Функции не найдены</div>}
</div>
)}
<div className="mt-5 flex justify-end">
<Button variant="secondary" size="lg" onClick={props.onClose} className="nodedc-settings-secondary-button">
Закрыть
</Button>
</div>
</WorkspaceModalShell>
);
}
@@ -5,20 +5,26 @@
*/
import { observer } from "mobx-react";
import { ExternalLink, Sparkles, UsersRound } from "lucide-react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
import { NewTabIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { getFileURL } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store";
type TWorkspaceListItemProps = {
onFeaturesClick: (workspaceId: string) => void;
onMembersClick: (workspaceId: string) => void;
workspaceId: string;
};
export const WorkspaceListItem = observer(function WorkspaceListItem({ workspaceId }: TWorkspaceListItemProps) {
export const WorkspaceListItem = observer(function WorkspaceListItem({
onFeaturesClick,
onMembersClick,
workspaceId,
}: TWorkspaceListItemProps) {
// store hooks
const { getWorkspaceById } = useWorkspace();
// derived values
@@ -26,14 +32,11 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
if (!workspace) return null;
return (
<a
<div
key={workspaceId}
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
target="_blank"
className="group flex items-center justify-between gap-2.5 truncate rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
rel="noreferrer"
className="nodedc-settings-card group flex items-center justify-between gap-3 rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
>
<div className="flex items-start gap-4">
<div className="flex min-w-0 items-start gap-4">
<span
className={`relative mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
!workspace?.logo_url && "rounded-lg bg-accent-primary text-on-color"
@@ -43,29 +46,29 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
<img
src={getFileURL(workspace.logo_url)}
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
alt="Workspace Logo"
alt="Логотип воркспейса"
/>
) : (
(workspace?.name?.[0] ?? "...")
)}
</span>
<div className="flex flex-col items-start gap-1">
<div className="flex min-w-0 flex-col items-start gap-1">
<div className="flex w-full flex-wrap items-center gap-2.5">
<h3 className={`text-14 font-medium capitalize`}>{workspace.name}</h3>/
<Tooltip tooltipContent="The unique URL of your workspace">
<h3 className={`truncate text-14 font-medium capitalize`}>{workspace.name}</h3>/
<Tooltip tooltipContent="Уникальный URL воркспейса">
<h4 className="text-13 text-tertiary">[{workspace.slug}]</h4>
</Tooltip>
</div>
{workspace.owner.email && (
<div className="flex items-center gap-1 text-11">
<h3 className="font-medium text-secondary">Owned by:</h3>
<h3 className="font-medium text-secondary">Владелец:</h3>
<h4 className="text-tertiary">{workspace.owner.email}</h4>
</div>
)}
<div className="flex items-center gap-2.5 text-11">
{workspace.total_projects !== null && (
<span className="flex items-center gap-1">
<h3 className="font-medium text-secondary">Total projects:</h3>
<h3 className="font-medium text-secondary">Проектов:</h3>
<h4 className="text-tertiary">{workspace.total_projects}</h4>
</span>
)}
@@ -73,7 +76,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
<>
<span className="flex items-center gap-1">
<h3 className="font-medium text-secondary">Total members:</h3>
<h3 className="font-medium text-secondary">Участников:</h3>
<h4 className="text-tertiary">{workspace.total_members}</h4>
</span>
</>
@@ -81,9 +84,39 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
</div>
</div>
</div>
<div className="flex-shrink-0">
<NewTabIcon width={14} height={16} className="text-placeholder group-hover:text-secondary" />
<div className="flex shrink-0 items-center gap-1.5">
<Tooltip tooltipContent="Доступный функционал">
<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"
aria-label="Доступный функционал"
>
<Sparkles className="size-4" />
</button>
</Tooltip>
<Tooltip tooltipContent="Участники воркспейса">
<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"
aria-label="Участники воркспейса"
>
<UsersRound className="size-4" />
</button>
</Tooltip>
<Tooltip tooltipContent="Открыть воркспейс">
<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"
rel="noreferrer"
aria-label="Открыть воркспейс"
>
<ExternalLink className="size-4" />
</a>
</Tooltip>
</div>
</a>
</div>
);
});