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 targetUser = findById(data.users, request.targetUserId, "user");
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now); const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
request.status = "approved"; request.status = "approved";
@ -1230,7 +1231,7 @@ export function createControlPlaneStore({ projectRoot }) {
}); });
await writeData(data); await writeData(data);
return { engineWorkflowAccessRequest: request, grant, targetUser, data }; return { engineWorkflowAccessRequest: request, grant, membership, targetUser, data };
} }
async function rejectEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) { async function rejectEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
@ -1647,6 +1648,11 @@ export function createControlPlaneStore({ projectRoot }) {
const value = requireString(payload?.value, "value"); const value = requireString(payload?.value, "value");
const user = findById(data.users, userId, "user"); const user = findById(data.users, userId, "user");
const service = findById(data.services, serviceId, "service"); 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( const directGrant = data.grants.find(
(grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId (grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId
@ -1677,6 +1683,9 @@ export function createControlPlaneStore({ projectRoot }) {
createdAt: directGrant?.createdAt ?? now, createdAt: directGrant?.createdAt ?? now,
updatedAt: now, updatedAt: now,
}); });
if (engineService) {
ensurePublicPoolMembershipActiveForServiceAccess(data, user, actor, now);
}
} else if (value !== "unset") { } else if (value !== "unset") {
throw new Error(`Unsupported access value: ${value}`); throw new Error(`Unsupported access value: ${value}`);
} }
@ -2662,13 +2671,78 @@ function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) {
return grant; 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) { function findEngineService(data) {
return data.services.find( return data.services.find(isEngineService);
(candidate) => }
candidate.id === "service_nodedc" ||
candidate.slug === "nodedc" || function isEngineService(candidate) {
candidate.slug === "engine" || return Boolean(
candidate.authentikApplicationSlug === "nodedc-engine" 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) { for (const request of data.accessRequests) {
const isOwnRequest = request.email.toLowerCase() === currentEmail; const isOwnRequest = request.email.toLowerCase() === currentEmail;
if (!canModerate && !isOwnRequest) continue; if (!canModerate && !isOwnRequest) continue;
if (canModerate && request.status !== "new") continue;
const applicantName = [request.lastName, request.firstName].filter(Boolean).join(" ") || request.email; const applicantName = [request.lastName, request.firstName].filter(Boolean).join(" ") || request.email;
items.push({ items.push({
@ -1547,7 +1546,6 @@ function buildLauncherNotifications(data: LauncherData, me: ReturnType<typeof bu
request.inviterEmail.toLowerCase() === currentEmail || request.inviterEmail.toLowerCase() === currentEmail ||
request.inviteeEmail.toLowerCase() === currentEmail; request.inviteeEmail.toLowerCase() === currentEmail;
if (!canModerate && !isRelated) continue; if (!canModerate && !isRelated) continue;
if (canModerate && request.status !== "new") continue;
items.push({ items.push({
id: `tasker:${request.id}`, id: `tasker:${request.id}`,
@ -1567,7 +1565,6 @@ function buildLauncherNotifications(data: LauncherData, me: ReturnType<typeof bu
request.requesterEmail.toLowerCase() === currentEmail || request.requesterEmail.toLowerCase() === currentEmail ||
request.targetEmail.toLowerCase() === currentEmail; request.targetEmail.toLowerCase() === currentEmail;
if (!canModerate && !isRelated) continue; if (!canModerate && !isRelated) continue;
if (canModerate && request.status !== "new") continue;
items.push({ items.push({
id: `engine:${request.id}`, id: `engine:${request.id}`,

View File

@ -32,8 +32,9 @@ export interface ClientMembership {
status: ClientMembershipStatus; status: ClientMembershipStatus;
invitedByUserId?: string | null; invitedByUserId?: string | null;
inviteId?: 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; sourceTaskerInviteRequestId?: string | null;
sourceEngineWorkflowAccessRequestId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

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

View File

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