Fix Launcher Engine access role handling

This commit is contained in:
DCCONSTRUCTIONS 2026-05-25 19:09:32 +03:00
parent 03f4044e35
commit de446c1879
5 changed files with 131 additions and 15 deletions

View File

@ -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"
)
);
}

View File

@ -1527,7 +1527,6 @@ function buildLauncherNotifications(data: LauncherData, me: ReturnType<typeof bu
for (const request of data.accessRequests) {
const isOwnRequest = request.email.toLowerCase() === currentEmail;
if (!canModerate && !isOwnRequest) continue;
if (canModerate && request.status !== "new") continue;
const applicantName = [request.lastName, request.firstName].filter(Boolean).join(" ") || request.email;
items.push({
@ -1547,7 +1546,6 @@ function buildLauncherNotifications(data: LauncherData, me: ReturnType<typeof bu
request.inviterEmail.toLowerCase() === currentEmail ||
request.inviteeEmail.toLowerCase() === currentEmail;
if (!canModerate && !isRelated) continue;
if (canModerate && request.status !== "new") continue;
items.push({
id: `tasker:${request.id}`,
@ -1567,7 +1565,6 @@ function buildLauncherNotifications(data: LauncherData, me: ReturnType<typeof bu
request.requesterEmail.toLowerCase() === currentEmail ||
request.targetEmail.toLowerCase() === currentEmail;
if (!canModerate && !isRelated) continue;
if (canModerate && request.status !== "new") continue;
items.push({
id: `engine:${request.id}`,

View File

@ -32,8 +32,9 @@ export interface ClientMembership {
status: ClientMembershipStatus;
invitedByUserId?: string | null;
inviteId?: string | null;
source?: "launcher" | "access_request" | "tasker_workspace_invite" | null;
source?: "launcher" | "access_request" | "tasker_workspace_invite" | "engine_workflow_access_request" | null;
sourceTaskerInviteRequestId?: string | null;
sourceEngineWorkflowAccessRequestId?: string | null;
createdAt: string;
updatedAt: string;
}

View File

@ -178,6 +178,15 @@ function isProtectedFromCurrentActor(me: MeResponse, userId: string): boolean {
return isProtectedLauncherUser(userId) && !canSelfManageProtectedUser(me, userId);
}
function isEngineServiceForAccessControl(service: Service): boolean {
return (
service.id === "service_nodedc" ||
service.slug === "nodedc" ||
service.slug === "engine" ||
service.authentikApplicationSlug === "nodedc-engine"
);
}
export function AdminOverlay({
data,
me,
@ -1399,6 +1408,14 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
];
const engineAccessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ 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<NodeDcSelectOption<AccessAssignmentValue>> = [
{ value: "unset", label: "—", description: "Не назначен", hidden: true },
{ value: "viewer", label: "Workspace Guest", description: "Выдаётся только через Tasker", tone: "green", hidden: true },
@ -3103,6 +3120,7 @@ function AccessSection({
<div key={service.id} className="access-grid-cell" role="cell">
<AccessCellControl
cell={cell}
service={service}
active={active}
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
busy={!usePublicTaskerAccess && isTaskManagerService && pendingTaskerAssignment}
@ -3287,9 +3305,10 @@ function PublicAccessUsersPanel({
)}
</td>
<td>
{operationalCoreCell ? (
{operationalCoreCell && operationalCoreService ? (
<AccessCellControl
cell={operationalCoreCell}
service={operationalCoreService}
active={selectedCell?.userId === user.id && selectedCell.serviceId === operationalCoreCell.serviceId}
pendingValue={pendingAccessAssignments[accessCellKey(user.id, operationalCoreCell.serviceId)]}
publicSelfService
@ -3684,6 +3703,7 @@ function OperationalCoreAccessModal({
function AccessCellControl({
cell,
service,
active,
pendingValue,
busy = false,
@ -3694,6 +3714,7 @@ function AccessCellControl({
onOpenDetails,
}: {
cell: AccessMatrixCell;
service: Service;
active: boolean;
pendingValue?: AccessAssignmentValue;
busy?: boolean;
@ -3706,7 +3727,11 @@ function AccessCellControl({
const isPending = pendingValue !== undefined || busy;
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
const selectValue = publicSelfService ? publicOperationalCoreSelectValue(assignmentValue) : assignmentValue;
const selectOptions = publicSelfService ? publicOperationalCoreAccessOptions : accessAssignmentOptions;
const selectOptions = publicSelfService
? publicOperationalCoreAccessOptions
: isEngineServiceForAccessControl(service)
? engineAccessAssignmentOptions
: accessAssignmentOptions;
const displayTitle = isPending
? publicSelfService
? publicAccessAssignmentLabel(assignmentValue)
@ -4551,6 +4576,9 @@ function getMembershipInviterMeta(data: LauncherData, membership: ClientMembersh
const taskerRequest = membership.sourceTaskerInviteRequestId
? data.taskerInviteRequests.find((request) => 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: "Открытый контур · ручной инвайт",

View File

@ -110,8 +110,8 @@ export function TopBar({
<div className="nodedc-notifications-list">
{visibleNotifications.length === 0 ? (
<div className="nodedc-notifications-empty">
<strong>Новых входящих нет</strong>
<span>Заявки NODE.DC, Operational Core и Engine появятся здесь.</span>
<strong>Уведомлений нет</strong>
<span>История заявок NODE.DC, Operational Core и Engine появится здесь.</span>
</div>
) : (
visibleNotifications.map((notification) => (