diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 51edae8..af9a2b6 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -1212,6 +1212,7 @@ export function createControlPlaneStore({ projectRoot }) { } const targetUser = findById(data.users, request.targetUserId, "user"); + const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now); const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now); request.status = "approved"; @@ -1230,7 +1231,7 @@ export function createControlPlaneStore({ projectRoot }) { }); await writeData(data); - return { engineWorkflowAccessRequest: request, grant, targetUser, data }; + return { engineWorkflowAccessRequest: request, grant, membership, targetUser, data }; } async function rejectEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) { @@ -1647,6 +1648,11 @@ export function createControlPlaneStore({ projectRoot }) { const value = requireString(payload?.value, "value"); const user = findById(data.users, userId, "user"); const service = findById(data.services, serviceId, "service"); + const engineService = isEngineService(service); + + if (engineService && (value === "admin" || value === "owner")) { + throw new Error("Для Engine доступны только роли guest, member или блокировка"); + } const directGrant = data.grants.find( (grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId @@ -1677,6 +1683,9 @@ export function createControlPlaneStore({ projectRoot }) { createdAt: directGrant?.createdAt ?? now, updatedAt: now, }); + if (engineService) { + ensurePublicPoolMembershipActiveForServiceAccess(data, user, actor, now); + } } else if (value !== "unset") { throw new Error(`Unsupported access value: ${value}`); } @@ -2662,13 +2671,78 @@ function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) { return grant; } +function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, now) { + const invitedByUserId = request.requesterUserId || actor?.id || null; + let membership = data.memberships.find( + (candidate) => candidate.clientId === publicPoolClientId && candidate.userId === user.id + ); + + if (membership) { + const wasActive = membership.status === "active"; + membership.status = "active"; + if (!wasActive) { + membership.role = "member"; + membership.source = "engine_workflow_access_request"; + membership.sourceEngineWorkflowAccessRequestId = request.id; + } else { + membership.sourceEngineWorkflowAccessRequestId = + membership.sourceEngineWorkflowAccessRequestId ?? request.id; + } + membership.invitedByUserId = membership.invitedByUserId ?? invitedByUserId; + membership.updatedAt = now; + markPendingSync(data, user, "user", user.email); + return membership; + } + + membership = { + id: uniqueId(data.memberships, "mem", `${publicPoolClientId}-${user.email}`), + clientId: publicPoolClientId, + userId: user.id, + role: "member", + status: "active", + invitedByUserId, + inviteId: null, + source: "engine_workflow_access_request", + sourceTaskerInviteRequestId: null, + sourceEngineWorkflowAccessRequestId: request.id, + createdAt: now, + updatedAt: now, + }; + data.memberships.push(membership); + markPendingSync(data, user, "user", user.email); + return membership; +} + +function ensurePublicPoolMembershipActiveForServiceAccess(data, user, actor, now) { + const membership = data.memberships.find( + (candidate) => candidate.clientId === publicPoolClientId && candidate.userId === user.id + ); + if (!membership || membership.status === "active") { + return null; + } + + membership.role = "member"; + membership.status = "active"; + membership.invitedByUserId = membership.invitedByUserId ?? actor?.id ?? null; + membership.source = membership.source ?? "launcher"; + membership.updatedAt = now; + markPendingSync(data, user, "user", user.email); + return membership; +} + function findEngineService(data) { - return data.services.find( - (candidate) => - candidate.id === "service_nodedc" || - candidate.slug === "nodedc" || - candidate.slug === "engine" || - candidate.authentikApplicationSlug === "nodedc-engine" + return data.services.find(isEngineService); +} + +function isEngineService(candidate) { + return Boolean( + candidate && + ( + candidate.id === "service_nodedc" || + candidate.slug === "nodedc" || + candidate.slug === "engine" || + candidate.authentikApplicationSlug === "nodedc-engine" + ) ); } diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index e405657..21a7935 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -1527,7 +1527,6 @@ function buildLauncherNotifications(data: LauncherData, me: ReturnType> { value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" }, ]; +const engineAccessAssignmentOptions: Array> = [ + { value: "unset", label: "—", description: "Не назначен" }, + { value: "viewer", label: "Гость", description: "Только явно открытые workflow", tone: "green" }, + { value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" }, + { value: "admin", label: "Админ", description: "Существующее значение", tone: "green", hidden: true }, + { value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" }, +]; + const publicOperationalCoreAccessOptions: Array> = [ { value: "unset", label: "—", description: "Не назначен", hidden: true }, { value: "viewer", label: "Workspace Guest", description: "Выдаётся только через Tasker", tone: "green", hidden: true }, @@ -3103,6 +3120,7 @@ function AccessSection({
- {operationalCoreCell ? ( + {operationalCoreCell && operationalCoreService ? ( request.id === membership.sourceTaskerInviteRequestId) : null; + const engineRequest = membership.sourceEngineWorkflowAccessRequestId + ? data.engineWorkflowAccessRequests.find((request) => request.id === membership.sourceEngineWorkflowAccessRequestId) + : null; if (membership.source === "tasker_workspace_invite") { const title = inviter?.name ?? taskerRequest?.inviterName ?? "Operational Core"; @@ -4574,6 +4602,15 @@ function getMembershipInviterMeta(data: LauncherData, membership: ClientMembersh }; } + if (membership.source === "engine_workflow_access_request") { + return { + title: inviter?.name ?? engineRequest?.requesterName ?? "Engine", + subtitle: inviter?.email ?? engineRequest?.requesterEmail ?? "Workflow share request", + sourceLabel: engineRequest?.workflowName ? `Engine · ${engineRequest.workflowName}` : "Engine workflow", + showInAccessMatrix: true, + }; + } + if (membership.inviteId) { return { title: inviter?.name ?? "NODE.DC", @@ -4612,6 +4649,13 @@ function getPlatformUserOrigin(data: LauncherData, user: LauncherUser, membershi }; } + if (publicMembership.source === "engine_workflow_access_request") { + return { + label: "Открытый контур · Engine workflow", + detail: `${inviterMeta.title} · ${inviterMeta.sourceLabel}`, + }; + } + if (publicMembership.inviteId) { return { label: "Открытый контур · ручной инвайт", diff --git a/src/widgets/top-bar/TopBar.tsx b/src/widgets/top-bar/TopBar.tsx index a14902f..fecdab8 100644 --- a/src/widgets/top-bar/TopBar.tsx +++ b/src/widgets/top-bar/TopBar.tsx @@ -110,8 +110,8 @@ export function TopBar({
{visibleNotifications.length === 0 ? (
- Новых входящих нет - Заявки NODE.DC, Operational Core и Engine появятся здесь. + Уведомлений нет + История заявок NODE.DC, Operational Core и Engine появится здесь.
) : ( visibleNotifications.map((notification) => (