From 01e098803171285d9cd0911fb5b1f74ad9130fa6 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 9 May 2026 12:49:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A5=20-=20=D0=9C=D0=95=D0=96?= =?UTF-8?q?=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A?= =?UTF-8?q?=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98?= =?UTF-8?q?=D0=AF:=20managedBy=20policy=20Launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/control-plane-store.mjs | 31 ++++-- server/dev-server.mjs | 114 +++++++++++++++++++-- src/entities/client/types.ts | 2 + src/shared/api/adminApi.ts | 3 +- src/shared/api/mockApi.ts | 4 +- src/widgets/admin-overlay/AdminOverlay.tsx | 6 +- 6 files changed, 140 insertions(+), 20 deletions(-) diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 480ac13..82f9f91 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -27,6 +27,7 @@ const appRoles = new Set(["viewer", "member", "admin", "owner"]); const grantStatuses = new Set(["active", "disabled"]); const exceptionTypes = new Set(["deny", "allow"]); const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]); +const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]); const defaultSettings = { brand: { logoLinkUrl: "/", @@ -177,6 +178,7 @@ export function createControlPlaneStore({ projectRoot }) { workspaceSlug: workspace.slug ?? payload?.workspaceSlug, workspaceName: workspace.name ?? payload?.workspaceName ?? null, role: normalizeTaskManagerMembershipRole(payload?.role), + managedBy: payload?.managedBy, planeUserId: membership.member?.id ?? null, planeRole: typeof membership.role === "number" ? membership.role : null, }); @@ -234,15 +236,16 @@ export function createControlPlaneStore({ projectRoot }) { upsertTaskManagerProjectMembership(data, { clientId: client.id, userId: user.id, - workspaceSlug: workspace.slug ?? payload?.workspaceSlug, - workspaceName: workspace.name ?? payload?.workspaceName ?? null, - projectId: project.id ?? payload?.projectId, - projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null, - projectName: project.name ?? payload?.projectName ?? null, - role: normalizeTaskManagerMembershipRole(payload?.role), - planeUserId: membership.member?.id ?? null, - planeRole: typeof membership.role === "number" ? membership.role : null, - }); + workspaceSlug: workspace.slug ?? payload?.workspaceSlug, + workspaceName: workspace.name ?? payload?.workspaceName ?? null, + projectId: project.id ?? payload?.projectId, + projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null, + projectName: project.name ?? payload?.projectName ?? null, + role: normalizeTaskManagerMembershipRole(payload?.role), + managedBy: payload?.managedBy, + planeUserId: membership.member?.id ?? null, + planeRole: typeof membership.role === "number" ? membership.role : null, + }); addAuditEvent(data, actor, { action: "Назначен Tasker project", @@ -1252,11 +1255,12 @@ function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) { slug, name: nullableStringWithFallback(item.name, null), isPrimary: item.isPrimary === true, + managedBy: normalizeTaskManagerWorkspaceManagedBy(item.managedBy), }); } if (legacySlug && !bySlug.has(legacySlug)) { - bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true }); + bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true, managedBy: "launcher" }); } const workspaces = [...bySlug.values()]; @@ -1274,10 +1278,15 @@ function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) { slug: workspace.slug, name: workspace.name ?? null, isPrimary, + managedBy: normalizeTaskManagerWorkspaceManagedBy(workspace.managedBy), }; }); } +function normalizeTaskManagerWorkspaceManagedBy(value) { + return taskManagerWorkspaceManagedByValues.has(value) ? value : "launcher"; +} + function normalizeTaskManagerMembershipRole(value) { return value === "guest" || value === "admin" || value === "member" ? value : "member"; } @@ -1294,6 +1303,7 @@ function upsertTaskManagerMembership(data, payload) { workspaceSlug, workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null), role: normalizeTaskManagerMembershipRole(payload.role), + managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy), planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null), planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null), updatedAt: isoNow(), @@ -1330,6 +1340,7 @@ function upsertTaskManagerProjectMembership(data, payload) { projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null), projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null), role: normalizeTaskManagerMembershipRole(payload.role), + managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy), planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null), planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null), updatedAt: isoNow(), diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 95e7b6c..f2a6b8c 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -374,7 +374,7 @@ app.post("/api/internal/access/check", (req, res) => { 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; + serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user) : null; res.json({ ok: true, @@ -569,6 +569,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id); const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null; + const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug); if (!workspaceSlug) { res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); @@ -585,6 +586,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher avatarUrl: resolveUserAvatarPublicUrl(user), role, companyRole: membership?.role ?? null, + managedBy: workspaceManagedBy, setLastWorkspace: req.body?.setLastWorkspace !== false, }, }); @@ -595,6 +597,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher userId: user.id, workspaceSlug, role, + managedBy: workspaceManagedBy, taskManager, }, req.nodedcSession.user @@ -641,17 +644,19 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher email: user.email, subject: user.authentikUserId ?? undefined, avatarUrl: resolveUserAvatarPublicUrl(user), - role: "admin", - companyRole: membership.role, - setLastWorkspace: false, - }, - }); + role: "admin", + companyRole: membership.role, + managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug), + setLastWorkspace: false, + }, + }); const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership( { clientId: client.id, userId: user.id, workspaceSlug, role: "admin", + managedBy: resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug), taskManager, }, req.nodedcSession.user @@ -707,6 +712,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug); const projectId = normalizeOptionalText(req.body?.projectId); const role = normalizeTaskManagerRole(req.body?.role); + const workspaceManagedBy = resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug); if (!workspaceSlug) { res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); @@ -732,6 +738,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd subject: user.authentikUserId ?? undefined, avatarUrl: resolveUserAvatarPublicUrl(user), role, + managedBy: workspaceManagedBy, setLastWorkspace: false, }, }); @@ -743,6 +750,7 @@ app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAd workspaceSlug, projectId, role, + managedBy: workspaceManagedBy, taskManager, }, req.nodedcSession.user @@ -1632,6 +1640,72 @@ function resolveTaskManagerRoleForMembership(role) { return role === "client_owner" || role === "client_admin" ? "admin" : "member"; } +function normalizeTaskManagerWorkspaceManagedBy(value) { + return value === "tasker" ? "tasker" : "launcher"; +} + +function getClientTaskManagerWorkspaces(client) { + const taskManager = client?.integrations?.taskManager; + const workspaces = Array.isArray(taskManager?.workspaces) ? taskManager.workspaces : []; + const legacySlug = normalizeOptionalText(taskManager?.workspaceSlug); + + if (!legacySlug || workspaces.some((workspace) => normalizeOptionalText(workspace?.slug) === legacySlug)) { + return workspaces; + } + + return [ + ...workspaces, + { + slug: legacySlug, + name: normalizeOptionalText(taskManager?.workspaceName), + isPrimary: true, + managedBy: "launcher", + }, + ]; +} + +function resolveTaskManagerWorkspaceBinding(client, workspaceSlug) { + const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug); + if (!normalizedWorkspaceSlug) return null; + + return ( + getClientTaskManagerWorkspaces(client).find((workspace) => normalizeOptionalText(workspace?.slug) === normalizedWorkspaceSlug) ?? null + ); +} + +function resolveTaskManagerWorkspaceManagedByForClient(client, workspaceSlug) { + return normalizeTaskManagerWorkspaceManagedBy(resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy); +} + +function resolveTaskManagerWorkspaceAssignments(data, user) { + if (!user?.id) return []; + + const bySlug = new Map(); + for (const membership of data.taskManagerMemberships ?? []) { + if (membership.userId !== user.id) continue; + const workspaceSlug = normalizeOptionalText(membership.workspaceSlug); + if (!workspaceSlug) continue; + + const client = data.clients.find((candidate) => candidate.id === membership.clientId); + const managedBy = normalizeTaskManagerWorkspaceManagedBy( + membership.managedBy ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.managedBy + ); + const current = bySlug.get(workspaceSlug); + if (current && current.managedBy === "launcher") continue; + + bySlug.set(workspaceSlug, { + slug: workspaceSlug, + name: normalizeOptionalText(membership.workspaceName ?? resolveTaskManagerWorkspaceBinding(client, workspaceSlug)?.name), + managedBy, + clientId: client?.id ?? membership.clientId ?? null, + clientName: client?.name ?? null, + role: normalizeTaskManagerRole(membership.role) ?? "member", + }); + } + + return [...bySlug.values()]; +} + function createServiceHandoff(serviceSlug, user) { pruneExpiredServiceHandoffs(); @@ -1679,15 +1753,21 @@ function pruneExpiredServiceHandoffs() { } } -function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) { +function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user) { 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"); + const workspaces = resolveTaskManagerWorkspaceAssignments(data, user); + const hasLauncherManagedWorkspace = workspaces.some((workspace) => workspace.managedBy === "launcher"); + const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker"; if (!hasTaskManagerAccess) { return { mode, + managedBy: defaultManagedBy, + defaultManagedBy, + workspaces, canCreateWorkspace: false, reason: "Нет доступа к Operational Core.", }; @@ -1696,14 +1776,31 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) { if (mode === "disabled") { return { mode, + managedBy: defaultManagedBy, + defaultManagedBy, + workspaces, canCreateWorkspace: false, reason: "Создание рабочих пространств отключено на уровне платформы.", }; } + if (hasLauncherManagedWorkspace && !isSuperAdmin) { + return { + mode, + managedBy: "launcher", + defaultManagedBy: "launcher", + workspaces, + canCreateWorkspace: false, + reason: "Рабочие пространства этого пользователя управляются через Launcher.", + }; + } + if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) { return { mode, + managedBy: defaultManagedBy, + defaultManagedBy, + workspaces, canCreateWorkspace: false, reason: "Создание рабочих пространств доступно только администраторам Operational Core.", }; @@ -1711,6 +1808,9 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) { return { mode, + managedBy: "tasker", + defaultManagedBy: "tasker", + workspaces, canCreateWorkspace: true, reason: "Создание рабочих пространств разрешено платформенной policy.", }; diff --git a/src/entities/client/types.ts b/src/entities/client/types.ts index 4137181..12b5452 100644 --- a/src/entities/client/types.ts +++ b/src/entities/client/types.ts @@ -1,10 +1,12 @@ export type ClientType = "company" | "person"; export type ClientStatus = "active" | "suspended" | "demo" | "expired"; +export type TaskManagerWorkspaceManagedBy = "launcher" | "tasker"; export interface ClientTaskManagerWorkspaceBinding { slug: string; name?: string | null; isPrimary?: boolean; + managedBy?: TaskManagerWorkspaceManagedBy; } export interface Client { diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 5a6fb51..db539da 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -1,5 +1,5 @@ import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types"; -import type { Client } from "../../entities/client/types"; +import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types"; import type { Invite } from "../../entities/invite/types"; import type { Service } from "../../entities/service/types"; import type { SyncStatus } from "../../entities/sync/types"; @@ -35,6 +35,7 @@ export interface TaskManagerWorkspaceSummary { id: string; slug: string; name: string; + managedBy?: TaskManagerWorkspaceManagedBy; ownerEmail: string | null; memberCount: number; projects?: TaskManagerProjectSummary[]; diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index ebe4c0e..e411f60 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -1,6 +1,6 @@ import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess"; import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types"; -import type { Client } from "../../entities/client/types"; +import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types"; import type { Invite } from "../../entities/invite/types"; import { getServiceLaunchLink } from "../../entities/service/links"; import type { LauncherServiceView, Service } from "../../entities/service/types"; @@ -72,6 +72,7 @@ export interface TaskManagerMembershipAssignment { workspaceSlug: string; workspaceName?: string | null; role: "guest" | "member" | "admin"; + managedBy?: TaskManagerWorkspaceManagedBy; planeUserId?: string | null; planeRole?: number | null; updatedAt: string; @@ -87,6 +88,7 @@ export interface TaskManagerProjectMembershipAssignment { projectIdentifier?: string | null; projectName?: string | null; role: "guest" | "member" | "admin"; + managedBy?: TaskManagerWorkspaceManagedBy; planeUserId?: string | null; planeRole?: number | null; updatedAt: string; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 802867e..3ac6761 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -989,6 +989,7 @@ function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorksp slug: workspace.slug, name: workspace.name ?? null, isPrimary: workspace.isPrimary === true, + managedBy: workspace.managedBy ?? "launcher", }); } @@ -997,6 +998,7 @@ function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorksp slug: taskManager.workspaceSlug, name: taskManager.workspaceName ?? null, isPrimary: true, + managedBy: "launcher", }); } @@ -1055,6 +1057,7 @@ function normalizeClientTaskManagerWorkspaceDraft(workspaces: ClientTaskManagerW slug: workspace.slug, name: workspace.name ?? null, isPrimary: workspace.isPrimary === true, + managedBy: workspace.managedBy ?? "launcher", }); } @@ -1781,6 +1784,7 @@ function ClientEditorModal({ slug: workspace.slug, name: workspace.name, isPrimary: currentWorkspaces.length === 0, + managedBy: "launcher", }, ]; @@ -1919,7 +1923,7 @@ function ClientEditorModal({ {workspace.name} - {workspace.slug} · {workspace.memberCount} участников + {workspace.slug} · {workspace.memberCount} участников · Launcher-managed {selected ? (