From 6b002ec1766453cb4a1d042ef57fd4922ddb91a5 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 6 May 2026 09:38:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20policy=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20workspace=20=D0=B2=20Launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/control-plane-store.mjs | 17 +++++++- server/dev-server.mjs | 40 +++++++++++++++++ src/shared/api/mockApi.ts | 22 ++++++++++ src/widgets/admin-overlay/AdminOverlay.tsx | 51 ++++++++++++++++++++-- 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index b8c5041..73d14c2 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -29,6 +29,9 @@ const defaultSettings = { brand: { logoLinkUrl: "/", }, + taskManager: { + workspaceCreationPolicy: "any_authorized_user", + }, }; export function createControlPlaneStore({ projectRoot }) { @@ -195,6 +198,10 @@ export function createControlPlaneStore({ projectRoot }) { ...(data.settings?.brand ?? {}), ...(patch.brand ?? {}), }, + taskManager: { + ...(data.settings?.taskManager ?? {}), + ...(patch.taskManager ?? {}), + }, }); data.settings = settings; @@ -203,7 +210,7 @@ export function createControlPlaneStore({ projectRoot }) { objectType: "settings", objectName: "Brand settings", result: "success", - details: `Logo link: ${settings.brand.logoLinkUrl}`, + details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`, }); await writeData(data); @@ -1051,11 +1058,19 @@ function normalizeData(payload) { function normalizeSettings(payload) { const settings = typeof payload === "object" && payload !== null ? payload : {}; const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {}; + const taskManager = typeof settings.taskManager === "object" && settings.taskManager !== null ? settings.taskManager : {}; return { brand: { logoLinkUrl: optionalString(brand.logoLinkUrl, defaultSettings.brand.logoLinkUrl), }, + taskManager: { + workspaceCreationPolicy: pickEnum( + taskManager.workspaceCreationPolicy, + new Set(["any_authorized_user", "task_admins_only", "disabled"]), + defaultSettings.taskManager.workspaceCreationPolicy + ), + }, }; } diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 7589001..618b710 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -373,6 +373,8 @@ app.post("/api/internal/access/check", (req, res) => { const groups = resolveRequiredGroups(snapshot.data, user); const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug); const allowed = Boolean(app?.hasAccess); + const workspacePolicy = + serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed) : null; res.json({ ok: true, @@ -381,6 +383,7 @@ app.post("/api/internal/access/check", (req, res) => { serviceSlug, groups, matchedGroups: app?.matchedGroups ?? [], + workspacePolicy, user: { id: user.id, email: user.email, @@ -1234,6 +1237,43 @@ function pruneExpiredServiceHandoffs() { } } +function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) { + const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user"; + const groupSet = new Set(groups); + const isSuperAdmin = groupSet.has("nodedc:superadmin"); + const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin"); + + if (!hasTaskManagerAccess) { + return { + mode, + canCreateWorkspace: false, + reason: "Нет доступа к Operational Core.", + }; + } + + if (mode === "disabled") { + return { + mode, + canCreateWorkspace: false, + reason: "Создание рабочих пространств отключено на уровне платформы.", + }; + } + + if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) { + return { + mode, + canCreateWorkspace: false, + reason: "Создание рабочих пространств доступно только администраторам Operational Core.", + }; + } + + return { + mode, + canCreateWorkspace: true, + reason: "Создание рабочих пространств разрешено платформенной policy.", + }; +} + function getFrontchannelLogoutUrls() { const urls = [config.taskLogoutUrl]; const launcherData = readLauncherData(); diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index 72ecb0f..5bed247 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -67,8 +67,13 @@ export interface LauncherSettings { brand: { logoLinkUrl: string; }; + taskManager: { + workspaceCreationPolicy: TaskManagerWorkspaceCreationPolicy; + }; } +export type TaskManagerWorkspaceCreationPolicy = "any_authorized_user" | "task_admins_only" | "disabled"; + export interface ProfileOption { userId: string; label: string; @@ -94,6 +99,9 @@ export const defaultLauncherSettings: LauncherSettings = { brand: { logoLinkUrl: "/", }, + taskManager: { + workspaceCreationPolicy: "any_authorized_user", + }, }; export const initialLauncherData: LauncherData = normalizeLauncherData({ @@ -115,15 +123,29 @@ export function normalizeLauncherSettings(settings?: Partial | typeof settings?.brand === "object" && settings.brand !== null ? settings.brand : ({} as Partial); + const taskManager = + typeof settings?.taskManager === "object" && settings.taskManager !== null + ? settings.taskManager + : ({} as Partial); const logoLinkUrl = typeof brand.logoLinkUrl === "string" && brand.logoLinkUrl.trim() ? brand.logoLinkUrl.trim() : "/"; + const workspaceCreationPolicy = isTaskManagerWorkspaceCreationPolicy(taskManager.workspaceCreationPolicy) + ? taskManager.workspaceCreationPolicy + : defaultLauncherSettings.taskManager.workspaceCreationPolicy; return { brand: { logoLinkUrl, }, + taskManager: { + workspaceCreationPolicy, + }, }; } +function isTaskManagerWorkspaceCreationPolicy(value: unknown): value is TaskManagerWorkspaceCreationPolicy { + return value === "any_authorized_user" || value === "task_admins_only" || value === "disabled"; +} + export function normalizeLauncherData(data: Partial | null | undefined): LauncherData { const payload = data ?? {}; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index f4efcb5..a4f33c4 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -62,6 +62,7 @@ import { type LauncherData, type LauncherSettings, type MeResponse, + type TaskManagerWorkspaceCreationPolicy, } from "../../shared/api/mockApi"; import { uploadStorageFile } from "../../shared/api/storageApi"; import { cn } from "../../shared/lib/cn"; @@ -885,6 +886,27 @@ const accessAssignmentOptions: Array> { value: "deny", label: "Deny", description: "Исключение", tone: "red" }, ]; +const taskManagerWorkspacePolicyOptions: Array> = [ + { + value: "any_authorized_user", + label: "Все с доступом", + description: "Пользователь с доступом к Operational Core может создать собственный workspace.", + tone: "green", + }, + { + value: "task_admins_only", + label: "Только админы", + description: "Workspace создают только суперпользователь и админы Operational Core.", + tone: "yellow", + }, + { + value: "disabled", + label: "Отключено", + description: "Создание workspace закрыто для всех через платформенную policy.", + tone: "red", + }, +]; + const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv"; const modalActionAccentRgb = [247, 248, 244] as const; @@ -2401,13 +2423,19 @@ function MiscSection({ onUpdateSettings: (patch: Partial) => void; }) { const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl); + const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState( + data.settings.taskManager.workspaceCreationPolicy + ); useEffect(() => { setLogoLinkUrl(data.settings.brand.logoLinkUrl); - }, [data.settings.brand.logoLinkUrl]); + setWorkspaceCreationPolicy(data.settings.taskManager.workspaceCreationPolicy); + }, [data.settings.brand.logoLinkUrl, data.settings.taskManager.workspaceCreationPolicy]); const normalizedLogoLinkUrl = logoLinkUrl.trim() || "/"; - const hasChanges = normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl; + const hasChanges = + normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl || + workspaceCreationPolicy !== data.settings.taskManager.workspaceCreationPolicy; return ( @@ -2423,7 +2451,12 @@ function MiscSection({ type="button" icon={} disabled={!hasChanges} - onClick={() => onUpdateSettings({ brand: { logoLinkUrl: normalizedLogoLinkUrl } })} + onClick={() => + onUpdateSettings({ + brand: { logoLinkUrl: normalizedLogoLinkUrl }, + taskManager: { workspaceCreationPolicy }, + }) + } > Сохранить @@ -2440,6 +2473,18 @@ function MiscSection({ /> Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL. + );