Fix Launcher Engine access role handling
This commit is contained in:
parent
03f4044e35
commit
de446c1879
|
|
@ -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) =>
|
}
|
||||||
|
|
||||||
|
function isEngineService(candidate) {
|
||||||
|
return Boolean(
|
||||||
|
candidate &&
|
||||||
|
(
|
||||||
candidate.id === "service_nodedc" ||
|
candidate.id === "service_nodedc" ||
|
||||||
candidate.slug === "nodedc" ||
|
candidate.slug === "nodedc" ||
|
||||||
candidate.slug === "engine" ||
|
candidate.slug === "engine" ||
|
||||||
candidate.authentikApplicationSlug === "nodedc-engine"
|
candidate.authentikApplicationSlug === "nodedc-engine"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "Открытый контур · ручной инвайт",
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue