ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: администрирование воркспейсов и feature-gates в God Mode
This commit is contained in:
parent
7f47b85c36
commit
7bf416ec1f
|
|
@ -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'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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from plane.db.models import (
|
|||
Workspace,
|
||||
WorkspaceAICredential,
|
||||
WorkspaceAISettings,
|
||||
WorkspaceFeatureEntitlement,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.license.utils.encryption import decrypt_data
|
||||
|
|
@ -288,10 +289,12 @@ def get_request_project_id(request):
|
|||
def get_voice_task_preflight(workspace, user, project_id=None):
|
||||
ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first()
|
||||
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first()
|
||||
entitlement_enabled = is_voice_tasker_entitled(workspace)
|
||||
|
||||
response = {
|
||||
"available": False,
|
||||
"reason": "not_configured",
|
||||
"entitlement_enabled": entitlement_enabled,
|
||||
"max_audio_duration_seconds": 120,
|
||||
"accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES,
|
||||
"access_mode": "all_workspace_members",
|
||||
|
|
@ -299,6 +302,10 @@ def get_voice_task_preflight(workspace, user, project_id=None):
|
|||
"enabled_member_ids": [],
|
||||
}
|
||||
|
||||
if not entitlement_enabled:
|
||||
response["reason"] = "disabled"
|
||||
return response
|
||||
|
||||
if not ai_settings:
|
||||
return response
|
||||
|
||||
|
|
@ -343,6 +350,14 @@ def get_voice_task_preflight(workspace, user, project_id=None):
|
|||
return response
|
||||
|
||||
|
||||
def is_voice_tasker_entitled(workspace):
|
||||
return WorkspaceFeatureEntitlement.objects.filter(
|
||||
workspace=workspace,
|
||||
feature_key=WorkspaceFeatureEntitlement.FeatureKey.VOICE_TASKER,
|
||||
is_enabled=True,
|
||||
).exists()
|
||||
|
||||
|
||||
def get_voice_task_quota_project(workspace, project_id=None, ai_settings=None):
|
||||
if project_id:
|
||||
project = Project.objects.filter(
|
||||
|
|
@ -3390,11 +3405,23 @@ class WorkspaceAISettingsEndpoint(BaseAPIView):
|
|||
def get(self, request, slug):
|
||||
workspace, ai_settings = self.get_settings(slug)
|
||||
serializer = WorkspaceAISettingsSerializer(ai_settings, context={"workspace": workspace})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
data = serializer.data
|
||||
data["feature_entitlement_enabled"] = is_voice_tasker_entitled(workspace)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def patch(self, request, slug):
|
||||
workspace, ai_settings = self.get_settings(slug)
|
||||
requested_enabled = request.data.get("voice_tasker_enabled")
|
||||
if requested_enabled in [True, "true", "True", "1", 1] and not is_voice_tasker_entitled(workspace):
|
||||
return Response(
|
||||
{
|
||||
"error": "Voice Tasker is not enabled for this workspace by the instance administrator.",
|
||||
"code": "feature_not_entitled",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = WorkspaceAISettingsSerializer(
|
||||
ai_settings,
|
||||
data=request.data,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
# Generated by Codex on 2026-04-28
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("db", "0134_voice_tasker_retention"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WorkspaceFeatureEntitlement",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
|
||||
),
|
||||
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"feature_key",
|
||||
models.CharField(
|
||||
choices=[("voice_tasker", "AI / Voice Tasker")],
|
||||
max_length=80,
|
||||
),
|
||||
),
|
||||
("is_enabled", models.BooleanField(default=False)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="feature_entitlements",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Workspace Feature Entitlement",
|
||||
"verbose_name_plural": "Workspace Feature Entitlements",
|
||||
"db_table": "workspace_feature_entitlements",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="workspacefeatureentitlement",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("workspace", "feature_key"),
|
||||
name="workspace_feature_entitlement_unique_active_feature",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -69,6 +69,7 @@ from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISe
|
|||
from .workspace import (
|
||||
Workspace,
|
||||
WorkspaceBaseModel,
|
||||
WorkspaceFeatureEntitlement,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
|
|
|
|||
|
|
@ -260,6 +260,36 @@ class WorkspaceMemberInvite(BaseModel):
|
|||
return f"{self.workspace.name} {self.email} {self.accepted}"
|
||||
|
||||
|
||||
class WorkspaceFeatureEntitlement(BaseModel):
|
||||
class FeatureKey(models.TextChoices):
|
||||
VOICE_TASKER = "voice_tasker", "AI / Voice Tasker"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="feature_entitlements",
|
||||
)
|
||||
feature_key = models.CharField(max_length=80, choices=FeatureKey.choices)
|
||||
is_enabled = models.BooleanField(default=False)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "feature_key"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_feature_entitlement_unique_active_feature",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace Feature Entitlement"
|
||||
verbose_name_plural = "Workspace Feature Entitlements"
|
||||
db_table = "workspace_feature_entitlements"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.slug} <{self.feature_key}>"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Team Name")
|
||||
description = models.TextField(verbose_name="Team Description", blank=True)
|
||||
|
|
|
|||
|
|
@ -25,4 +25,6 @@ from .admin import (
|
|||
from .workspace import (
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
InstanceWorkSpaceFeatureEndpoint,
|
||||
InstanceWorkSpaceMemberEndpoint,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,16 +6,109 @@
|
|||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import OuterRef, Func, F
|
||||
from django.db.models import Count, OuterRef, Func, F, Q
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.license.api.permissions import InstanceAdminPermission
|
||||
from plane.db.models import Workspace, WorkspaceMember, Project
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
WorkspaceAICredential,
|
||||
WorkspaceAISettings,
|
||||
WorkspaceFeatureEntitlement,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.license.api.serializers import WorkspaceSerializer
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
|
||||
|
||||
WORKSPACE_ROLES = {5, 15, 20}
|
||||
VOICE_TASKER_FEATURE = WorkspaceFeatureEntitlement.FeatureKey.VOICE_TASKER
|
||||
|
||||
|
||||
def serialize_instance_workspace_member(workspace_member):
|
||||
member = workspace_member.member
|
||||
return {
|
||||
"id": str(workspace_member.id),
|
||||
"workspace": str(workspace_member.workspace_id),
|
||||
"member": {
|
||||
"id": str(member.id),
|
||||
"email": member.email,
|
||||
"display_name": member.display_name,
|
||||
"first_name": member.first_name,
|
||||
"last_name": member.last_name,
|
||||
"avatar_url": member.avatar_url,
|
||||
"is_bot": member.is_bot,
|
||||
"is_active": member.is_active,
|
||||
},
|
||||
"role": workspace_member.role,
|
||||
"is_active": workspace_member.is_active,
|
||||
"created_at": workspace_member.created_at,
|
||||
"active_project_count": getattr(workspace_member, "active_project_count", 0),
|
||||
"admin_project_count": getattr(workspace_member, "admin_project_count", 0),
|
||||
}
|
||||
|
||||
|
||||
def has_other_workspace_admin(workspace_member):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace=workspace_member.workspace,
|
||||
role=20,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
).exclude(pk=workspace_member.pk).exists()
|
||||
|
||||
|
||||
def is_only_project_admin(workspace_member):
|
||||
return (
|
||||
Project.objects.filter(
|
||||
workspace=workspace_member.workspace,
|
||||
archived_at__isnull=True,
|
||||
project_projectmember__member_id=workspace_member.member_id,
|
||||
project_projectmember__role=20,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
.annotate(
|
||||
other_admin_count=Count(
|
||||
"project_projectmember",
|
||||
filter=(
|
||||
Q(project_projectmember__role=20)
|
||||
& Q(project_projectmember__is_active=True)
|
||||
& ~Q(project_projectmember__member_id=workspace_member.member_id)
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.filter(other_admin_count=0)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def get_workspace_member_queryset(workspace_id):
|
||||
return (
|
||||
WorkspaceMember.objects.filter(workspace_id=workspace_id, member__is_bot=False, is_active=True)
|
||||
.select_related("workspace", "member", "member__avatar_asset")
|
||||
.annotate(
|
||||
active_project_count=Count(
|
||||
"member__member_project",
|
||||
filter=Q(member__member_project__workspace_id=workspace_id)
|
||||
& Q(member__member_project__is_active=True),
|
||||
distinct=True,
|
||||
),
|
||||
admin_project_count=Count(
|
||||
"member__member_project",
|
||||
filter=Q(member__member_project__workspace_id=workspace_id)
|
||||
& Q(member__member_project__is_active=True)
|
||||
& Q(member__member_project__role=20),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
.order_by("member__display_name", "member__email")
|
||||
)
|
||||
|
||||
|
||||
class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
|
||||
permission_classes = [InstanceAdminPermission]
|
||||
|
||||
|
|
@ -108,3 +201,131 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
|
|||
{"slug": "The workspace with the slug already exists"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
|
||||
class InstanceWorkSpaceMemberEndpoint(BaseAPIView):
|
||||
permission_classes = [InstanceAdminPermission]
|
||||
|
||||
def get(self, request, workspace_id):
|
||||
if not Workspace.objects.filter(id=workspace_id).exists():
|
||||
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
workspace_members = get_workspace_member_queryset(workspace_id)
|
||||
return Response(
|
||||
[serialize_instance_workspace_member(workspace_member) for workspace_member in workspace_members],
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
role = int(request.data.get("role"))
|
||||
except (TypeError, ValueError):
|
||||
return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if role not in WORKSPACE_ROLES:
|
||||
return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if workspace_member.role == 20 and role != 20 and not has_other_workspace_admin(workspace_member):
|
||||
return Response(
|
||||
{"error": "Cannot demote the only workspace admin"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace_member.role = role
|
||||
workspace_member.save()
|
||||
|
||||
if role == 5:
|
||||
ProjectMember.objects.filter(
|
||||
workspace_id=workspace_id,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).update(role=5, updated_at=timezone.now())
|
||||
|
||||
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)
|
||||
|
||||
def delete(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)
|
||||
|
||||
if workspace_member.role == 20 and not has_other_workspace_admin(workspace_member):
|
||||
return Response(
|
||||
{"error": "Cannot remove the only workspace admin"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if is_only_project_admin(workspace_member):
|
||||
return Response(
|
||||
{"error": "Cannot remove a member who is the only admin in one or more projects"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
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_active = False
|
||||
workspace_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class InstanceWorkSpaceFeatureEndpoint(BaseAPIView):
|
||||
permission_classes = [InstanceAdminPermission]
|
||||
|
||||
def serialize_voice_tasker_feature(self, workspace):
|
||||
entitlement = WorkspaceFeatureEntitlement.objects.filter(
|
||||
workspace=workspace,
|
||||
feature_key=VOICE_TASKER_FEATURE,
|
||||
).first()
|
||||
ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first()
|
||||
credential = WorkspaceAICredential.objects.filter(
|
||||
workspace=workspace,
|
||||
provider=WorkspaceAICredential.Provider.OPENAI,
|
||||
is_active=True,
|
||||
).first()
|
||||
|
||||
return {
|
||||
"key": VOICE_TASKER_FEATURE,
|
||||
"title": "AI / Voice Tasker",
|
||||
"description": "Голосовая постановка задач и встроенные AI-сценарии воркспейса.",
|
||||
"is_enabled": bool(entitlement and entitlement.is_enabled),
|
||||
"workspace_setting_enabled": bool(ai_settings and ai_settings.voice_tasker_enabled),
|
||||
"access_mode": ai_settings.access_mode if ai_settings else WorkspaceAISettings.AccessMode.ALL_WORKSPACE_MEMBERS,
|
||||
"has_workspace_key": bool(credential and credential.encrypted_api_key),
|
||||
}
|
||||
|
||||
def get(self, request, workspace_id):
|
||||
workspace = Workspace.objects.filter(id=workspace_id).first()
|
||||
if not workspace:
|
||||
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK)
|
||||
|
||||
def patch(self, request, workspace_id):
|
||||
workspace = Workspace.objects.filter(id=workspace_id).first()
|
||||
if not workspace:
|
||||
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
feature_key = request.data.get("feature_key")
|
||||
if feature_key != VOICE_TASKER_FEATURE:
|
||||
return Response({"error": "Unsupported feature key"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
is_enabled = request.data.get("is_enabled")
|
||||
if not isinstance(is_enabled, bool):
|
||||
return Response({"error": "is_enabled must be a boolean"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
entitlement, _ = WorkspaceFeatureEntitlement.objects.get_or_create(
|
||||
workspace=workspace,
|
||||
feature_key=feature_key,
|
||||
)
|
||||
entitlement.is_enabled = is_enabled
|
||||
entitlement.save()
|
||||
|
||||
return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from plane.license.api.views import (
|
|||
InstanceAdminUserSessionEndpoint,
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
InstanceWorkSpaceFeatureEndpoint,
|
||||
InstanceWorkSpaceMemberEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -71,4 +73,19 @@ urlpatterns = [
|
|||
name="instance-workspace-availability",
|
||||
),
|
||||
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
|
||||
path(
|
||||
"workspaces/<uuid:workspace_id>/members/",
|
||||
InstanceWorkSpaceMemberEndpoint.as_view(),
|
||||
name="instance-workspace-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<uuid:workspace_id>/members/<uuid:member_id>/",
|
||||
InstanceWorkSpaceMemberEndpoint.as_view(),
|
||||
name="instance-workspace-member",
|
||||
),
|
||||
path(
|
||||
"workspaces/<uuid:workspace_id>/features/",
|
||||
InstanceWorkSpaceFeatureEndpoint.as_view(),
|
||||
name="instance-workspace-features",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,18 +7,24 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useParams } from "react-router";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, WORKSPACE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, WORKSPACE_SETTINGS, WORKSPACE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TWorkspaceSettingsTabs } from "@plane/types";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// services
|
||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
||||
// local imports
|
||||
import { WORKSPACE_SETTINGS_ICONS } from "./item-icon";
|
||||
|
||||
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set(["billing-and-plans"]);
|
||||
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
|
||||
const workspaceAIService = new WorkspaceAIService();
|
||||
|
||||
export const WorkspaceSettingsSidebarItemCategories = observer(function WorkspaceSettingsSidebarItemCategories() {
|
||||
// params
|
||||
|
|
@ -28,6 +34,15 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac
|
|||
const { allowPermissions } = useUserPermissions();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// feature gates
|
||||
const canLoadVoiceTaskerEntitlement =
|
||||
!!workspaceSlug &&
|
||||
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, workspaceSlug);
|
||||
const { data: aiSettings } = useSWR(
|
||||
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null,
|
||||
() => workspaceAIService.retrieveSettings(workspaceSlug as string)
|
||||
);
|
||||
const isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col divide-y divide-white/6">
|
||||
|
|
@ -36,6 +51,7 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac
|
|||
const accessibleItems = categoryItems.filter(
|
||||
(item) =>
|
||||
!HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) &&
|
||||
(!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) || isVoiceTaskerEntitled) &&
|
||||
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissionsLevel,
|
||||
|
|
@ -32,6 +33,8 @@ import { WorkspaceDetails } from "@/components/workspace/settings/workspace-deta
|
|||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// services
|
||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
||||
// local imports
|
||||
import {
|
||||
closeWorkspaceSettingsModal,
|
||||
|
|
@ -43,7 +46,9 @@ import {
|
|||
} from "./workspace-settings-modal.utils";
|
||||
|
||||
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
|
||||
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
|
||||
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
|
||||
const workspaceAIService = new WorkspaceAIService();
|
||||
|
||||
const getInitialTab = (): TWorkspaceSettingsModalTab => {
|
||||
if (typeof window === "undefined") return "general";
|
||||
|
|
@ -67,6 +72,14 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
const canLoadVoiceTaskerEntitlement =
|
||||
!!currentWorkspace?.slug &&
|
||||
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug);
|
||||
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
|
||||
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
|
||||
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
|
||||
);
|
||||
const isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true;
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromLocation = () => {
|
||||
|
|
@ -97,6 +110,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeTab !== "ai-voice-tasker" || isVoiceTaskerEntitlementLoading) return;
|
||||
if (!isVoiceTaskerEntitled) openWorkspaceSettingsModal("general", true);
|
||||
}, [activeTab, isOpen, isVoiceTaskerEntitlementLoading, isVoiceTaskerEntitled]);
|
||||
|
||||
const handleClose = () => {
|
||||
closeWorkspaceSettingsModal();
|
||||
};
|
||||
|
|
@ -114,6 +132,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
|
||||
const renderContent = () => {
|
||||
if (activeTab === "ai-voice-tasker" && currentWorkspace?.slug) {
|
||||
if (!isVoiceTaskerEntitled) return <WorkspaceDetails />;
|
||||
return <AIVoiceTaskerSettingsContent workspaceSlug={currentWorkspace.slug} />;
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +174,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
activeTab={activeTab}
|
||||
onSelectItem={handleSelectItem}
|
||||
allowPermissions={allowPermissions}
|
||||
isVoiceTaskerEntitled={isVoiceTaskerEntitled}
|
||||
workspaceSlug={currentWorkspace?.slug}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -189,11 +209,18 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
type TWorkspaceModalSidebarProps = {
|
||||
activeTab: TWorkspaceSettingsModalTab;
|
||||
allowPermissions: ReturnType<typeof useUserPermissions>["allowPermissions"];
|
||||
isVoiceTaskerEntitled: boolean;
|
||||
onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void;
|
||||
workspaceSlug?: string;
|
||||
};
|
||||
|
||||
function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, workspaceSlug }: TWorkspaceModalSidebarProps) {
|
||||
function WorkspaceModalSidebar({
|
||||
activeTab,
|
||||
allowPermissions,
|
||||
isVoiceTaskerEntitled,
|
||||
onSelectItem,
|
||||
workspaceSlug,
|
||||
}: TWorkspaceModalSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
|
@ -208,6 +235,7 @@ function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, work
|
|||
const accessibleItems = GROUPED_WORKSPACE_SETTINGS[category].filter(
|
||||
(item) =>
|
||||
!HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) &&
|
||||
(!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) || isVoiceTaskerEntitled) &&
|
||||
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@
|
|||
*/
|
||||
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
|
||||
import type {
|
||||
IWorkspace,
|
||||
TInstanceWorkspaceFeatureKey,
|
||||
TInstanceWorkspaceFeaturesResponse,
|
||||
TInstanceWorkspaceMember,
|
||||
TWorkspacePaginationInfo,
|
||||
} from "@plane/types";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
|
|
@ -68,4 +74,55 @@ export class InstanceWorkspaceService extends APIService {
|
|||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async listMembers(workspaceId: string): Promise<TInstanceWorkspaceMember[]> {
|
||||
return this.get(`/api/instances/workspaces/${workspaceId}/members/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateMemberRole(
|
||||
workspaceId: string,
|
||||
workspaceMemberId: string,
|
||||
role: TInstanceWorkspaceMember["role"]
|
||||
): Promise<TInstanceWorkspaceMember> {
|
||||
return this.patch(`/api/instances/workspaces/${workspaceId}/members/${workspaceMemberId}/`, { role })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(workspaceId: string, workspaceMemberId: string): Promise<void> {
|
||||
return this.delete(`/api/instances/workspaces/${workspaceId}/members/${workspaceMemberId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async retrieveFeatures(workspaceId: string): Promise<TInstanceWorkspaceFeaturesResponse> {
|
||||
return this.get(`/api/instances/workspaces/${workspaceId}/features/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateFeature(
|
||||
workspaceId: string,
|
||||
featureKey: TInstanceWorkspaceFeatureKey,
|
||||
isEnabled: boolean
|
||||
): Promise<TInstanceWorkspaceFeaturesResponse> {
|
||||
return this.patch(`/api/instances/workspaces/${workspaceId}/features/`, {
|
||||
feature_key: featureKey,
|
||||
is_enabled: isEnabled,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type TWorkspaceAICredentialStatus = {
|
|||
export type TWorkspaceAISettings = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
feature_entitlement_enabled: boolean;
|
||||
voice_tasker_enabled: boolean;
|
||||
provider: TWorkspaceAIProvider;
|
||||
transcription_model: string;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,27 @@ export interface IWorkspaceMember {
|
|||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export type TInstanceWorkspaceMember = IWorkspaceMember & {
|
||||
active_project_count: number;
|
||||
admin_project_count: number;
|
||||
};
|
||||
|
||||
export type TInstanceWorkspaceFeatureKey = "voice_tasker";
|
||||
|
||||
export type TInstanceWorkspaceFeature = {
|
||||
key: TInstanceWorkspaceFeatureKey;
|
||||
title: string;
|
||||
description: string;
|
||||
is_enabled: boolean;
|
||||
workspace_setting_enabled: boolean;
|
||||
access_mode: "all_workspace_members" | "admins_only" | "selected_projects" | "selected_members";
|
||||
has_workspace_key: boolean;
|
||||
};
|
||||
|
||||
export type TInstanceWorkspaceFeaturesResponse = {
|
||||
features: TInstanceWorkspaceFeature[];
|
||||
};
|
||||
|
||||
export interface IWorkspaceMemberMe {
|
||||
company_role: string | null;
|
||||
created_at: Date;
|
||||
|
|
|
|||
Loading…
Reference in New Issue