From 11dd8d104397004eefc36306c32960ea0df17ff0 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 8 May 2026 15:19:38 +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:=20PROJECT-LEVEL=20=D0=94=D0=9E=D0=A1?= =?UTF-8?q?=D0=A2=D0=A3=D0=9F=D0=AB=20OPERATIONAL=20CORE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/storage/launcher-data.json | 211 +++++++++++++++++++-- server/control-plane-store.mjs | 109 +++++++++++ server/dev-server.mjs | 153 ++++++++++++++- src/app/LauncherApp.tsx | 45 +++++ src/shared/api/adminApi.ts | 69 +++++++ src/shared/api/mockApi.ts | 17 ++ src/styles/globals.css | 80 +++++++- src/widgets/admin-overlay/AdminOverlay.tsx | 132 ++++++++++++- 8 files changed, 782 insertions(+), 34 deletions(-) diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 298e6a8..2909db1 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -14,11 +14,23 @@ "contactEmail": "dcctouch@gmail.com", "notes": "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.", "createdAt": "2026-05-04T00:00:00.000Z", - "updatedAt": "2026-05-06T08:44:44.882Z", + "updatedAt": "2026-05-08T10:14:10.727Z", "integrations": { "taskManager": { "workspaceSlug": "nodedc", - "workspaceName": "NODE DC" + "workspaceName": "NODE DC", + "workspaces": [ + { + "slug": "nodedc", + "name": "NODE DC", + "isPrimary": true + }, + { + "slug": "dcabramov", + "name": "DCABRAMOV", + "isPrimary": false + } + ] } }, "inn": null @@ -75,7 +87,7 @@ "avatarUrl": null, "globalStatus": "active", "createdAt": "2026-05-05T16:02:43.235Z", - "updatedAt": "2026-05-05T16:04:53.004Z" + "updatedAt": "2026-05-08T11:44:25.215Z" }, { "id": "user_silverpsih007_gmail_com", @@ -149,10 +161,10 @@ "id": "mem_client_romashka_support_dctouch_ru", "clientId": "client_romashka", "userId": "user_support_dctouch_ru", - "role": "member", + "role": "client_admin", "status": "active", "createdAt": "2026-05-05T16:02:43.235Z", - "updatedAt": "2026-05-05T16:02:43.235Z" + "updatedAt": "2026-05-08T11:44:24.773Z" }, { "id": "mem_client_romashka_silverpsih007_gmail_com", @@ -387,16 +399,6 @@ "createdAt": "2026-05-05T14:57:13.249Z", "updatedAt": "2026-05-05T14:57:13.249Z" }, - { - "id": "grant_task_manager_user_support_dctouch_ru", - "serviceId": "service_task_manager", - "targetType": "user", - "targetId": "user_support_dctouch_ru", - "appRole": "member", - "status": "active", - "createdAt": "2026-05-05T16:04:52.709Z", - "updatedAt": "2026-05-05T16:04:52.709Z" - }, { "id": "grant_task_manager_user_silverpsih007_gmail_com", "serviceId": "service_task_manager", @@ -426,6 +428,16 @@ "status": "active", "createdAt": "2026-05-06T01:28:06.515Z", "updatedAt": "2026-05-06T01:28:06.515Z" + }, + { + "id": "grant_task_manager_user_support_dctouch_ru", + "serviceId": "service_task_manager", + "targetType": "user", + "targetId": "user_support_dctouch_ru", + "appRole": "member", + "status": "active", + "createdAt": "2026-05-05T16:04:52.709Z", + "updatedAt": "2026-05-08T10:14:37.303Z" } ], "exceptions": [], @@ -501,7 +513,7 @@ "state": "pending", "lastSyncAt": "2026-05-04T12:55:13.842Z", "error": null, - "updatedAt": "2026-05-06T08:44:44.887Z" + "updatedAt": "2026-05-08T10:14:10.729Z" }, { "id": "sync_dc_touch_authentik", @@ -620,9 +632,9 @@ "objectType": "user", "target": "authentik", "state": "synced", - "lastSyncAt": "2026-05-05T16:04:53.004Z", + "lastSyncAt": "2026-05-08T11:44:25.215Z", "error": null, - "updatedAt": "2026-05-05T16:04:53.004Z" + "updatedAt": "2026-05-08T11:44:25.215Z" }, { "id": "sync_grant_service_task_manager_user_support_dctouch_ru", @@ -633,7 +645,7 @@ "state": "pending", "lastSyncAt": null, "error": null, - "updatedAt": "2026-05-05T16:04:52.709Z" + "updatedAt": "2026-05-08T10:14:37.305Z" }, { "id": "sync_invite_invite_silverpsih007_gmail_com", @@ -2367,6 +2379,126 @@ "clientId": null, "result": "success", "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" + }, + { + "id": "audit_dc_support_2", + "at": "2026-05-08T10:08:56.801Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker project", + "objectType": "task-manager-project-membership", + "objectName": "DC SUPPORT", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Project: DCTM-WT-CODEX; Role: member" + }, + { + "id": "audit_dctouch_2", + "at": "2026-05-08T10:10:17.084Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён клиент", + "objectType": "client", + "objectName": "DCTOUCH", + "clientId": "client_romashka", + "result": "success", + "details": null + }, + { + "id": "audit_dctouch_3", + "at": "2026-05-08T10:11:08.178Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён клиент", + "objectType": "client", + "objectName": "DCTOUCH", + "clientId": "client_romashka", + "result": "success", + "details": null + }, + { + "id": "audit_dctouch_4", + "at": "2026-05-08T10:14:10.729Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён клиент", + "objectType": "client", + "objectName": "DCTOUCH", + "clientId": "client_romashka", + "result": "success", + "details": null + }, + { + "id": "audit_support_dctouch_ru_task_manager_2", + "at": "2026-05-08T10:14:37.304Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "support@dctouch.ru / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_dc_support_3", + "at": "2026-05-08T10:14:37.448Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SUPPORT", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: DCABRAMOV; Role: member" + }, + { + "id": "audit_support_dctouch_ru_6", + "at": "2026-05-08T10:14:38.013Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "support@dctouch.ru", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" + }, + { + "id": "audit_support_dctouch_ru_7", + "at": "2026-05-08T11:44:24.774Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлено членство", + "objectType": "user", + "objectName": "support@dctouch.ru", + "clientId": "client_romashka", + "result": "success", + "details": "Role: client_admin; status: active" + }, + { + "id": "audit_support_dctouch_ru_8", + "at": "2026-05-08T11:44:25.215Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "support@dctouch.ru", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" + }, + { + "id": "audit_dc_constrictions_2", + "at": "2026-05-08T11:52:50.107Z", + "actorUserId": "user_support_dctouch_ru", + "actorName": "DC SUPPORT", + "action": "Назначен Tasker project", + "objectType": "task-manager-project-membership", + "objectName": "DC CONSTRICTIONS", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Project: Менеджмент; Role: member" } ], "settings": { @@ -2454,6 +2586,47 @@ "planeUserId": "1cc7ae3a-1f42-41ac-8cc2-1ff0fce59554", "planeRole": 15, "updatedAt": "2026-05-06T11:20:48.841Z" + }, + { + "id": "tasker_mem_client_romashka_user_support_dctouch_ru_dcabramov", + "clientId": "client_romashka", + "userId": "user_support_dctouch_ru", + "workspaceSlug": "dcabramov", + "workspaceName": "DCABRAMOV", + "role": "member", + "planeUserId": "53e56870-8772-4dfd-9c8c-2eeacfb53caa", + "planeRole": 15, + "updatedAt": "2026-05-08T10:14:37.448Z" + } + ], + "taskManagerProjectMemberships": [ + { + "id": "tasker_project_mem_client_romashka_user_support_dctouch_ru_nodedc_823af409_bcfd_498c_b2fb_31119ff23", + "clientId": "client_romashka", + "userId": "user_support_dctouch_ru", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "projectId": "823af409-bcfd-498c-b2fb-31119ff238a7", + "projectIdentifier": "CODEX", + "projectName": "DCTM-WT-CODEX", + "role": "member", + "planeUserId": "53e56870-8772-4dfd-9c8c-2eeacfb53caa", + "planeRole": 15, + "updatedAt": "2026-05-08T10:08:56.800Z" + }, + { + "id": "tasker_project_mem_client_romashka_user_support_dcconstructions_ru_nodedc_a3de175a_2df5_4604_aadf_6", + "clientId": "client_romashka", + "userId": "user_support_dcconstructions_ru", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "projectId": "a3de175a-2df5-4604-aadf-618877445135", + "projectIdentifier": "MGR", + "projectName": "Менеджмент", + "role": "member", + "planeUserId": "1cc7ae3a-1f42-41ac-8cc2-1ff0fce59554", + "planeRole": 15, + "updatedAt": "2026-05-08T11:52:50.104Z" } ] } diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index d406a54..f800c5b 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -15,6 +15,7 @@ const collectionKeys = [ "syncStatuses", "auditEvents", "taskManagerMemberships", + "taskManagerProjectMemberships", ]; const clientTypes = new Set(["company", "person"]); @@ -201,6 +202,9 @@ export function createControlPlaneStore({ projectRoot }) { data.taskManagerMemberships = data.taskManagerMemberships.filter( (membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug) ); + data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter( + (membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug) + ); addAuditEvent(data, actor, { action: "Снят Tasker workspace", @@ -215,6 +219,73 @@ export function createControlPlaneStore({ projectRoot }) { return { data }; } + async function recordTaskManagerProjectMembership(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const client = findById(data.clients, payload?.clientId, "client"); + const user = findById(data.users, payload?.userId, "user"); + const taskManager = typeof payload?.taskManager === "object" && payload.taskManager !== null ? payload.taskManager : {}; + const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {}; + const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {}; + const project = typeof membership.project === "object" && membership.project !== null ? membership.project : {}; + + 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, + }); + + addAuditEvent(data, actor, { + action: "Назначен Tasker project", + objectType: "task-manager-project-membership", + objectName: user.name, + clientId: client.id, + result: "success", + details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Project: ${project.name ?? project.identifier ?? payload?.projectId}; Role: ${payload?.role ?? "member"}`, + }); + + await writeData(data); + return { data }; + } + + async function removeTaskManagerProjectMembership(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const client = findById(data.clients, payload?.clientId, "client"); + const user = findById(data.users, payload?.userId, "user"); + const workspaceSlug = requireString(payload?.workspaceSlug ?? client.integrations?.taskManager?.workspaceSlug, "workspaceSlug"); + const projectId = requireString(payload?.projectId, "projectId"); + + data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter( + (membership) => + !( + membership.clientId === client.id && + membership.userId === user.id && + membership.workspaceSlug === workspaceSlug && + membership.projectId === projectId + ) + ); + + addAuditEvent(data, actor, { + action: "Снят Tasker project", + objectType: "task-manager-project-membership", + objectName: user.name, + clientId: client.id, + result: "success", + details: `Workspace: ${workspaceSlug}; Project: ${projectId}`, + }); + + await writeData(data); + return { data }; + } + async function deleteClient(clientId, identity) { const data = readData(); const actor = resolveActor(data, identity); @@ -1089,7 +1160,9 @@ export function createControlPlaneStore({ projectRoot }) { reorderServices, retrySync, markUserAuthentikProvisioned, + recordTaskManagerProjectMembership, recordTaskManagerWorkspaceMembership, + removeTaskManagerProjectMembership, removeTaskManagerWorkspaceMembership, setUserServiceAccess, updateClient, @@ -1233,6 +1306,42 @@ function upsertTaskManagerMembership(data, payload) { return nextMembership; } +function upsertTaskManagerProjectMembership(data, payload) { + const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug"); + const projectId = requireString(payload.projectId, "projectId"); + const existingMembership = data.taskManagerProjectMemberships.find( + (membership) => + membership.clientId === payload.clientId && + membership.userId === payload.userId && + membership.workspaceSlug === workspaceSlug && + membership.projectId === projectId + ); + const nextMembership = { + id: + existingMembership?.id ?? + uniqueId(data.taskManagerProjectMemberships, "tasker_project_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}-${projectId}`), + clientId: payload.clientId, + userId: payload.userId, + workspaceSlug, + workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null), + projectId, + projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null), + projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null), + role: normalizeTaskManagerMembershipRole(payload.role), + planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null), + planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null), + updatedAt: isoNow(), + }; + + if (existingMembership) { + Object.assign(existingMembership, nextMembership); + return existingMembership; + } + + data.taskManagerProjectMemberships.push(nextMembership); + return nextMembership; +} + function resolveActor(data, identity) { const user = data.users.find( (item) => diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 585319f..95e7b6c 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -521,13 +521,29 @@ app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => { }); app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (req, res) => { - if (!req.nodedcAdminScope?.isRoot) { - res.json({ ok: true, workspaces: [] }); + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/"); + + if (req.nodedcAdminScope?.isRoot) { + res.json(taskManager); return; } - const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/"); - res.json(taskManager); + const allowedWorkspaceSlugs = new Set( + req.nodedcAdminScope.snapshot.data.clients + .filter((client) => req.nodedcAdminScope.clientIds.has(client.id)) + .flatMap((client) => { + const workspaces = Array.isArray(client.integrations?.taskManager?.workspaces) + ? client.integrations.taskManager.workspaces + : []; + const slugs = workspaces.map((workspace) => workspace?.slug).filter((slug) => typeof slug === "string" && slug.trim()); + const legacySlug = client.integrations?.taskManager?.workspaceSlug; + return legacySlug ? [...slugs, legacySlug] : slugs; + }) + ); + res.json({ + ...taskManager, + workspaces: (taskManager.workspaces ?? []).filter((workspace) => allowedWorkspaceSlugs.has(workspace.slug)), + }); })); app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncherAdmin, asyncRoute(async (req, res) => { @@ -667,6 +683,132 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher res.json({ ...scopeAdminMutationResult(req, result), taskManager }); })); +app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : ""; + const userId = typeof req.body?.userId === "string" ? req.body.userId : ""; + const client = snapshot.data.clients.find((candidate) => candidate.id === clientId); + const user = snapshot.data.users.find((candidate) => candidate.id === userId); + + if (!client) { + res.status(404).json({ ok: false, error: "client_not_found" }); + return; + } + + if (!user) { + res.status(404).json({ ok: false, error: "user_not_found" }); + return; + } + + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + return; + } + + const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug); + const projectId = normalizeOptionalText(req.body?.projectId); + const role = normalizeTaskManagerRole(req.body?.role); + + if (!workspaceSlug) { + res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); + return; + } + + if (!projectId) { + res.status(400).json({ ok: false, error: "task_manager_project_not_configured" }); + return; + } + + if (!role) { + res.status(400).json({ ok: false, error: "task_manager_role_invalid" }); + return; + } + + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/ensure/", { + method: "POST", + body: { + workspaceSlug, + projectId, + email: user.email, + subject: user.authentikUserId ?? undefined, + avatarUrl: resolveUserAvatarPublicUrl(user), + role, + setLastWorkspace: false, + }, + }); + + const result = await controlPlaneStore.recordTaskManagerProjectMembership( + { + clientId: client.id, + userId: user.id, + workspaceSlug, + projectId, + role, + taskManager, + }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.task-manager.project-membership.updated", [user.id]); + res.json({ ...scopeAdminMutationResult(req, result), taskManager }); +})); + +app.post("/api/admin/task-manager/project-memberships/remove", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : ""; + const userId = typeof req.body?.userId === "string" ? req.body.userId : ""; + const client = snapshot.data.clients.find((candidate) => candidate.id === clientId); + const user = snapshot.data.users.find((candidate) => candidate.id === userId); + + if (!client) { + res.status(404).json({ ok: false, error: "client_not_found" }); + return; + } + + if (!user) { + res.status(404).json({ ok: false, error: "user_not_found" }); + return; + } + + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + return; + } + + const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug); + const projectId = normalizeOptionalText(req.body?.projectId); + + if (!workspaceSlug) { + res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); + return; + } + + if (!projectId) { + res.status(400).json({ ok: false, error: "task_manager_project_not_configured" }); + return; + } + + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/remove/", { + method: "POST", + body: { + workspaceSlug, + projectId, + email: user.email, + subject: user.authentikUserId ?? undefined, + }, + }); + const result = await controlPlaneStore.removeTaskManagerProjectMembership( + { + clientId: client.id, + userId: user.id, + workspaceSlug, + projectId, + }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.task-manager.project-membership.updated", [user.id]); + res.json({ ...scopeAdminMutationResult(req, result), taskManager }); +})); + app.post("/api/admin/clients", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user); res.status(201).json(result); @@ -2209,6 +2351,9 @@ function scopeControlPlaneData(data, scope) { taskManagerMemberships: data.taskManagerMemberships.filter( (membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId) ), + taskManagerProjectMemberships: data.taskManagerProjectMemberships.filter( + (membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId) + ), syncStatuses: data.syncStatuses.filter( (syncStatus) => clientIds.has(syncStatus.objectId) || userIds.has(syncStatus.objectId) || groupIds.has(syncStatus.objectId) ), diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index bc79af7..b20398c 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -15,11 +15,13 @@ import { deleteAdminInvite, deleteAdminMembership, deleteAdminService, + ensureAdminTaskManagerProjectMembership, ensureAdminTaskManagerWorkspaceMembership, fetchAdminTaskManagerWorkspaces, fetchControlPlaneSnapshot, reorderAdminServices, retryAdminSync, + removeAdminTaskManagerProjectMembership, removeAdminTaskManagerWorkspaceMembership, setAdminUserServiceAccess, updateAdminClient, @@ -57,6 +59,7 @@ import { AdminOverlay, type AccessAssignmentValue, type CreateUserCommand, + type EnsureTaskManagerProjectMemberCommand, type SetUserServiceAccessCommand, } from "../widgets/admin-overlay/AdminOverlay"; import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel"; @@ -87,6 +90,7 @@ export function LauncherApp() { const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [pendingAccessAssignments, setPendingAccessAssignments] = useState>({}); const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState>({}); + const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState>({}); const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState([]); const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false); const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState(null); @@ -506,6 +510,45 @@ export function LauncherApp() { }); } + function handleSetTaskManagerProjectMemberRole(command: EnsureTaskManagerProjectMemberCommand) { + const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug}:${command.projectId}`; + + if (pendingTaskManagerProjectMemberships[membershipKey]) { + return; + } + + setPendingTaskManagerProjectMemberships((current) => ({ ...current, [membershipKey]: true })); + const request = + command.role === "unset" + ? removeAdminTaskManagerProjectMembership({ + clientId: command.clientId, + userId: command.userId, + workspaceSlug: command.workspaceSlug, + projectId: command.projectId, + }) + : ensureAdminTaskManagerProjectMembership({ + clientId: command.clientId, + userId: command.userId, + workspaceSlug: command.workspaceSlug, + projectId: command.projectId, + role: command.role, + }); + + request + .then((result) => { + setData(syncLauncherServiceLinks(result.data)); + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать проект Tasker"); + }) + .finally(() => { + setPendingTaskManagerProjectMemberships((current) => { + const { [membershipKey]: _completed, ...rest } = current; + return rest; + }); + }); + } + function handleCreateInvite(invite: Pick) { applyControlPlaneMutation(createAdminInvite(invite)); } @@ -752,8 +795,10 @@ export function LauncherApp() { taskManagerWorkspacesLoading={taskManagerWorkspacesLoading} taskManagerWorkspacesError={taskManagerWorkspacesError} pendingTaskManagerMemberships={pendingTaskManagerMemberships} + pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships} onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()} onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole} + onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole} /> ) : null} {profileSettingsOpen && activeProfileUser ? ( diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 58bb478..5a6fb51 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -37,6 +37,15 @@ export interface TaskManagerWorkspaceSummary { name: string; ownerEmail: string | null; memberCount: number; + projects?: TaskManagerProjectSummary[]; +} + +export interface TaskManagerProjectSummary { + id: string; + workspaceSlug: string; + name: string; + identifier: string; + memberCount: number; } export interface TaskManagerWorkspaceMembershipResult { @@ -52,6 +61,20 @@ export interface TaskManagerWorkspaceMembershipResult { isBanned: boolean; } +export interface TaskManagerProjectMembershipResult { + created: boolean; + workspace: TaskManagerWorkspaceSummary; + project: TaskManagerProjectSummary; + member: { + id: string; + email: string; + displayName: string; + }; + role: number; + roleSlug: Exclude; + isActive: boolean; +} + export type TaskManagerWorkspaceMemberRole = "unset" | "guest" | "member" | "admin"; export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult { @@ -74,6 +97,27 @@ export interface TaskManagerWorkspaceMembershipRemoveMutationResult extends Cont }; } +export interface TaskManagerProjectMembershipMutationResult extends ControlPlaneMutationResult { + taskManager: { + ok: boolean; + membership: TaskManagerProjectMembershipResult; + }; +} + +export interface TaskManagerProjectMembershipRemoveMutationResult extends ControlPlaneMutationResult { + taskManager: { + ok: boolean; + removed: boolean; + workspace: TaskManagerWorkspaceSummary; + project: TaskManagerProjectSummary; + member: { + id: string; + email: string; + displayName: string; + }; + }; +} + export async function fetchControlPlaneSnapshot(): Promise { return requestJson("/api/admin/control-plane"); } @@ -171,6 +215,31 @@ export async function removeAdminTaskManagerWorkspaceMembership(payload: { }); } +export async function ensureAdminTaskManagerProjectMembership(payload: { + clientId: string; + userId: string; + workspaceSlug: string; + projectId: string; + role: Exclude; +}): Promise { + return requestJson("/api/admin/task-manager/project-memberships/ensure", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeAdminTaskManagerProjectMembership(payload: { + clientId: string; + userId: string; + workspaceSlug: string; + projectId: string; +}): Promise { + return requestJson("/api/admin/task-manager/project-memberships/remove", { + method: "POST", + body: JSON.stringify(payload), + }); +} + export async function createAdminGroup(payload: Pick & Partial): Promise { return requestJson("/api/admin/groups", { method: "POST", diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index f2029bb..ebe4c0e 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -61,6 +61,7 @@ export interface LauncherData { syncStatuses: SyncStatus[]; auditEvents: typeof mockAuditEvents; taskManagerMemberships: TaskManagerMembershipAssignment[]; + taskManagerProjectMemberships: TaskManagerProjectMembershipAssignment[]; settings: LauncherSettings; } @@ -76,6 +77,21 @@ export interface TaskManagerMembershipAssignment { updatedAt: string; } +export interface TaskManagerProjectMembershipAssignment { + id: string; + clientId: string; + userId: string; + workspaceSlug: string; + workspaceName?: string | null; + projectId: string; + projectIdentifier?: string | null; + projectName?: string | null; + role: "guest" | "member" | "admin"; + planeUserId?: string | null; + planeRole?: number | null; + updatedAt: string; +} + export interface LauncherSettings { brand: { logoLinkUrl: string; @@ -174,6 +190,7 @@ export function normalizeLauncherData(data: Partial | null | undef syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses, auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents, taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [], + taskManagerProjectMemberships: Array.isArray(payload.taskManagerProjectMemberships) ? payload.taskManagerProjectMemberships : [], settings: normalizeLauncherSettings(payload.settings), }; } diff --git a/src/styles/globals.css b/src/styles/globals.css index 8f5e1fa..4214062 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -3215,17 +3215,38 @@ code { .task-access-modal { width: min(44rem, calc(100vw - 2rem)); + max-height: min(44rem, calc(100vh - 2.8rem)); + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 0.85rem; } .task-access-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.65rem; + min-height: 0; +} + +.task-access-summary .info-line { + min-width: 0; + min-height: 3.65rem; + overflow: hidden; +} + +.task-access-summary .info-line strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .task-workspace-access-list { display: grid; + min-height: 0; + align-content: start; gap: 0.7rem; + overflow: auto; + padding-right: 0.15rem; } .task-workspace-access-card { @@ -3240,13 +3261,21 @@ code { display: grid; grid-template-columns: minmax(0, 1fr) 10.8rem; gap: 0.75rem; - align-items: center; + align-items: start; +} + +.task-workspace-access-card__head > div { + min-width: 0; + padding-top: 0.08rem; } .task-workspace-access-card__head strong, .task-workspace-access-card__head small { display: block; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .task-workspace-access-card__head small { @@ -3255,25 +3284,66 @@ code { font-size: 0.75rem; } -.task-project-access-note { +.task-project-access-list { display: grid; - gap: 0.22rem; + gap: 0.55rem; padding: 0.7rem; border-radius: 0.85rem; background: rgba(0, 0, 0, 0.18); } -.task-project-access-note strong { +.task-project-access-list__head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; +} + +.task-project-access-list__head strong { color: var(--text-secondary); font-size: 0.8rem; } -.task-project-access-note span { +.task-project-access-list__head span { color: var(--text-muted); font-size: 0.76rem; line-height: 1.35; } +.task-project-access-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 10.8rem; + gap: 0.65rem; + align-items: center; + min-height: 3.4rem; + padding: 0.55rem; + border-radius: 0.85rem; + background: rgba(255, 255, 255, 0.045); +} + +.task-project-access-row__meta { + display: grid; + min-width: 0; + gap: 0.16rem; +} + +.task-project-access-row__meta strong, +.task-project-access-row__meta small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-project-access-row__meta strong { + color: var(--text-primary); + font-size: 0.82rem; +} + +.task-project-access-row__meta small { + color: var(--text-muted); + font-size: 0.72rem; +} + .access-explanation { display: grid; align-content: start; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index b3757c3..0fd85b8 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -66,7 +66,7 @@ import { type MeResponse, type TaskManagerWorkspaceCreationPolicy, } from "../../shared/api/mockApi"; -import type { TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi"; +import type { TaskManagerProjectSummary, TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi"; import { uploadStorageFile } from "../../shared/api/storageApi"; import { cn } from "../../shared/lib/cn"; import { formatDate, formatDateTime } from "../../shared/lib/format"; @@ -114,6 +114,14 @@ export interface EnsureTaskManagerWorkspaceMemberCommand { role: TaskManagerWorkspaceMemberRole; } +export interface EnsureTaskManagerProjectMemberCommand { + clientId: string; + userId: string; + workspaceSlug: string; + projectId: string; + role: TaskManagerWorkspaceMemberRole; +} + const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ { id: "overview", label: "Обзор", icon: }, { id: "clients", label: "Клиенты", icon: }, @@ -166,8 +174,10 @@ export function AdminOverlay({ taskManagerWorkspacesLoading, taskManagerWorkspacesError, pendingTaskManagerMemberships, + pendingTaskManagerProjectMemberships, onRefreshTaskManagerWorkspaces, onSetTaskManagerWorkspaceMemberRole, + onSetTaskManagerProjectMemberRole, }: { data: LauncherData; me: MeResponse; @@ -198,8 +208,10 @@ export function AdminOverlay({ taskManagerWorkspacesLoading: boolean; taskManagerWorkspacesError: string | null; pendingTaskManagerMemberships: Record; + pendingTaskManagerProjectMemberships: Record; onRefreshTaskManagerWorkspaces: () => void; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; + onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; }) { const isRoot = me.launcherRole === "root_admin"; const sections = isRoot ? rootSections : clientSections; @@ -375,7 +387,10 @@ export function AdminOverlay({ onUpdateUser={onUpdateUser} onUpdateMembership={onUpdateMembership} pendingTaskManagerMemberships={pendingTaskManagerMemberships} + pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships} + taskManagerWorkspaceCatalog={taskManagerWorkspaces} onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole} + onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole} /> ) : null} {activeSection === "invites" ? ( @@ -948,6 +963,14 @@ const operationalCoreRoleOptions: Array> = [ + { value: "unset", label: "—", description: "Не назначен" }, + { value: "viewer", label: "Гость", description: "Просмотр", tone: "green" }, + { value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" }, + { value: "admin", label: "Админ", description: "Администрирование", tone: "green" }, + { value: "pending", label: "Сохраняем...", disabled: true, hidden: true }, +]; + function membershipRoleLabel(role: ClientMembershipRole): string { return membershipRoleOptions.find((option) => option.value === role)?.label ?? role; } @@ -1005,6 +1028,28 @@ function getTaskManagerMembershipRole(data: LauncherData, clientId: string, user return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset"; } +function getTaskManagerProjectMembershipRole( + data: LauncherData, + clientId: string, + userId: string, + workspaceSlug: string, + projectId: string +): TaskManagerWorkspaceMemberRole { + return ( + data.taskManagerProjectMemberships.find( + (membership) => + membership.clientId === clientId && + membership.userId === userId && + membership.workspaceSlug === workspaceSlug && + membership.projectId === projectId + )?.role ?? "unset" + ); +} + +function getWorkspaceCatalogProjects(workspace: ClientTaskManagerWorkspaceBinding, catalog: TaskManagerWorkspaceSummary[]): TaskManagerProjectSummary[] { + return catalog.find((item) => item.slug === workspace.slug)?.projects ?? []; +} + function normalizeClientTaskManagerWorkspaceDraft(workspaces: ClientTaskManagerWorkspaceBinding[]): ClientTaskManagerWorkspaceBinding[] { const bySlug = new Map(); @@ -2228,7 +2273,10 @@ function AccessSection({ onUpdateUser, onUpdateMembership, pendingTaskManagerMemberships, + pendingTaskManagerProjectMemberships, + taskManagerWorkspaceCatalog, onSetTaskManagerWorkspaceMemberRole, + onSetTaskManagerProjectMemberRole, }: { data: LauncherData; matrix: ReturnType; @@ -2239,10 +2287,13 @@ function AccessSection({ onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; pendingTaskManagerMemberships: Record; + pendingTaskManagerProjectMemberships: Record; + taskManagerWorkspaceCatalog: TaskManagerWorkspaceSummary[]; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; + onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; }) { const hasUsers = matrix.users.length > 0; - const taskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client); + const clientTaskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client); const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(matrix.client); const [detailsCell, setDetailsCell] = useState(null); @@ -2399,12 +2450,15 @@ function AccessSection({ user={getUser(data, detailsCell.userId)} service={getService(data, detailsCell.serviceId)} cell={detailsCell} - workspaces={taskManagerWorkspaces} + workspaces={clientTaskManagerWorkspaces} + workspaceCatalog={taskManagerWorkspaceCatalog} pendingAccessAssignments={pendingAccessAssignments} pendingTaskManagerMemberships={pendingTaskManagerMemberships} + pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships} onClose={() => setDetailsCell(null)} onSetUserServiceAccess={onSetUserServiceAccess} onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole} + onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole} /> ) : null} @@ -2501,11 +2555,14 @@ function OperationalCoreAccessModal({ service, cell, workspaces, + workspaceCatalog, pendingAccessAssignments, pendingTaskManagerMemberships, + pendingTaskManagerProjectMemberships, onClose, onSetUserServiceAccess, onSetTaskManagerWorkspaceMemberRole, + onSetTaskManagerProjectMemberRole, }: { data: LauncherData; client: Client; @@ -2513,11 +2570,14 @@ function OperationalCoreAccessModal({ service: Service; cell: AccessMatrixCell; workspaces: ClientTaskManagerWorkspaceBinding[]; + workspaceCatalog: TaskManagerWorkspaceSummary[]; pendingAccessAssignments: Record; pendingTaskManagerMemberships: Record; + pendingTaskManagerProjectMemberships: Record; onClose: () => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; + onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; }) { const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id); const protectedUser = user.id === "user_root" || membership?.role === "client_owner"; @@ -2538,6 +2598,7 @@ function OperationalCoreAccessModal({ {workspaces.length ? ( workspaces.map((workspace) => { const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug); + const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog); const pendingKey = `${client.id}:${user.id}:${workspace.slug}`; const pending = Boolean(pendingTaskManagerMemberships[pendingKey]) || basePendingValue !== undefined; const value: OperationalCoreRoleSelectValue = pending @@ -2580,9 +2641,68 @@ function OperationalCoreAccessModal({ /> )} -
- Проекты - Сейчас наследуют роль workspace. Точечные роли проектов подключаются следующим Project API-адаптером. +
+
+ Проекты + {projects.length ? "Точечные роли внутри выбранного workspace" : "В этом workspace пока нет проектов"} +
+ {projects.map((project) => { + const projectRole = getTaskManagerProjectMembershipRole(data, client.id, user.id, workspace.slug, project.id); + const projectPendingKey = `${client.id}:${user.id}:${workspace.slug}:${project.id}`; + const projectPending = Boolean(pendingTaskManagerProjectMemberships[projectPendingKey]); + const projectValue: OperationalCoreRoleSelectValue = projectPending ? "pending" : taskManagerRoleToAccessAssignment(projectRole); + + return ( +
+
+ {project.name} + + {project.identifier} + {project.memberCount ? ` · ${project.memberCount} участников` : ""} + +
+ {protectedUser ? ( + {accessAssignmentLabel("admin")} + ) : ( + { + if (nextValue === "pending") return; + + const nextTaskManagerRole = accessAssignmentToTaskManagerRole(nextValue); + if (nextTaskManagerRole !== "unset") { + if (baseAssignmentValue === "unset" || baseAssignmentValue === "deny") { + onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue }); + } + if (role === "unset") { + onSetTaskManagerWorkspaceMemberRole({ + clientId: client.id, + userId: user.id, + workspaceSlug: workspace.slug, + role: nextTaskManagerRole, + }); + } + } + + onSetTaskManagerProjectMemberRole({ + clientId: client.id, + userId: user.id, + workspaceSlug: workspace.slug, + projectId: project.id, + role: nextTaskManagerRole, + }); + }} + /> + )} +
+ ); + })}
);