From 5f461d57ea96fc93df04918c50f264e6944f62a1 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 6 May 2026 15:30:56 +0300 Subject: [PATCH] =?UTF-8?q?FIX=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D0=90?= =?UTF-8?q?=D0=94=D0=9C=D0=98=D0=9D=D0=9A=D0=90=20=D0=A0=D0=9E=D0=9B=D0=95?= =?UTF-8?q?=D0=99=20TASKER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/storage/launcher-data.json | 286 ++++++++++++++++++++- server/control-plane-store.mjs | 68 ++++- server/dev-server.mjs | 85 +++++- src/app/LauncherApp.tsx | 20 +- src/shared/api/adminApi.ts | 27 +- src/shared/api/mockApi.ts | 14 + src/shared/nodedc-ui/Select.tsx | 8 +- src/styles/globals.css | 69 +++-- src/widgets/admin-overlay/AdminOverlay.tsx | 85 ++++-- 9 files changed, 604 insertions(+), 58 deletions(-) diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 7e29bf8..8674199 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -14,7 +14,14 @@ "contactEmail": "dcctouch@gmail.com", "notes": "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.", "createdAt": "2026-05-04T00:00:00.000Z", - "updatedAt": "2026-05-04T12:55:13.842Z" + "updatedAt": "2026-05-06T08:44:44.882Z", + "integrations": { + "taskManager": { + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC" + } + }, + "inn": null } ], "users": [ @@ -491,10 +498,10 @@ "objectName": "DCTOUCH", "objectType": "client", "target": "authentik", - "state": "synced", + "state": "pending", "lastSyncAt": "2026-05-04T12:55:13.842Z", "error": null, - "updatedAt": "2026-05-04T12:55:13.842Z" + "updatedAt": "2026-05-06T08:44:44.887Z" }, { "id": "sync_dc_touch_authentik", @@ -1964,6 +1971,198 @@ "clientId": null, "result": "success", "details": "Logo link: http://launcher.local.nodedc/; Tasker workspace policy: task_admins_only" + }, + { + "id": "audit_dctouch", + "at": "2026-05-06T08:44:44.887Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён клиент", + "objectType": "client", + "objectName": "DCTOUCH", + "clientId": "client_romashka", + "result": "success", + "details": null + }, + { + "id": "audit_dc_constr", + "at": "2026-05-06T09:02:42.183Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTR", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: 15" + }, + { + "id": "audit_dc_constr_2", + "at": "2026-05-06T09:02:45.197Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTR", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: 15" + }, + { + "id": "audit_dc_constr_3", + "at": "2026-05-06T09:02:57.971Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTR", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: 15" + }, + { + "id": "audit_dc_constr_4", + "at": "2026-05-06T09:02:59.293Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTR", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: 15" + }, + { + "id": "audit_dc_constr_5", + "at": "2026-05-06T09:09:09.389Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTR", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: 15" + }, + { + "id": "audit_dc_silver", + "at": "2026-05-06T09:46:34.612Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SILVER", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: admin" + }, + { + "id": "audit_dc_constr_6", + "at": "2026-05-06T09:46:41.427Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTR", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_dc_sudo", + "at": "2026-05-06T10:17:58.710Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SUDO", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: admin" + }, + { + "id": "audit_dc_sudo_2", + "at": "2026-05-06T10:21:44.717Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Снят Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SUDO", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: nodedc" + }, + { + "id": "audit_dc_support", + "at": "2026-05-06T10:38:27.410Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SUPPORT", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_dc_silver_2", + "at": "2026-05-06T10:51:20.914Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SILVER", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_dc_sudo_3", + "at": "2026-05-06T10:54:33.543Z", + "actorUserId": "system", + "actorName": "NODE.DC System", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SUDO", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: admin" + }, + { + "id": "audit_dc_silver007", + "at": "2026-05-06T11:20:45.826Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SILVER007", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_dc_abramov", + "at": "2026-05-06T11:20:47.255Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC ABRAMOV", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_dc_constrictions", + "at": "2026-05-06T11:20:48.841Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTRICTIONS", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" } ], "settings": { @@ -1973,5 +2172,84 @@ "taskManager": { "workspaceCreationPolicy": "task_admins_only" } - } + }, + "taskManagerMemberships": [ + { + "id": "tasker_mem_client_romashka_user_silver_psih_nodedc", + "clientId": "client_romashka", + "userId": "user_silver_psih", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "role": "member", + "planeUserId": "7315d59a-50e1-4d26-8de8-ae632777b46e", + "planeRole": 15, + "updatedAt": "2026-05-06T10:51:20.911Z" + }, + { + "id": "tasker_mem_client_romashka_user_constr_dc_yahoo_com_nodedc", + "clientId": "client_romashka", + "userId": "user_constr_dc_yahoo_com", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "role": "member", + "planeUserId": "62aa744d-32ef-427f-8ad2-184d2ec2f5e5", + "planeRole": 15, + "updatedAt": "2026-05-06T09:46:41.427Z" + }, + { + "id": "tasker_mem_client_romashka_user_support_dctouch_ru_nodedc", + "clientId": "client_romashka", + "userId": "user_support_dctouch_ru", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "role": "member", + "planeUserId": "53e56870-8772-4dfd-9c8c-2eeacfb53caa", + "planeRole": 15, + "updatedAt": "2026-05-06T10:38:27.409Z" + }, + { + "id": "tasker_mem_client_romashka_user_root_nodedc", + "clientId": "client_romashka", + "userId": "user_root", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "role": "admin", + "planeUserId": "844d7f18-285d-4671-8371-8ca9ca5ffa39", + "planeRole": 20, + "updatedAt": "2026-05-06T10:54:33.542Z" + }, + { + "id": "tasker_mem_client_romashka_user_silverpsih007_gmail_com_nodedc", + "clientId": "client_romashka", + "userId": "user_silverpsih007_gmail_com", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "role": "member", + "planeUserId": "52817493-1ff4-44f9-aae4-463ecd512d51", + "planeRole": 15, + "updatedAt": "2026-05-06T11:20:45.826Z" + }, + { + "id": "tasker_mem_client_romashka_user_abramov_dcconstructions_ru_nodedc", + "clientId": "client_romashka", + "userId": "user_abramov_dcconstructions_ru", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "role": "member", + "planeUserId": "d28a2d28-da56-4625-a211-d9bb3d06b0d3", + "planeRole": 15, + "updatedAt": "2026-05-06T11:20:47.255Z" + }, + { + "id": "tasker_mem_client_romashka_user_support_dcconstructions_ru_nodedc", + "clientId": "client_romashka", + "userId": "user_support_dcconstructions_ru", + "workspaceSlug": "nodedc", + "workspaceName": "NODE DC", + "role": "member", + "planeUserId": "1cc7ae3a-1f42-41ac-8cc2-1ff0fce59554", + "planeRole": 15, + "updatedAt": "2026-05-06T11:20:48.841Z" + } + ] } diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 10de335..e295b48 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -14,6 +14,7 @@ const collectionKeys = [ "invites", "syncStatuses", "auditEvents", + "taskManagerMemberships", ]; const clientTypes = new Set(["company", "person"]); @@ -167,13 +168,47 @@ export function createControlPlaneStore({ projectRoot }) { const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {}; const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {}; + upsertTaskManagerMembership(data, { + clientId: client.id, + userId: user.id, + workspaceSlug: workspace.slug ?? payload?.workspaceSlug, + workspaceName: workspace.name ?? payload?.workspaceName ?? null, + role: normalizeTaskManagerMembershipRole(payload?.role), + planeUserId: membership.member?.id ?? null, + planeRole: typeof membership.role === "number" ? membership.role : null, + }); + addAuditEvent(data, actor, { action: "Назначен Tasker workspace", objectType: "task-manager-membership", objectName: user.name, clientId: client.id, result: "success", - details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${membership.role ?? payload?.role ?? "member"}`, + details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${payload?.role ?? "member"}`, + }); + + await writeData(data); + return { data }; + } + + async function removeTaskManagerWorkspaceMembership(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"); + + data.taskManagerMemberships = data.taskManagerMemberships.filter( + (membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug) + ); + + addAuditEvent(data, actor, { + action: "Снят Tasker workspace", + objectType: "task-manager-membership", + objectName: user.name, + clientId: client.id, + result: "success", + details: `Workspace: ${workspaceSlug}`, }); await writeData(data); @@ -1055,6 +1090,7 @@ export function createControlPlaneStore({ projectRoot }) { retrySync, markUserAuthentikProvisioned, recordTaskManagerWorkspaceMembership, + removeTaskManagerWorkspaceMembership, setUserServiceAccess, updateClient, updateGroup, @@ -1120,6 +1156,36 @@ function normalizeClientIntegrations(payload, fallback = {}) { }; } +function normalizeTaskManagerMembershipRole(value) { + return value === "guest" || value === "admin" || value === "member" ? value : "member"; +} + +function upsertTaskManagerMembership(data, payload) { + const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug"); + const existingMembership = data.taskManagerMemberships.find( + (membership) => membership.clientId === payload.clientId && membership.userId === payload.userId && membership.workspaceSlug === workspaceSlug + ); + const nextMembership = { + id: existingMembership?.id ?? uniqueId(data.taskManagerMemberships, "tasker_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}`), + clientId: payload.clientId, + userId: payload.userId, + workspaceSlug, + workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? 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.taskManagerMemberships.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 6d25668..69652b4 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -301,7 +301,7 @@ app.post("/api/internal/handoff/consume", (req, res) => { id: user.id, email: user.email, name: user.name, - avatarUrl: user.avatarUrl ?? null, + avatarUrl: resolveUserAvatarPublicUrl(user), subject: user.authentikUserId || handoff.user.sub, authentikUserId: user.authentikUserId ?? null, groups, @@ -557,6 +557,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher workspaceSlug, email: user.email, subject: user.authentikUserId ?? undefined, + avatarUrl: resolveUserAvatarPublicUrl(user), role, companyRole: membership?.role ?? null, setLastWorkspace: req.body?.setLastWorkspace !== false, @@ -578,6 +579,81 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher res.json({ ...result, taskManager }); })); +app.post("/api/admin/task-manager/workspace-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; + } + + const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id); + const workspaceSlug = client.integrations?.taskManager?.workspaceSlug ?? null; + + if (!workspaceSlug) { + res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" }); + return; + } + + if (membership?.role === "client_owner") { + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", { + method: "POST", + body: { + workspaceSlug, + email: user.email, + subject: user.authentikUserId ?? undefined, + avatarUrl: resolveUserAvatarPublicUrl(user), + role: "admin", + companyRole: membership.role, + setLastWorkspace: false, + }, + }); + const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership( + { + clientId: client.id, + userId: user.id, + workspaceSlug, + role: "admin", + taskManager, + }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]); + res.json({ ...result, taskManager, protected: true }); + return; + } + + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/remove/", { + method: "POST", + body: { + workspaceSlug, + email: user.email, + subject: user.authentikUserId ?? undefined, + }, + }); + const result = await controlPlaneStore.removeTaskManagerWorkspaceMembership( + { + clientId: client.id, + userId: user.id, + workspaceSlug, + }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]); + res.json({ ...result, taskManager }); +})); + app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user); res.status(201).json(result); @@ -1289,7 +1365,7 @@ function normalizeOptionalText(value) { } function normalizeTaskManagerRole(value) { - return value === "admin" || value === "member" ? value : null; + return value === "guest" || value === "admin" || value === "member" ? value : null; } function resolveTaskManagerRoleForMembership(role) { @@ -1598,6 +1674,11 @@ function resolvePublicUrl(value, baseUrl) { } } +function resolveUserAvatarPublicUrl(user) { + if (!user?.avatarUrl) return null; + return resolvePublicUrl(user.avatarUrl, config.appBaseUrl); +} + async function saveUploadedFile(payload) { if (!isUploadPayload(payload)) { throw new Error("Некорректный payload загрузки"); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index fab9631..eed32b2 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -20,6 +20,7 @@ import { fetchControlPlaneSnapshot, reorderAdminServices, retryAdminSync, + removeAdminTaskManagerWorkspaceMembership, setAdminUserServiceAccess, updateAdminClient, updateAdminGroup, @@ -29,6 +30,7 @@ import { updateAdminSettings, updateAdminUserProfile, type ControlPlaneMutationResult, + type TaskManagerWorkspaceMemberRole, type TaskManagerWorkspaceSummary, } from "../shared/api/adminApi"; import { @@ -467,7 +469,7 @@ export function LauncherApp() { }); } - function handleEnsureTaskManagerWorkspaceMember(command: { clientId: string; userId: string; role?: "member" | "admin" }) { + function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole }) { const membershipKey = `${command.clientId}:${command.userId}`; if (pendingTaskManagerMemberships[membershipKey]) { @@ -475,12 +477,22 @@ export function LauncherApp() { } setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true })); - ensureAdminTaskManagerWorkspaceMembership({ ...command, setLastWorkspace: true }) + const request = + command.role === "unset" + ? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId }) + : ensureAdminTaskManagerWorkspaceMembership({ + clientId: command.clientId, + userId: command.userId, + role: command.role, + setLastWorkspace: true, + }); + + request .then((result) => { setData(syncLauncherServiceLinks(result.data)); }) .catch((error: unknown) => { - console.warn(error instanceof Error ? error.message : "Не удалось назначить workspace Operational Core"); + console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать Tasker"); }) .finally(() => { setPendingTaskManagerMemberships((current) => { @@ -737,7 +749,7 @@ export function LauncherApp() { taskManagerWorkspacesError={taskManagerWorkspacesError} pendingTaskManagerMemberships={pendingTaskManagerMemberships} onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()} - onEnsureTaskManagerWorkspaceMember={handleEnsureTaskManagerWorkspaceMember} + onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole} /> ) : null} {profileSettingsOpen && activeProfileUser ? ( diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index dc4963f..d14705d 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -52,6 +52,8 @@ export interface TaskManagerWorkspaceMembershipResult { isBanned: boolean; } +export type TaskManagerWorkspaceMemberRole = "unset" | "guest" | "member" | "admin"; + export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult { taskManager: { ok: boolean; @@ -59,6 +61,19 @@ export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPla }; } +export interface TaskManagerWorkspaceMembershipRemoveMutationResult extends ControlPlaneMutationResult { + taskManager: { + ok: boolean; + removed: boolean; + workspace: TaskManagerWorkspaceSummary; + member: { + id: string; + email: string; + displayName: string; + }; + }; +} + export async function fetchControlPlaneSnapshot(): Promise { return requestJson("/api/admin/control-plane"); } @@ -135,7 +150,7 @@ export async function deleteAdminMembership(membershipId: string): Promise; setLastWorkspace?: boolean; }): Promise { return requestJson("/api/admin/task-manager/workspace-memberships/ensure", { @@ -144,6 +159,16 @@ export async function ensureAdminTaskManagerWorkspaceMembership(payload: { }); } +export async function removeAdminTaskManagerWorkspaceMembership(payload: { + clientId: string; + userId: string; +}): Promise { + return requestJson("/api/admin/task-manager/workspace-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 5bed247..f2029bb 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -60,9 +60,22 @@ export interface LauncherData { invites: Invite[]; syncStatuses: SyncStatus[]; auditEvents: typeof mockAuditEvents; + taskManagerMemberships: TaskManagerMembershipAssignment[]; settings: LauncherSettings; } +export interface TaskManagerMembershipAssignment { + id: string; + clientId: string; + userId: string; + workspaceSlug: string; + workspaceName?: string | null; + role: "guest" | "member" | "admin"; + planeUserId?: string | null; + planeRole?: number | null; + updatedAt: string; +} + export interface LauncherSettings { brand: { logoLinkUrl: string; @@ -160,6 +173,7 @@ export function normalizeLauncherData(data: Partial | null | undef invites: Array.isArray(payload.invites) ? payload.invites : mockInvites, syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses, auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents, + taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [], settings: normalizeLauncherSettings(payload.settings), }; } diff --git a/src/shared/nodedc-ui/Select.tsx b/src/shared/nodedc-ui/Select.tsx index d504132..cdb23ce 100644 --- a/src/shared/nodedc-ui/Select.tsx +++ b/src/shared/nodedc-ui/Select.tsx @@ -10,6 +10,7 @@ export interface NodeDcSelectOption { icon?: ReactNode; tone?: string; disabled?: boolean; + hidden?: boolean; } interface NodeDcSelectTriggerApi { @@ -59,14 +60,15 @@ export function NodeDcSelect({ const [query, setQuery] = useState(""); const selectedOption = options.find((option) => option.value === value) ?? options[0]; const normalizedQuery = query.trim().toLowerCase(); + const menuOptions = useMemo(() => options.filter((option) => !option.hidden), [options]); const visibleOptions = useMemo(() => { - if (!normalizedQuery) return options; + if (!normalizedQuery) return menuOptions; - return options.filter((option) => { + return menuOptions.filter((option) => { const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase(); return haystack.includes(normalizedQuery); }); - }, [normalizedQuery, options]); + }, [menuOptions, normalizedQuery]); return ( ; export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset"; +type TaskManagerRoleSelectValue = TaskManagerWorkspaceMemberRole | "pending"; export interface SetUserServiceAccessCommand { userId: string; @@ -107,7 +108,7 @@ export interface CreateUserCommand { export interface EnsureTaskManagerWorkspaceMemberCommand { clientId: string; userId: string; - role?: "member" | "admin"; + role: TaskManagerWorkspaceMemberRole; } const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ @@ -164,7 +165,7 @@ export function AdminOverlay({ taskManagerWorkspacesError, pendingTaskManagerMemberships, onRefreshTaskManagerWorkspaces, - onEnsureTaskManagerWorkspaceMember, + onSetTaskManagerWorkspaceMemberRole, }: { data: LauncherData; me: MeResponse; @@ -196,7 +197,7 @@ export function AdminOverlay({ taskManagerWorkspacesError: string | null; pendingTaskManagerMemberships: Record; onRefreshTaskManagerWorkspaces: () => void; - onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; + onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; }) { const isRoot = me.launcherRole === "root_admin"; const sections = isRoot ? rootSections : clientSections; @@ -329,7 +330,7 @@ export function AdminOverlay({ onUpdateMembership={onUpdateMembership} onDeleteMembership={onDeleteMembership} pendingTaskManagerMemberships={pendingTaskManagerMemberships} - onEnsureTaskManagerWorkspaceMember={onEnsureTaskManagerWorkspaceMember} + onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole} /> ) : null} {activeSection === "groups" ? ( @@ -570,7 +571,7 @@ function UsersSection({ onUpdateMembership, onDeleteMembership, pendingTaskManagerMemberships, - onEnsureTaskManagerWorkspaceMember, + onSetTaskManagerWorkspaceMemberRole, }: { data: LauncherData; clientId: string; @@ -580,7 +581,7 @@ function UsersSection({ onUpdateMembership: (membershipId: string, patch: Partial) => void; onDeleteMembership: (membershipId: string) => void; pendingTaskManagerMemberships: Record; - onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; + onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; }) { const [editingMembershipId, setEditingMembershipId] = useState(null); const [newUserEmail, setNewUserEmail] = useState(""); @@ -661,11 +662,11 @@ function UsersSection({ - +

Участники

- +
@@ -683,7 +684,15 @@ function UsersSection({ const taskManagerWorkspace = client.integrations?.taskManager?.workspaceSlug ?? null; const pendingKey = `${client.id}:${user.id}`; const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]); - const taskManagerRole = membership.role === "client_owner" || membership.role === "client_admin" ? "admin" : "member"; + const forcedTaskManagerAdmin = membership.role === "client_owner"; + const taskManagerAssignment = data.taskManagerMemberships.find( + (candidate) => candidate.clientId === client.id && candidate.userId === user.id && candidate.workspaceSlug === taskManagerWorkspace + ); + const taskManagerRole = taskManagerAssignment?.role ?? (forcedTaskManagerAdmin ? "admin" : "unset"); + const taskManagerRoleOptions = buildTaskManagerRoleOptions({ + hasWorkspace: Boolean(taskManagerWorkspace), + disabled: pendingTaskerAssignment || forcedTaskManagerAdmin || membership.status !== "active" || user.globalStatus !== "active", + }); return ( @@ -736,15 +745,25 @@ function UsersSection({ />
Пользователь
- + { + if (role === "pending") return; + onSetTaskManagerWorkspaceMemberRole({ clientId: client.id, userId: user.id, role }); + }} + /> > { value: "deny", label: "Deny", description: "Исключение", tone: "red" }, ]; +function buildTaskManagerRoleOptions({ + hasWorkspace, + disabled, +}: { + hasWorkspace: boolean; + disabled: boolean; +}): Array> { + return [ + { value: "unset", label: hasWorkspace ? "—" : "Workspace не выбран" }, + { value: "guest", label: "Гость", disabled: !hasWorkspace || disabled }, + { value: "member", label: "Участник", disabled: !hasWorkspace || disabled }, + { value: "admin", label: "Админ", disabled: !hasWorkspace || disabled }, + { value: "pending", label: "Сохраняем...", disabled: true, hidden: true }, + ]; +} + const taskManagerWorkspacePolicyOptions: Array> = [ { value: "any_authorized_user", @@ -1651,9 +1686,15 @@ function ClientEditorModal({ minMenuWidth={280} onChange={updateTaskManagerWorkspace} /> - + + + {taskManagerWorkspacesError