ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: администрирование воркспейсов и 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

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
});

View File

@ -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,

View File

@ -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",
),
),
]

View File

@ -69,6 +69,7 @@ from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISe
from .workspace import (
Workspace,
WorkspaceBaseModel,
WorkspaceFeatureEntitlement,
WorkspaceMember,
WorkspaceMemberInvite,
WorkspaceTheme,

View File

@ -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)

View File

@ -25,4 +25,6 @@ from .admin import (
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
InstanceWorkSpaceFeatureEndpoint,
InstanceWorkSpaceMemberEndpoint,
)

View File

@ -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)

View File

@ -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",
),
]

View File

@ -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)
);

View File

@ -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)
);

View File

@ -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;
});
}
}

View File

@ -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;

View File

@ -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;