feat: integrate hub notifications with core
This commit is contained in:
parent
de446c1879
commit
8f56fd2ab6
|
|
@ -38,7 +38,9 @@ const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
|
||||||
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
||||||
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
|
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
|
||||||
const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
||||||
|
const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
|
||||||
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
|
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
|
||||||
|
const engineServiceRoles = new Set(["viewer", "member"]);
|
||||||
const publicPoolClientId = "client_public_pool";
|
const publicPoolClientId = "client_public_pool";
|
||||||
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
|
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
|
||||||
const publicPoolClient = {
|
const publicPoolClient = {
|
||||||
|
|
@ -1164,12 +1166,15 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(request, {
|
Object.assign(request, {
|
||||||
|
requestType: "workflow",
|
||||||
workflowId,
|
workflowId,
|
||||||
workflowName,
|
workflowName,
|
||||||
targetUserId: targetUser.id,
|
targetUserId: targetUser.id,
|
||||||
targetEmail,
|
targetEmail,
|
||||||
targetName: targetUser.name ?? null,
|
targetName: targetUser.name ?? null,
|
||||||
role,
|
role,
|
||||||
|
requestedServiceRole: null,
|
||||||
|
currentServiceRole: null,
|
||||||
requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null),
|
requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null),
|
||||||
requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru",
|
requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru",
|
||||||
requesterName,
|
requesterName,
|
||||||
|
|
@ -1201,6 +1206,78 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createEngineRoleAccessRequest(payload, identity = { name: "NODE.DC Engine", source: "engine" }) {
|
||||||
|
const data = readData();
|
||||||
|
const now = isoNow();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const requesterEmail = normalizeEmail(payload?.requesterEmail ?? actor.email ?? payload?.email ?? "");
|
||||||
|
const targetUser =
|
||||||
|
(payload?.requesterUserId ? data.users.find((user) => user.id === payload.requesterUserId) : null) ??
|
||||||
|
(requesterEmail ? data.users.find((user) => normalizeEmail(user.email) === requesterEmail) : null) ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!targetUser || targetUser.globalStatus !== "active") {
|
||||||
|
throw new Error("engine_target_user_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedServiceRole = normalizeEngineServiceRole(payload?.requestedServiceRole ?? payload?.requestedRole ?? "member");
|
||||||
|
const currentServiceRole = normalizeNullableEngineServiceRole(payload?.currentServiceRole ?? payload?.currentRole);
|
||||||
|
const role = requestedServiceRole === "viewer" ? "viewer" : "editor";
|
||||||
|
const existingRequest = data.engineWorkflowAccessRequests.find(
|
||||||
|
(request) =>
|
||||||
|
request.status === "new" &&
|
||||||
|
request.requestType === "service_role" &&
|
||||||
|
request.targetUserId === targetUser.id
|
||||||
|
);
|
||||||
|
const request =
|
||||||
|
existingRequest ??
|
||||||
|
{
|
||||||
|
id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_access_request", targetUser.email),
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(request, {
|
||||||
|
requestType: "service_role",
|
||||||
|
workflowId: "engine:service-role",
|
||||||
|
workflowName: "NODE.DC Engine",
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
targetEmail: normalizeEmail(targetUser.email),
|
||||||
|
targetName: targetUser.name ?? null,
|
||||||
|
role,
|
||||||
|
requestedServiceRole,
|
||||||
|
currentServiceRole,
|
||||||
|
requesterUserId: targetUser.id,
|
||||||
|
requesterEmail: normalizeEmail(targetUser.email),
|
||||||
|
requesterName: optionalString(payload?.requesterName, targetUser.name ?? targetUser.email),
|
||||||
|
status: "new",
|
||||||
|
reviewedByUserId: null,
|
||||||
|
reviewedAt: null,
|
||||||
|
engineAppliedAt: null,
|
||||||
|
comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null),
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingRequest) {
|
||||||
|
data.engineWorkflowAccessRequests.push(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: existingRequest ? "Обновлена заявка повышения роли Engine" : "Создана заявка повышения роли Engine",
|
||||||
|
objectType: "engine_role_access_request",
|
||||||
|
objectName: `${request.targetName ?? request.targetEmail}:${requestedServiceRole}`,
|
||||||
|
result: "success",
|
||||||
|
details: `Current role: ${currentServiceRole ?? "unknown"}; requested role: ${requestedServiceRole}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return {
|
||||||
|
engineWorkflowAccessRequest: request,
|
||||||
|
affectedUserIds: [targetUser.id, "user_root"].filter(Boolean),
|
||||||
|
targetUser,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function approveEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
|
async function approveEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
|
|
@ -1212,6 +1289,32 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUser = findById(data.users, request.targetUserId, "user");
|
const targetUser = findById(data.users, request.targetUserId, "user");
|
||||||
|
if (request.requestType === "service_role") {
|
||||||
|
const serviceRole = normalizeEngineServiceRole(payload?.serviceRole ?? payload?.requestedServiceRole ?? request.requestedServiceRole);
|
||||||
|
request.requestedServiceRole = serviceRole;
|
||||||
|
request.role = serviceRole === "viewer" ? "viewer" : "editor";
|
||||||
|
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
|
||||||
|
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
|
||||||
|
|
||||||
|
request.status = "approved";
|
||||||
|
request.reviewedByUserId = actor.id;
|
||||||
|
request.reviewedAt = now;
|
||||||
|
request.engineAppliedAt = nullableStringWithFallback(payload?.engineAppliedAt, now);
|
||||||
|
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
|
||||||
|
request.updatedAt = now;
|
||||||
|
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: "Подтверждена заявка повышения роли Engine",
|
||||||
|
objectType: "engine_role_access_request",
|
||||||
|
objectName: `${request.targetName ?? request.targetEmail}:${serviceRole}`,
|
||||||
|
result: "success",
|
||||||
|
details: `Engine role: ${serviceRole}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return { engineWorkflowAccessRequest: request, grant, membership, targetUser, data };
|
||||||
|
}
|
||||||
|
|
||||||
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
|
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
|
||||||
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
|
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
|
||||||
|
|
||||||
|
|
@ -1251,9 +1354,11 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
request.updatedAt = now;
|
request.updatedAt = now;
|
||||||
|
|
||||||
addAuditEvent(data, actor, {
|
addAuditEvent(data, actor, {
|
||||||
action: "Отклонена заявка доступа Engine workflow",
|
action: request.requestType === "service_role" ? "Отклонена заявка повышения роли Engine" : "Отклонена заявка доступа Engine workflow",
|
||||||
objectType: "engine_workflow_access_request",
|
objectType: request.requestType === "service_role" ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||||
objectName: `${request.workflowName}:${request.targetEmail}`,
|
objectName: request.requestType === "service_role"
|
||||||
|
? `${request.targetName ?? request.targetEmail}:${request.requestedServiceRole ?? request.role}`
|
||||||
|
: `${request.workflowName}:${request.targetEmail}`,
|
||||||
result: "warning",
|
result: "warning",
|
||||||
details: request.comment ?? null,
|
details: request.comment ?? null,
|
||||||
});
|
});
|
||||||
|
|
@ -1649,6 +1754,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
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);
|
const engineService = isEngineService(service);
|
||||||
|
const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null;
|
||||||
|
|
||||||
if (engineService && (value === "admin" || value === "owner")) {
|
if (engineService && (value === "admin" || value === "owner")) {
|
||||||
throw new Error("Для Engine доступны только роли guest, member или блокировка");
|
throw new Error("Для Engine доступны только роли guest, member или блокировка");
|
||||||
|
|
@ -1690,6 +1796,17 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
throw new Error(`Unsupported access value: ${value}`);
|
throw new Error(`Unsupported access value: ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let engineWorkflowAccessRequest = null;
|
||||||
|
if (engineService && (value === "viewer" || value === "member") && previousEngineServiceRole !== value) {
|
||||||
|
engineWorkflowAccessRequest = appendEngineRoleChangeNotification(data, {
|
||||||
|
actor,
|
||||||
|
currentServiceRole: previousEngineServiceRole,
|
||||||
|
serviceRole: value,
|
||||||
|
targetUser: user,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
addAuditEvent(data, actor, {
|
addAuditEvent(data, actor, {
|
||||||
action: "Обновлён доступ пользователя к сервису",
|
action: "Обновлён доступ пользователя к сервису",
|
||||||
objectType: "grant",
|
objectType: "grant",
|
||||||
|
|
@ -1700,7 +1817,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`);
|
markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`);
|
||||||
|
|
||||||
await writeData(data);
|
await writeData(data);
|
||||||
return { data };
|
return { data, engineWorkflowAccessRequest, affectedUserIds: [user.id] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setServiceModuleEntitlement(payload, identity) {
|
async function setServiceModuleEntitlement(payload, identity) {
|
||||||
|
|
@ -2011,6 +2128,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
buildAuthentikSyncPlan,
|
buildAuthentikSyncPlan,
|
||||||
cancelTaskerInviteRequest,
|
cancelTaskerInviteRequest,
|
||||||
createAccessRequest,
|
createAccessRequest,
|
||||||
|
createEngineRoleAccessRequest,
|
||||||
createEngineWorkflowAccessRequest,
|
createEngineWorkflowAccessRequest,
|
||||||
createTaskerInviteRequest,
|
createTaskerInviteRequest,
|
||||||
createClient,
|
createClient,
|
||||||
|
|
@ -2229,6 +2347,7 @@ function normalizeTaskerInviteRequest(payload) {
|
||||||
function normalizeEngineWorkflowAccessRequest(payload) {
|
function normalizeEngineWorkflowAccessRequest(payload) {
|
||||||
if (typeof payload !== "object" || payload === null) return null;
|
if (typeof payload !== "object" || payload === null) return null;
|
||||||
const now = isoNow();
|
const now = isoNow();
|
||||||
|
const requestType = pickEnum(payload.requestType ?? payload.type, engineWorkflowAccessRequestTypes, "workflow");
|
||||||
const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : "";
|
const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : "";
|
||||||
const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : "";
|
const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : "";
|
||||||
const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email);
|
const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email);
|
||||||
|
|
@ -2238,12 +2357,17 @@ function normalizeEngineWorkflowAccessRequest(payload) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`),
|
id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`),
|
||||||
|
requestType,
|
||||||
workflowId,
|
workflowId,
|
||||||
workflowName: optionalString(payload.workflowName, workflowId),
|
workflowName: optionalString(payload.workflowName, workflowId),
|
||||||
targetUserId,
|
targetUserId,
|
||||||
targetEmail,
|
targetEmail,
|
||||||
targetName: nullableStringWithFallback(payload.targetName, null),
|
targetName: nullableStringWithFallback(payload.targetName, null),
|
||||||
role: normalizeEngineWorkflowRole(payload.role),
|
role: normalizeEngineWorkflowRole(payload.role),
|
||||||
|
requestedServiceRole: requestType === "service_role"
|
||||||
|
? normalizeEngineServiceRole(payload.requestedServiceRole ?? payload.requestedRole ?? payload.serviceRole)
|
||||||
|
: nullableStringWithFallback(payload.requestedServiceRole, null),
|
||||||
|
currentServiceRole: normalizeNullableEngineServiceRole(payload.currentServiceRole ?? payload.currentRole),
|
||||||
requesterUserId: nullableStringWithFallback(payload.requesterUserId, null),
|
requesterUserId: nullableStringWithFallback(payload.requesterUserId, null),
|
||||||
requesterEmail,
|
requesterEmail,
|
||||||
requesterName: optionalString(payload.requesterName, requesterEmail),
|
requesterName: optionalString(payload.requesterName, requesterEmail),
|
||||||
|
|
@ -2671,6 +2795,49 @@ function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) {
|
||||||
return grant;
|
return grant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDirectEngineServiceRole(data, serviceId, userId) {
|
||||||
|
const denied = data.exceptions.some(
|
||||||
|
(exception) => exception.serviceId === serviceId && exception.userId === userId && exception.type === "deny"
|
||||||
|
);
|
||||||
|
if (denied) return null;
|
||||||
|
|
||||||
|
const grant = data.grants.find(
|
||||||
|
(candidate) => candidate.serviceId === serviceId && candidate.targetType === "user" && candidate.targetId === userId
|
||||||
|
);
|
||||||
|
if (!grant || grant.status !== "active") return null;
|
||||||
|
if (grant.appRole === "viewer") return "viewer";
|
||||||
|
if (grant.appRole === "member") return "member";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEngineRoleChangeNotification(data, { actor, currentServiceRole, serviceRole, targetUser, now }) {
|
||||||
|
const requestedServiceRole = normalizeEngineServiceRole(serviceRole);
|
||||||
|
const request = {
|
||||||
|
id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_change", `${targetUser.email}-${now}`),
|
||||||
|
requestType: "service_role",
|
||||||
|
workflowId: "engine:service-role",
|
||||||
|
workflowName: "NODE.DC Engine",
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
targetEmail: normalizeEmail(targetUser.email),
|
||||||
|
targetName: targetUser.name ?? null,
|
||||||
|
role: requestedServiceRole === "viewer" ? "viewer" : "editor",
|
||||||
|
requestedServiceRole,
|
||||||
|
currentServiceRole: normalizeNullableEngineServiceRole(currentServiceRole),
|
||||||
|
requesterUserId: actor.id ?? null,
|
||||||
|
requesterEmail: normalizeEmail(actor.email) || "admin@nodedc.ru",
|
||||||
|
requesterName: actor.name || actor.email || "Администратор",
|
||||||
|
status: "approved",
|
||||||
|
reviewedByUserId: actor.id ?? null,
|
||||||
|
reviewedAt: now,
|
||||||
|
engineAppliedAt: now,
|
||||||
|
comment: "admin_role_changed",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
data.engineWorkflowAccessRequests.push(request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, now) {
|
function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, now) {
|
||||||
const invitedByUserId = request.requesterUserId || actor?.id || null;
|
const invitedByUserId = request.requesterUserId || actor?.id || null;
|
||||||
let membership = data.memberships.find(
|
let membership = data.memberships.find(
|
||||||
|
|
@ -3099,6 +3266,19 @@ function normalizeEngineWorkflowRole(value) {
|
||||||
return engineWorkflowRoles.has(normalized) ? normalized : "viewer";
|
return engineWorkflowRoles.has(normalized) ? normalized : "viewer";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEngineServiceRole(value) {
|
||||||
|
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||||
|
if (normalized === "guest" || normalized === "viewer" || normalized === "read_only" || normalized === "readonly") return "viewer";
|
||||||
|
if (normalized === "participant" || normalized === "editor" || normalized === "member") return "member";
|
||||||
|
return engineServiceRoles.has(normalized) ? normalized : "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableEngineServiceRole(value) {
|
||||||
|
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||||
|
if (!normalized || normalized === "null" || normalized === "undefined") return null;
|
||||||
|
return normalizeEngineServiceRole(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
function isValidEmail(email) {
|
function isValidEmail(email) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||||
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
|
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
|
||||||
import { createControlPlaneStore } from "./control-plane-store.mjs";
|
import { createControlPlaneStore } from "./control-plane-store.mjs";
|
||||||
|
import { createNotificationCoreClient } from "./notification-core-client.mjs";
|
||||||
|
|
||||||
const serverRoot = dirname(fileURLToPath(import.meta.url));
|
const serverRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
const projectRoot = resolve(serverRoot, "..");
|
const projectRoot = resolve(serverRoot, "..");
|
||||||
|
|
@ -34,6 +35,7 @@ const controlPlaneStore = createControlPlaneStore({ projectRoot });
|
||||||
const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot);
|
const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot);
|
||||||
const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot);
|
const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot);
|
||||||
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
|
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
|
||||||
|
const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.notificationCoreBaseUrl, token: config.internalAccessToken });
|
||||||
const pendingLogins = new Map();
|
const pendingLogins = new Map();
|
||||||
const serviceHandoffs = new Map();
|
const serviceHandoffs = new Map();
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
@ -58,6 +60,7 @@ app.get("/healthz", (_req, res) => {
|
||||||
oidcConfigured: config.oidcConfigured,
|
oidcConfigured: config.oidcConfigured,
|
||||||
authentikApiConfigured: authentikSyncClient.isConfigured(),
|
authentikApiConfigured: authentikSyncClient.isConfigured(),
|
||||||
internalAccessApiConfigured: Boolean(config.internalAccessToken),
|
internalAccessApiConfigured: Boolean(config.internalAccessToken),
|
||||||
|
notificationCoreConfigured: notificationCoreClient.isConfigured(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -99,6 +102,28 @@ app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
publishControlPlaneEvent("access-request.created", [result.user.id]);
|
publishControlPlaneEvent("access-request.created", [result.user.id]);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "nodedc.access_request.created",
|
||||||
|
sourceService: "launcher",
|
||||||
|
actor: { userId: result.user.id, email: result.user.email },
|
||||||
|
subject: { type: "access_request", id: result.accessRequest.id },
|
||||||
|
payload: {
|
||||||
|
accessRequestId: result.accessRequest.id,
|
||||||
|
userId: result.user.id,
|
||||||
|
email: result.user.email,
|
||||||
|
status: result.accessRequest.status,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:created:${result.accessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
rootAdminNotificationDelivery(result.data, {
|
||||||
|
...buildAccessRequestHubNotification(result.accessRequest),
|
||||||
|
actionable: true,
|
||||||
|
actionType: "nodedc.access_request.review",
|
||||||
|
entityType: "access_request",
|
||||||
|
entityId: result.accessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
res.status(201).json({ accessRequest: result.accessRequest });
|
res.status(201).json({ accessRequest: result.accessRequest });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendAccessRequestApiError(res, error);
|
sendAccessRequestApiError(res, error);
|
||||||
|
|
@ -268,6 +293,92 @@ app.get("/api/me", (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/notifications", requireSession, asyncRoute(async (req, res) => {
|
||||||
|
if (!notificationCoreClient.isConfigured()) {
|
||||||
|
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||||
|
const user = findLauncherUser(data, actor.id);
|
||||||
|
const payload = await notificationCoreClient.listNotifications({
|
||||||
|
subject: notificationSubjectFromUser(user),
|
||||||
|
surface: sanitizeNotificationSurface(req.query.surface, "hub"),
|
||||||
|
limit: req.query.limit,
|
||||||
|
unreadOnly: req.query.unreadOnly === "1" || req.query.unreadOnly === "true",
|
||||||
|
});
|
||||||
|
res.json(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/notifications/:deliveryId/read", requireSession, asyncRoute(async (req, res) => {
|
||||||
|
if (!notificationCoreClient.isConfigured()) {
|
||||||
|
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||||
|
const user = findLauncherUser(data, actor.id);
|
||||||
|
const payload = await notificationCoreClient.markNotificationRead({
|
||||||
|
subject: notificationSubjectFromUser(user),
|
||||||
|
deliveryId: req.params.deliveryId,
|
||||||
|
});
|
||||||
|
res.json(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/notifications/read-all", requireSession, asyncRoute(async (req, res) => {
|
||||||
|
if (!notificationCoreClient.isConfigured()) {
|
||||||
|
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||||
|
const user = findLauncherUser(data, actor.id);
|
||||||
|
const payload = await notificationCoreClient.markAllNotificationsRead({
|
||||||
|
subject: notificationSubjectFromUser(user),
|
||||||
|
surface: sanitizeNotificationSurface(req.query.surface ?? req.body?.surface, "hub"),
|
||||||
|
});
|
||||||
|
res.json(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get("/api/notifications/stream", requireSession, asyncRoute(async (req, res) => {
|
||||||
|
if (!notificationCoreClient.isConfigured()) {
|
||||||
|
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||||
|
const user = findLauncherUser(data, actor.id);
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
req.on("close", () => {
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const upstream = await notificationCoreClient.openNotificationStream({
|
||||||
|
subject: notificationSubjectFromUser(user),
|
||||||
|
surface: sanitizeNotificationSurface(req.query.surface, "hub"),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.flushHeaders?.();
|
||||||
|
|
||||||
|
const reader = upstream.body.getReader();
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
res.write(Buffer.from(value));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
app.get("/api/apps", (req, res) => {
|
app.get("/api/apps", (req, res) => {
|
||||||
const session = getCurrentSession(req);
|
const session = getCurrentSession(req);
|
||||||
|
|
||||||
|
|
@ -514,6 +625,39 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) =>
|
||||||
"tasker.invite-request.created",
|
"tasker.invite-request.created",
|
||||||
result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id]
|
result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id]
|
||||||
);
|
);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: result.taskerInviteRequest.status === "approved" ? "tasker.workspace_invite.approved" : "tasker.workspace_invite.requested",
|
||||||
|
sourceService: "tasker",
|
||||||
|
actor: { userId: inviter.id, email: inviter.email },
|
||||||
|
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.taskerInviteRequest.id,
|
||||||
|
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
|
||||||
|
workspaceName: result.taskerInviteRequest.workspaceName,
|
||||||
|
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
|
||||||
|
status: result.taskerInviteRequest.status,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:${result.taskerInviteRequest.status}:${result.taskerInviteRequest.updatedAt}`,
|
||||||
|
deliveries: (
|
||||||
|
result.taskerInviteRequest.status === "new"
|
||||||
|
? [
|
||||||
|
rootAdminNotificationDelivery(result.data, {
|
||||||
|
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||||
|
actionable: true,
|
||||||
|
actionType: "tasker.workspace_invite.review",
|
||||||
|
entityType: "tasker_invite_request",
|
||||||
|
entityId: result.taskerInviteRequest.id,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
|
||||||
|
notificationDeliveryForUser(user, "hub", {
|
||||||
|
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||||
|
entityType: "tasker_invite_request",
|
||||||
|
entityId: result.taskerInviteRequest.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter(Boolean),
|
||||||
|
});
|
||||||
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest, autoApproved: Boolean(result.autoApproved) });
|
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest, autoApproved: Boolean(result.autoApproved) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -607,9 +751,137 @@ app.post("/api/internal/engine/workflow-access-requests", asyncRoute(async (req,
|
||||||
"engine.workflow-access-request.created",
|
"engine.workflow-access-request.created",
|
||||||
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id]
|
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id]
|
||||||
);
|
);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "engine.workflow_share.access_requested",
|
||||||
|
sourceService: "engine",
|
||||||
|
actor: { userId: requester.id, email: requester.email },
|
||||||
|
subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.engineWorkflowAccessRequest.id,
|
||||||
|
workflowId: result.engineWorkflowAccessRequest.workflowId,
|
||||||
|
workflowName: result.engineWorkflowAccessRequest.workflowName,
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
targetEmail: targetUser.email,
|
||||||
|
role: result.engineWorkflowAccessRequest.role,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
rootAdminNotificationDelivery(result.data, {
|
||||||
|
...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest),
|
||||||
|
actionable: true,
|
||||||
|
actionType: "engine.workflow_access.review",
|
||||||
|
entityType: "engine_workflow_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
|
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.post("/api/internal/engine/role-access-requests", asyncRoute(async (req, res) => {
|
||||||
|
if (!isInternalRequestAuthorized(req)) {
|
||||||
|
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||||
|
ok: false,
|
||||||
|
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request" });
|
||||||
|
const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {};
|
||||||
|
const requester = findInternalAccessUser(snapshot.data, {
|
||||||
|
subject: requesterPayload.subject,
|
||||||
|
email: requesterPayload.email,
|
||||||
|
userId: requesterPayload.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requester || requester.globalStatus !== "active") {
|
||||||
|
res.status(404).json({ ok: false, error: "requester_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await controlPlaneStore.createEngineRoleAccessRequest({
|
||||||
|
requestedRole: req.body?.requestedRole,
|
||||||
|
currentRole: req.body?.currentRole,
|
||||||
|
requesterUserId: requester.id,
|
||||||
|
requesterEmail: requester.email,
|
||||||
|
requesterName: requester.name,
|
||||||
|
}, requester);
|
||||||
|
|
||||||
|
publishControlPlaneEvent(
|
||||||
|
"engine.role-access-request.created",
|
||||||
|
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, "user_root"]
|
||||||
|
);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "engine.role_upgrade.requested",
|
||||||
|
sourceService: "engine",
|
||||||
|
actor: { userId: requester.id, email: requester.email },
|
||||||
|
subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.engineWorkflowAccessRequest.id,
|
||||||
|
targetUserId: requester.id,
|
||||||
|
targetEmail: requester.email,
|
||||||
|
requestedServiceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
|
||||||
|
currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
rootAdminNotificationDelivery(result.data, {
|
||||||
|
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
|
||||||
|
actionable: true,
|
||||||
|
actionType: "engine.role_upgrade.review",
|
||||||
|
entityType: "engine_role_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/internal/engine/role-access-requests/list", asyncRoute(async (req, res) => {
|
||||||
|
if (!isInternalRequestAuthorized(req)) {
|
||||||
|
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||||
|
ok: false,
|
||||||
|
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request list" });
|
||||||
|
const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {};
|
||||||
|
const requester = findInternalAccessUser(snapshot.data, {
|
||||||
|
subject: requesterPayload.subject,
|
||||||
|
email: requesterPayload.email,
|
||||||
|
userId: requesterPayload.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requester || requester.globalStatus !== "active") {
|
||||||
|
res.status(404).json({ ok: false, error: "requester_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterEmail = String(requester.email || "").toLowerCase();
|
||||||
|
const requesterGroups = Array.isArray(requesterPayload.groups)
|
||||||
|
? requesterPayload.groups.map((group) => String(group || "").trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const canReviewEngineRoleRequests = requester.id === "user_root" || isLauncherAdmin(requesterGroups);
|
||||||
|
const requests = snapshot.data.engineWorkflowAccessRequests
|
||||||
|
.filter((request) => {
|
||||||
|
if (request.requestType !== "service_role" || request.status === "cancelled") return false;
|
||||||
|
const isRelated =
|
||||||
|
request.requesterUserId === requester.id ||
|
||||||
|
request.targetUserId === requester.id ||
|
||||||
|
String(request.requesterEmail || "").toLowerCase() === requesterEmail ||
|
||||||
|
String(request.targetEmail || "").toLowerCase() === requesterEmail;
|
||||||
|
|
||||||
|
if (canReviewEngineRoleRequests && request.status === "new") return true;
|
||||||
|
return isRelated && request.status !== "new";
|
||||||
|
})
|
||||||
|
.sort((left, right) => String(right.updatedAt || right.createdAt).localeCompare(String(left.updatedAt || left.createdAt)));
|
||||||
|
|
||||||
|
res.json({ ok: true, requests });
|
||||||
|
}));
|
||||||
|
|
||||||
app.post("/api/internal/tasker/profile-sync", asyncRoute(async (req, res) => {
|
app.post("/api/internal/tasker/profile-sync", asyncRoute(async (req, res) => {
|
||||||
if (!isInternalRequestAuthorized(req)) {
|
if (!isInternalRequestAuthorized(req)) {
|
||||||
res.status(config.internalAccessToken ? 401 : 503).json({
|
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||||
|
|
@ -1358,6 +1630,28 @@ app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherA
|
||||||
}
|
}
|
||||||
|
|
||||||
publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []);
|
publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []);
|
||||||
|
if (result.user) {
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "nodedc.access_request.approved",
|
||||||
|
sourceService: "launcher",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: { type: "access_request", id: result.accessRequest.id },
|
||||||
|
payload: {
|
||||||
|
accessRequestId: result.accessRequest.id,
|
||||||
|
userId: result.user.id,
|
||||||
|
email: result.user.email,
|
||||||
|
status: result.accessRequest.status,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:approved:${result.accessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
notificationDeliveryForUser(result.user, "hub", {
|
||||||
|
...buildAccessRequestHubNotification(result.accessRequest),
|
||||||
|
entityType: "access_request",
|
||||||
|
entityId: result.accessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json(scopeAdminMutationResult(req, result));
|
res.json(scopeAdminMutationResult(req, result));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendAccessRequestApiError(res, error);
|
sendAccessRequestApiError(res, error);
|
||||||
|
|
@ -1368,6 +1662,29 @@ app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAd
|
||||||
try {
|
try {
|
||||||
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.access-request.rejected");
|
publishControlPlaneEvent("admin.access-request.rejected");
|
||||||
|
const targetUser = result.data.users.find((user) => user.email.toLowerCase() === result.accessRequest.email.toLowerCase()) ?? null;
|
||||||
|
if (targetUser) {
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "nodedc.access_request.rejected",
|
||||||
|
sourceService: "launcher",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: { type: "access_request", id: result.accessRequest.id },
|
||||||
|
payload: {
|
||||||
|
accessRequestId: result.accessRequest.id,
|
||||||
|
userId: targetUser.id,
|
||||||
|
email: targetUser.email,
|
||||||
|
status: result.accessRequest.status,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:rejected:${result.accessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
notificationDeliveryForUser(targetUser, "hub", {
|
||||||
|
...buildAccessRequestHubNotification(result.accessRequest),
|
||||||
|
entityType: "access_request",
|
||||||
|
entityId: result.accessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json(scopeAdminMutationResult(req, result));
|
res.json(scopeAdminMutationResult(req, result));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendAccessRequestApiError(res, error);
|
sendAccessRequestApiError(res, error);
|
||||||
|
|
@ -1407,6 +1724,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", req
|
||||||
);
|
);
|
||||||
|
|
||||||
publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]);
|
publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "tasker.workspace_invite.approved",
|
||||||
|
sourceService: "tasker",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.taskerInviteRequest.id,
|
||||||
|
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
|
||||||
|
workspaceName: result.taskerInviteRequest.workspaceName,
|
||||||
|
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
|
||||||
|
status: result.taskerInviteRequest.status,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:approved:${result.taskerInviteRequest.updatedAt}`,
|
||||||
|
deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
|
||||||
|
notificationDeliveryForUser(user, "hub", {
|
||||||
|
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||||
|
entityType: "tasker_invite_request",
|
||||||
|
entityId: result.taskerInviteRequest.id,
|
||||||
|
})
|
||||||
|
).filter(Boolean),
|
||||||
|
});
|
||||||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -1429,6 +1767,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requ
|
||||||
const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
|
||||||
|
|
||||||
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]);
|
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "tasker.workspace_invite.rejected",
|
||||||
|
sourceService: "tasker",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.taskerInviteRequest.id,
|
||||||
|
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
|
||||||
|
workspaceName: result.taskerInviteRequest.workspaceName,
|
||||||
|
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
|
||||||
|
status: result.taskerInviteRequest.status,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:rejected:${result.taskerInviteRequest.updatedAt}`,
|
||||||
|
deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
|
||||||
|
notificationDeliveryForUser(user, "hub", {
|
||||||
|
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||||
|
entityType: "tasker_invite_request",
|
||||||
|
entityId: result.taskerInviteRequest.id,
|
||||||
|
})
|
||||||
|
).filter(Boolean),
|
||||||
|
});
|
||||||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -1443,6 +1802,50 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.requestType === "service_role") {
|
||||||
|
const result = await controlPlaneStore.approveEngineWorkflowAccessRequest(
|
||||||
|
req.params.engineWorkflowAccessRequestId,
|
||||||
|
{
|
||||||
|
serviceRole: req.body?.serviceRole ?? req.body?.requestedServiceRole ?? req.body?.role,
|
||||||
|
comment: req.body?.comment,
|
||||||
|
},
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
const syncResult = await syncUsersToAuthentik(result.data, [result.targetUser.id], req.nodedcSession.user);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.engine-role-access-request.approved", [
|
||||||
|
result.engineWorkflowAccessRequest.requesterUserId,
|
||||||
|
result.targetUser.id,
|
||||||
|
]);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "engine.role_upgrade.approved",
|
||||||
|
sourceService: "launcher",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.engineWorkflowAccessRequest.id,
|
||||||
|
targetUserId: result.targetUser.id,
|
||||||
|
targetEmail: result.targetUser.email,
|
||||||
|
serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
notificationDeliveryForUser(result.targetUser, "hub", {
|
||||||
|
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
|
||||||
|
entityType: "engine_role_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
notificationDeliveryForUser(result.targetUser, "engine", {
|
||||||
|
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||||
|
entityType: "engine_role_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, {
|
const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, {
|
||||||
body: {
|
body: {
|
||||||
email: request.targetEmail,
|
email: request.targetEmail,
|
||||||
|
|
@ -1466,6 +1869,33 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
|
||||||
result.engineWorkflowAccessRequest.requesterUserId,
|
result.engineWorkflowAccessRequest.requesterUserId,
|
||||||
result.targetUser.id,
|
result.targetUser.id,
|
||||||
]);
|
]);
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "engine.workflow_share.approved",
|
||||||
|
sourceService: "launcher",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.engineWorkflowAccessRequest.id,
|
||||||
|
workflowId: result.engineWorkflowAccessRequest.workflowId,
|
||||||
|
workflowName: result.engineWorkflowAccessRequest.workflowName,
|
||||||
|
targetUserId: result.targetUser.id,
|
||||||
|
targetEmail: result.targetUser.email,
|
||||||
|
role: result.engineWorkflowAccessRequest.role,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
notificationDeliveryForUser(result.targetUser, "hub", {
|
||||||
|
...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest),
|
||||||
|
entityType: "engine_workflow_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
notificationDeliveryForUser(result.targetUser, "engine", {
|
||||||
|
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||||
|
entityType: "engine_workflow_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data, engine: engineResult }));
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data, engine: engineResult }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -1479,6 +1909,41 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
|
||||||
publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [
|
publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [
|
||||||
result.engineWorkflowAccessRequest.requesterUserId,
|
result.engineWorkflowAccessRequest.requesterUserId,
|
||||||
]);
|
]);
|
||||||
|
const targetUser = result.data.users.find((user) => user.id === result.engineWorkflowAccessRequest.targetUserId) ?? null;
|
||||||
|
if (targetUser) {
|
||||||
|
const isServiceRoleRequest = result.engineWorkflowAccessRequest.requestType === "service_role";
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: isServiceRoleRequest ? "engine.role_upgrade.rejected" : "engine.workflow_share.rejected",
|
||||||
|
sourceService: "launcher",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: {
|
||||||
|
type: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||||
|
id: result.engineWorkflowAccessRequest.id,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
requestId: result.engineWorkflowAccessRequest.id,
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
targetEmail: targetUser.email,
|
||||||
|
workflowId: result.engineWorkflowAccessRequest.workflowId,
|
||||||
|
workflowName: result.engineWorkflowAccessRequest.workflowName,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:engine-request:${result.engineWorkflowAccessRequest.id}:rejected:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||||
|
deliveries: [
|
||||||
|
notificationDeliveryForUser(targetUser, "hub", {
|
||||||
|
...(isServiceRoleRequest
|
||||||
|
? buildEngineRoleHubNotification(result.engineWorkflowAccessRequest)
|
||||||
|
: buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest)),
|
||||||
|
entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
notificationDeliveryForUser(targetUser, "engine", {
|
||||||
|
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||||
|
entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json(scopeAdminMutationResult(req, result));
|
res.json(scopeAdminMutationResult(req, result));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -1618,7 +2083,41 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn
|
||||||
|
|
||||||
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
|
publishControlPlaneEvent(
|
||||||
|
"admin.access.user-service.updated",
|
||||||
|
result.affectedUserIds?.length ? result.affectedUserIds : syncResult.userIds
|
||||||
|
);
|
||||||
|
if (result.engineWorkflowAccessRequest) {
|
||||||
|
const targetUser = syncResult.data.users.find((user) => user.id === req.body?.userId) ?? null;
|
||||||
|
if (targetUser) {
|
||||||
|
publishNotificationEvent({
|
||||||
|
type: "engine.role.changed",
|
||||||
|
sourceService: "launcher",
|
||||||
|
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||||
|
subject: { type: "engine_role_change", id: result.engineWorkflowAccessRequest.id },
|
||||||
|
payload: {
|
||||||
|
requestId: result.engineWorkflowAccessRequest.id,
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
targetEmail: targetUser.email,
|
||||||
|
serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
|
||||||
|
currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole,
|
||||||
|
},
|
||||||
|
idempotencyKey: `launcher:engine-role-change:${result.engineWorkflowAccessRequest.id}`,
|
||||||
|
deliveries: [
|
||||||
|
notificationDeliveryForUser(targetUser, "hub", {
|
||||||
|
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
|
||||||
|
entityType: "engine_role_change",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
notificationDeliveryForUser(targetUser, "engine", {
|
||||||
|
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||||
|
entityType: "engine_role_change",
|
||||||
|
entityId: result.engineWorkflowAccessRequest.id,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -1785,6 +2284,10 @@ function readConfig() {
|
||||||
process.env.NODEDC_ENGINE_BASE_URL ??
|
process.env.NODEDC_ENGINE_BASE_URL ??
|
||||||
process.env.ENGINE_BASE_URL ??
|
process.env.ENGINE_BASE_URL ??
|
||||||
"https://engine.nodedc.ru",
|
"https://engine.nodedc.ru",
|
||||||
|
notificationCoreBaseUrl:
|
||||||
|
process.env.NODEDC_NOTIFICATION_CORE_URL ??
|
||||||
|
process.env.NOTIFICATION_CORE_URL ??
|
||||||
|
"",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3320,6 +3823,239 @@ function findLauncherUser(data, userId) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notificationSubjectFromUser(user) {
|
||||||
|
return {
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
email: typeof user?.email === "string" ? user.email.toLowerCase() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationDeliveryForUser(user, surface, notification) {
|
||||||
|
if (!user?.id && !user?.email) return null;
|
||||||
|
return {
|
||||||
|
recipientUserId: user?.id ?? null,
|
||||||
|
recipientEmail: typeof user?.email === "string" ? user.email.toLowerCase() : null,
|
||||||
|
surface,
|
||||||
|
title: notification.title,
|
||||||
|
body: notification.body ?? "",
|
||||||
|
meta: notification.meta ?? {},
|
||||||
|
actionable: notification.actionable === true,
|
||||||
|
actionType: notification.actionType ?? null,
|
||||||
|
actionUrl: notification.actionUrl ?? null,
|
||||||
|
entityType: notification.entityType ?? null,
|
||||||
|
entityId: notification.entityId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootAdminNotificationDelivery(data, notification) {
|
||||||
|
const rootUser = data.users.find((user) => user.id === "user_root") ?? data.users[0] ?? null;
|
||||||
|
return notificationDeliveryForUser(rootUser, "hub", notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationUsersForTaskerInvite(data, request) {
|
||||||
|
const users = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const addUser = (user) => {
|
||||||
|
if (!user?.id || seen.has(user.id)) return;
|
||||||
|
seen.add(user.id);
|
||||||
|
users.push(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
addUser(data.users.find((user) => user.id === request?.inviterUserId));
|
||||||
|
addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviterEmail)));
|
||||||
|
addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviteeEmail)));
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccessRequestHubNotification(accessRequest) {
|
||||||
|
const applicantName = [accessRequest?.lastName, accessRequest?.firstName].filter(Boolean).join(" ") || accessRequest?.email || "NODE.DC user";
|
||||||
|
const status = accessRequest?.status || "new";
|
||||||
|
return {
|
||||||
|
title: status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён",
|
||||||
|
body: `${applicantName} · ${accessRequest?.email || ""}`,
|
||||||
|
meta: {
|
||||||
|
displayStatus: platformNotificationStatusLabel(status),
|
||||||
|
meta: accessRequest?.company || formatNotificationDate(accessRequest?.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskerInviteHubNotification(request) {
|
||||||
|
const status = request?.status || "new";
|
||||||
|
return {
|
||||||
|
title: status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена",
|
||||||
|
body: `${request?.workspaceName || request?.workspaceSlug || "Operational Core"} · ${request?.inviteeEmail || ""}`,
|
||||||
|
meta: {
|
||||||
|
displayStatus: platformNotificationStatusLabel(status),
|
||||||
|
meta: request?.inviterName || formatNotificationDate(request?.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEngineRoleHubNotification(request) {
|
||||||
|
const target = request?.targetName || request?.targetEmail || "Engine user";
|
||||||
|
const status = request?.status || "new";
|
||||||
|
return {
|
||||||
|
title: engineRoleHubNotificationTitle(request),
|
||||||
|
body: engineRoleHubNotificationBody(request, target),
|
||||||
|
meta: {
|
||||||
|
displayStatus: platformNotificationStatusLabel(status),
|
||||||
|
meta: `Запрошено: ${engineServiceRoleLabel(request?.requestedServiceRole)}`,
|
||||||
|
requestId: request?.id,
|
||||||
|
requestedServiceRole: request?.requestedServiceRole ?? null,
|
||||||
|
currentServiceRole: request?.currentServiceRole ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEngineWorkflowHubNotification(request) {
|
||||||
|
const status = request?.status || "new";
|
||||||
|
return {
|
||||||
|
title: status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена",
|
||||||
|
body: `${request?.workflowName || request?.workflowId || "Engine workflow"} · ${request?.targetEmail || ""}`,
|
||||||
|
meta: {
|
||||||
|
displayStatus: platformNotificationStatusLabel(status),
|
||||||
|
meta: request?.requesterName || formatNotificationDate(request?.createdAt),
|
||||||
|
requestId: request?.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEngineSurfaceNotification(request) {
|
||||||
|
if (request?.requestType === "service_role") {
|
||||||
|
return {
|
||||||
|
title: request?.workflowName || "NODE.DC Engine",
|
||||||
|
body: engineRoleSurfaceNotificationBody(request),
|
||||||
|
meta: {
|
||||||
|
displayStatus: engineSurfaceStatusLabel(request),
|
||||||
|
meta: engineSurfaceMeta(request),
|
||||||
|
requestId: request?.id,
|
||||||
|
requestedServiceRole: request?.requestedServiceRole ?? null,
|
||||||
|
currentServiceRole: request?.currentServiceRole ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: request?.workflowName || request?.workflowId || "Engine workflow",
|
||||||
|
body: `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${engineWorkflowRoleLabel(request?.role)}`,
|
||||||
|
meta: {
|
||||||
|
displayStatus: engineSurfaceStatusLabel(request),
|
||||||
|
meta: engineSurfaceMeta(request),
|
||||||
|
requestId: request?.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineRoleHubNotificationTitle(request) {
|
||||||
|
if (request?.status === "approved") return request?.comment === "admin_role_changed" ? "Engine: роль изменена" : "Engine: роль обновлена";
|
||||||
|
if (request?.status === "rejected") return "Engine: повышение роли отклонено";
|
||||||
|
return "Engine: запрос повышения роли";
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineRoleHubNotificationBody(request, target) {
|
||||||
|
if (request?.status === "rejected") return `${target} · администратор отклонил заявку`;
|
||||||
|
if (request?.status === "approved") {
|
||||||
|
return request?.comment === "admin_role_changed" ? `${target} · роль изменена администратором` : `${target} · новая роль применена`;
|
||||||
|
}
|
||||||
|
return `${target} · текущая роль ${engineServiceRoleLabel(request?.currentServiceRole)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineRoleSurfaceNotificationBody(request) {
|
||||||
|
const requestedRole = engineServiceRoleLabel(request?.requestedServiceRole);
|
||||||
|
const requestedByTarget = normalizeNotificationEmail(request?.requesterEmail) === normalizeNotificationEmail(request?.targetEmail);
|
||||||
|
if (request?.status === "approved") {
|
||||||
|
return requestedByTarget && request?.comment !== "admin_role_changed"
|
||||||
|
? `Администратор подтвердил повышение роли: ${requestedRole}`
|
||||||
|
: `Администратор изменил роль: ${requestedRole}`;
|
||||||
|
}
|
||||||
|
if (request?.status === "rejected") {
|
||||||
|
return "Администратор отклонил заявку на повышение роли";
|
||||||
|
}
|
||||||
|
return `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${requestedRole}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineSurfaceStatusLabel(request) {
|
||||||
|
if (request?.requestType === "service_role" && request?.status === "approved" && request?.comment === "admin_role_changed") {
|
||||||
|
return "Изменено";
|
||||||
|
}
|
||||||
|
if (request?.status === "approved") return "Одобрено";
|
||||||
|
if (request?.status === "rejected") return "Отклонено";
|
||||||
|
return "Новая заявка";
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineSurfaceMeta(request) {
|
||||||
|
const createdAt = formatShortNotificationDate(request?.createdAt);
|
||||||
|
return ["Ваш запрос", createdAt].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineServiceRoleLabel(role) {
|
||||||
|
if (role === "member") return "Участник";
|
||||||
|
if (role === "viewer") return "Гость";
|
||||||
|
return "не назначена";
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineWorkflowRoleLabel(role) {
|
||||||
|
if (role === "admin") return "Соавтор";
|
||||||
|
if (role === "editor") return "Редактор";
|
||||||
|
return "Просмотр";
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformNotificationStatusLabel(status) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
if (normalized === "approved") return "Подтверждено";
|
||||||
|
if (normalized === "rejected") return "Отклонено";
|
||||||
|
if (normalized === "cancelled") return "Отменено";
|
||||||
|
return "Входящее";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNotificationDate(value) {
|
||||||
|
const timestamp = Date.parse(String(value || ""));
|
||||||
|
if (!Number.isFinite(timestamp)) return "";
|
||||||
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortNotificationDate(value) {
|
||||||
|
const timestamp = Date.parse(String(value || ""));
|
||||||
|
if (!Number.isFinite(timestamp)) return "";
|
||||||
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotificationEmail(value) {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeNotificationSurface(value, fallback = "hub") {
|
||||||
|
const surface = typeof value === "string" && value.trim() ? value.trim().toLowerCase() : fallback;
|
||||||
|
if (surface === "hub" || surface === "engine" || surface === "operational-core") return surface;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishNotificationEvent(command) {
|
||||||
|
if (!notificationCoreClient.isConfigured()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(command?.deliveries) || command.deliveries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationCoreClient.createEvent(command).catch((error) => {
|
||||||
|
console.warn("[notification-core] event publish failed:", error?.message || error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isLauncherAdmin(groups) {
|
function isLauncherAdmin(groups) {
|
||||||
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
|
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
|
||||||
}
|
}
|
||||||
|
|
@ -3511,6 +4247,12 @@ function scopeControlPlaneData(data, scope) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id));
|
const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id));
|
||||||
|
const userEmails = new Set(
|
||||||
|
data.users
|
||||||
|
.filter((user) => userIds.has(user.id))
|
||||||
|
.map((user) => String(user.email || "").toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -3521,7 +4263,12 @@ function scopeControlPlaneData(data, scope) {
|
||||||
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
|
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
|
||||||
accessRequests: [],
|
accessRequests: [],
|
||||||
taskerInviteRequests: [],
|
taskerInviteRequests: [],
|
||||||
engineWorkflowAccessRequests: [],
|
engineWorkflowAccessRequests: data.engineWorkflowAccessRequests.filter((request) =>
|
||||||
|
userIds.has(request.requesterUserId) ||
|
||||||
|
userIds.has(request.targetUserId) ||
|
||||||
|
userEmails.has(String(request.requesterEmail || "").toLowerCase()) ||
|
||||||
|
userEmails.has(String(request.targetEmail || "").toLowerCase())
|
||||||
|
),
|
||||||
grants: data.grants.filter((grant) => {
|
grants: data.grants.filter((grant) => {
|
||||||
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
||||||
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
||||||
|
|
@ -3574,6 +4321,13 @@ function scopeRuntimeControlPlaneData(data, userId) {
|
||||||
.filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
|
.filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
|
||||||
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
|
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
|
||||||
const groupIds = new Set(groups.map((group) => group.id));
|
const groupIds = new Set(groups.map((group) => group.id));
|
||||||
|
const userEmail = String(user.email || "").toLowerCase();
|
||||||
|
const relatedEngineRequests = data.engineWorkflowAccessRequests.filter((request) =>
|
||||||
|
request.requesterUserId === user.id ||
|
||||||
|
request.targetUserId === user.id ||
|
||||||
|
String(request.requesterEmail || "").toLowerCase() === userEmail ||
|
||||||
|
String(request.targetEmail || "").toLowerCase() === userEmail
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -3585,7 +4339,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
|
||||||
accessRequests: [],
|
accessRequests: [],
|
||||||
revokedAccounts: [],
|
revokedAccounts: [],
|
||||||
taskerInviteRequests: [],
|
taskerInviteRequests: [],
|
||||||
engineWorkflowAccessRequests: [],
|
engineWorkflowAccessRequests: relatedEngineRequests,
|
||||||
grants: data.grants.filter((grant) => {
|
grants: data.grants.filter((grant) => {
|
||||||
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
||||||
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
export function createNotificationCoreClient({ baseUrl, token }) {
|
||||||
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
const internalToken = typeof token === "string" ? token.trim() : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConfigured() {
|
||||||
|
return Boolean(normalizedBaseUrl && internalToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createEvent(command) {
|
||||||
|
return requestJson({
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
token: internalToken,
|
||||||
|
method: "POST",
|
||||||
|
pathname: "/internal/notifications/events",
|
||||||
|
body: command,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async listNotifications({ subject, surface, limit, unreadOnly }) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set("surface", surface || "hub");
|
||||||
|
if (limit) searchParams.set("limit", String(limit));
|
||||||
|
if (unreadOnly) searchParams.set("unreadOnly", "1");
|
||||||
|
return requestJson({
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
token: internalToken,
|
||||||
|
method: "GET",
|
||||||
|
pathname: `/api/notifications?${searchParams.toString()}`,
|
||||||
|
subject,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async markNotificationRead({ subject, deliveryId }) {
|
||||||
|
return requestJson({
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
token: internalToken,
|
||||||
|
method: "POST",
|
||||||
|
pathname: `/api/notifications/${encodeURIComponent(deliveryId)}/read`,
|
||||||
|
subject,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAllNotificationsRead({ subject, surface }) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set("surface", surface || "hub");
|
||||||
|
return requestJson({
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
token: internalToken,
|
||||||
|
method: "POST",
|
||||||
|
pathname: `/api/notifications/read-all?${searchParams.toString()}`,
|
||||||
|
subject,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async openNotificationStream({ subject, surface, signal }) {
|
||||||
|
if (!normalizedBaseUrl || !internalToken) {
|
||||||
|
throw new Error("notification_core_not_configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set("surface", surface || "hub");
|
||||||
|
const response = await fetch(`${normalizedBaseUrl}/api/notifications/stream?${searchParams.toString()}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildHeaders(internalToken, subject),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(await readError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson({ baseUrl, token, method, pathname, body, subject }) {
|
||||||
|
if (!baseUrl || !token) {
|
||||||
|
throw new Error("notification_core_not_configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||||
|
method,
|
||||||
|
headers: buildHeaders(token, subject, body !== undefined),
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = await response.json();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(parsed?.error ? String(parsed.error) : `notification_core_http_${response.status}`);
|
||||||
|
error.status = response.status;
|
||||||
|
error.payload = parsed;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaders(token, subject, hasJsonBody = false) {
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
if (hasJsonBody) headers["Content-Type"] = "application/json";
|
||||||
|
if (subject?.userId) headers["X-NODEDC-User-Id"] = subject.userId;
|
||||||
|
if (subject?.email) headers["X-NODEDC-User-Email"] = subject.email;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(response) {
|
||||||
|
try {
|
||||||
|
const parsed = await response.json();
|
||||||
|
if (parsed?.error) return String(parsed.error);
|
||||||
|
} catch {}
|
||||||
|
return `notification_core_http_${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim().replace(/\/+$/, "") : "";
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,7 @@ import {
|
||||||
} from "../shared/api/authApi";
|
} from "../shared/api/authApi";
|
||||||
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
||||||
import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi";
|
import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi";
|
||||||
|
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationDelivery } from "../shared/api/notificationsApi";
|
||||||
import type { CreateAccessRequestCommand } from "../entities/access-request/types";
|
import type { CreateAccessRequestCommand } from "../entities/access-request/types";
|
||||||
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||||
|
|
@ -114,6 +115,7 @@ export function LauncherApp() {
|
||||||
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
|
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
|
||||||
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
|
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
|
||||||
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
|
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
|
||||||
|
const [hubNotifications, setHubNotifications] = useState<LauncherNotificationItem[]>([]);
|
||||||
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
|
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
|
||||||
const runtimeDataRef = useRef(data);
|
const runtimeDataRef = useRef(data);
|
||||||
const runtimeProfileIdRef = useRef(activeProfileId);
|
const runtimeProfileIdRef = useRef(activeProfileId);
|
||||||
|
|
@ -155,7 +157,6 @@ export function LauncherApp() {
|
||||||
};
|
};
|
||||||
}, [authSession, me]);
|
}, [authSession, me]);
|
||||||
const resolvedClientId = me.activeClientId;
|
const resolvedClientId = me.activeClientId;
|
||||||
const notifications = useMemo(() => buildLauncherNotifications(data, runtimeMe), [data, runtimeMe]);
|
|
||||||
const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin);
|
const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin);
|
||||||
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
|
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
|
||||||
const launcherServices = useMemo(
|
const launcherServices = useMemo(
|
||||||
|
|
@ -260,6 +261,30 @@ export function LauncherApp() {
|
||||||
return request;
|
return request;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadHubNotifications = useCallback(async () => {
|
||||||
|
if (!authSession?.authenticated) {
|
||||||
|
setHubNotifications([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deliveries = await fetchNotifications("hub");
|
||||||
|
setHubNotifications(deliveries.map(mapNotificationDeliveryToLauncherItem));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setHubNotifications([]);
|
||||||
|
console.warn(error instanceof Error ? error.message : "Не удалось загрузить уведомления Hub");
|
||||||
|
}
|
||||||
|
}, [authSession?.authenticated]);
|
||||||
|
|
||||||
|
const markHubNotificationsRead = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead("hub");
|
||||||
|
await loadHubNotifications();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.warn(error instanceof Error ? error.message : "Не удалось отметить уведомления Hub прочитанными");
|
||||||
|
}
|
||||||
|
}, [loadHubNotifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
|
|
@ -402,6 +427,32 @@ export function LauncherApp() {
|
||||||
};
|
};
|
||||||
}, [authSession?.authenticated, refreshRuntimeState]);
|
}, [authSession?.authenticated, refreshRuntimeState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authSession?.authenticated) {
|
||||||
|
setHubNotifications([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadHubNotifications();
|
||||||
|
|
||||||
|
const eventSource = new EventSource("/api/notifications/stream?surface=hub");
|
||||||
|
const refreshHubNotifications = () => {
|
||||||
|
void loadHubNotifications();
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.addEventListener("notification.ready", refreshHubNotifications);
|
||||||
|
eventSource.addEventListener("notification.delivery.created", refreshHubNotifications);
|
||||||
|
eventSource.addEventListener("notification.delivery.read", refreshHubNotifications);
|
||||||
|
eventSource.addEventListener("notification.deliveries.read-all", refreshHubNotifications);
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
console.warn("Hub notification stream disconnected; browser will retry automatically");
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [authSession?.authenticated, loadHubNotifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession?.authenticated) return;
|
if (!authSession?.authenticated) return;
|
||||||
|
|
||||||
|
|
@ -698,14 +749,14 @@ export function LauncherApp() {
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Parameters<typeof approveAdminEngineWorkflowAccessRequest>[1]
|
patch: Parameters<typeof approveAdminEngineWorkflowAccessRequest>[1]
|
||||||
) {
|
) {
|
||||||
applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
return applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRejectEngineWorkflowAccessRequest(
|
function handleRejectEngineWorkflowAccessRequest(
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Parameters<typeof rejectAdminEngineWorkflowAccessRequest>[1]
|
patch: Parameters<typeof rejectAdminEngineWorkflowAccessRequest>[1]
|
||||||
) {
|
) {
|
||||||
applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
return applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRetrySync(syncId: string) {
|
function handleRetrySync(syncId: string) {
|
||||||
|
|
@ -883,7 +934,20 @@ export function LauncherApp() {
|
||||||
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
brandLinkUrl={data.settings.brand.logoLinkUrl}
|
brandLinkUrl={data.settings.brand.logoLinkUrl}
|
||||||
notifications={notifications}
|
notifications={hubNotifications}
|
||||||
|
onMarkNotificationsRead={markHubNotificationsRead}
|
||||||
|
onResolveEngineRoleRequest={async (requestId, action, serviceRole, deliveryId) => {
|
||||||
|
const outcome = action === "approve"
|
||||||
|
? await handleApproveEngineWorkflowAccessRequest(requestId, { serviceRole: serviceRole ?? "member" })
|
||||||
|
: await handleRejectEngineWorkflowAccessRequest(requestId, {});
|
||||||
|
|
||||||
|
if (!outcome.ok) return;
|
||||||
|
|
||||||
|
if (deliveryId) {
|
||||||
|
await markNotificationRead(deliveryId);
|
||||||
|
}
|
||||||
|
await loadHubNotifications();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="launcher-main">
|
<main className="launcher-main">
|
||||||
|
|
@ -1518,82 +1582,52 @@ function parseInviteToken(pathname: string) {
|
||||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLauncherNotifications(data: LauncherData, me: ReturnType<typeof buildMe>): LauncherNotificationItem[] {
|
function mapNotificationDeliveryToLauncherItem(delivery: NotificationDelivery): LauncherNotificationItem {
|
||||||
const currentUserId = me.user.id;
|
const requestId = notificationString(delivery.entityId)
|
||||||
const currentEmail = me.user.email.toLowerCase();
|
?? notificationString(delivery.meta?.requestId)
|
||||||
const canModerate = me.permissions.canOpenAdmin;
|
?? notificationString(delivery.eventPayload?.requestId);
|
||||||
const items: LauncherNotificationItem[] = [];
|
const requestedServiceRole = notificationEngineServiceRole(
|
||||||
|
delivery.meta?.requestedServiceRole ?? delivery.eventPayload?.requestedServiceRole ?? delivery.eventPayload?.serviceRole
|
||||||
|
);
|
||||||
|
|
||||||
for (const request of data.accessRequests) {
|
return {
|
||||||
const isOwnRequest = request.email.toLowerCase() === currentEmail;
|
id: delivery.id,
|
||||||
if (!canModerate && !isOwnRequest) continue;
|
kind: notificationKindFromDelivery(delivery),
|
||||||
|
title: delivery.title,
|
||||||
const applicantName = [request.lastName, request.firstName].filter(Boolean).join(" ") || request.email;
|
description: delivery.body,
|
||||||
items.push({
|
meta: notificationString(delivery.meta?.meta),
|
||||||
id: `nodedc:${request.id}`,
|
displayStatus: notificationString(delivery.meta?.displayStatus),
|
||||||
kind: "nodedc",
|
status: delivery.status,
|
||||||
title: request.status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён",
|
createdAt: delivery.createdAt,
|
||||||
description: `${applicantName} · ${request.email}`,
|
updatedAt: delivery.updatedAt,
|
||||||
meta: request.company || formatNotificationDate(request.createdAt),
|
actionKind: delivery.actionType === "engine.role_upgrade.review" ? "engine-role-request" : undefined,
|
||||||
status: request.status,
|
requestId: delivery.actionType === "engine.role_upgrade.review" ? requestId : undefined,
|
||||||
createdAt: request.createdAt,
|
requestedServiceRole,
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
for (const request of data.taskerInviteRequests) {
|
|
||||||
const isRelated =
|
|
||||||
request.inviterUserId === currentUserId ||
|
|
||||||
request.inviterEmail.toLowerCase() === currentEmail ||
|
|
||||||
request.inviteeEmail.toLowerCase() === currentEmail;
|
|
||||||
if (!canModerate && !isRelated) continue;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: `tasker:${request.id}`,
|
|
||||||
kind: "operational-core",
|
|
||||||
title: request.status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена",
|
|
||||||
description: `${request.workspaceName} · ${request.inviteeEmail}`,
|
|
||||||
meta: request.inviterName || formatNotificationDate(request.createdAt),
|
|
||||||
status: request.status,
|
|
||||||
createdAt: request.createdAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const request of data.engineWorkflowAccessRequests) {
|
|
||||||
const isRelated =
|
|
||||||
request.requesterUserId === currentUserId ||
|
|
||||||
request.targetUserId === currentUserId ||
|
|
||||||
request.requesterEmail.toLowerCase() === currentEmail ||
|
|
||||||
request.targetEmail.toLowerCase() === currentEmail;
|
|
||||||
if (!canModerate && !isRelated) continue;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: `engine:${request.id}`,
|
|
||||||
kind: "engine",
|
|
||||||
title: request.status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена",
|
|
||||||
description: `${request.workflowName} · ${request.targetEmail}`,
|
|
||||||
meta: request.requesterName || formatNotificationDate(request.createdAt),
|
|
||||||
status: request.status,
|
|
||||||
createdAt: request.createdAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.sort((left, right) => {
|
|
||||||
if (left.status === "new" && right.status !== "new") return -1;
|
|
||||||
if (left.status !== "new" && right.status === "new") return 1;
|
|
||||||
return Date.parse(right.createdAt) - Date.parse(left.createdAt);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNotificationDate(value: string) {
|
function notificationKindFromDelivery(delivery: NotificationDelivery): LauncherNotificationItem["kind"] {
|
||||||
const timestamp = Date.parse(value);
|
const source = String(delivery.sourceService || "").toLowerCase();
|
||||||
if (!Number.isFinite(timestamp)) return value;
|
const eventType = String(delivery.eventType || "").toLowerCase();
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
const entityType = String(delivery.entityType || "").toLowerCase();
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
if (source === "tasker" || source === "operational-core" || eventType.startsWith("tasker.") || entityType.startsWith("tasker")) {
|
||||||
year: "numeric",
|
return "operational-core";
|
||||||
hour: "2-digit",
|
}
|
||||||
minute: "2-digit",
|
|
||||||
}).format(new Date(timestamp));
|
if (source === "engine" || eventType.startsWith("engine.") || entityType.startsWith("engine_")) {
|
||||||
|
return "engine";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "nodedc";
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationEngineServiceRole(value: unknown): "viewer" | "member" | null {
|
||||||
|
return value === "viewer" || value === "member" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAccessRequestPath(pathname: string) {
|
function isAccessRequestPath(pathname: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
export type EngineWorkflowRole = "viewer" | "editor" | "admin";
|
export type EngineWorkflowRole = "viewer" | "editor" | "admin";
|
||||||
|
export type EngineServiceRole = "viewer" | "member";
|
||||||
|
export type EngineWorkflowAccessRequestType = "workflow" | "service_role";
|
||||||
export type EngineWorkflowAccessRequestStatus = "new" | "approved" | "rejected" | "cancelled";
|
export type EngineWorkflowAccessRequestStatus = "new" | "approved" | "rejected" | "cancelled";
|
||||||
|
|
||||||
export interface EngineWorkflowAccessRequest {
|
export interface EngineWorkflowAccessRequest {
|
||||||
id: string;
|
id: string;
|
||||||
|
requestType?: EngineWorkflowAccessRequestType;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
workflowName: string;
|
workflowName: string;
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
targetEmail: string;
|
targetEmail: string;
|
||||||
targetName?: string | null;
|
targetName?: string | null;
|
||||||
role: EngineWorkflowRole;
|
role: EngineWorkflowRole;
|
||||||
|
requestedServiceRole?: EngineServiceRole | null;
|
||||||
|
currentServiceRole?: EngineServiceRole | null;
|
||||||
requesterUserId?: string | null;
|
requesterUserId?: string | null;
|
||||||
requesterEmail: string;
|
requesterEmail: string;
|
||||||
requesterName: string;
|
requesterName: string;
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,7 @@ export async function rejectAdminTaskerInviteRequest(
|
||||||
|
|
||||||
export async function approveAdminEngineWorkflowAccessRequest(
|
export async function approveAdminEngineWorkflowAccessRequest(
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
payload: Partial<Pick<EngineWorkflowAccessRequest, "comment">> = {}
|
payload: Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & { serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"] } = {}
|
||||||
): Promise<EngineWorkflowAccessRequestMutationResult> {
|
): Promise<EngineWorkflowAccessRequestMutationResult> {
|
||||||
return requestJson<EngineWorkflowAccessRequestMutationResult>(
|
return requestJson<EngineWorkflowAccessRequestMutationResult>(
|
||||||
`/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`,
|
`/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
export type NotificationDeliveryStatus = "unread" | "read" | "archived";
|
||||||
|
export type NotificationSurface = "hub" | "engine" | "operational-core";
|
||||||
|
|
||||||
|
export interface NotificationDelivery {
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
eventType?: string | null;
|
||||||
|
sourceService?: string | null;
|
||||||
|
recipientUserId?: string | null;
|
||||||
|
recipientEmail?: string | null;
|
||||||
|
surface: NotificationSurface;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
status: NotificationDeliveryStatus;
|
||||||
|
unread?: boolean;
|
||||||
|
actionable?: boolean;
|
||||||
|
actionType?: string | null;
|
||||||
|
actionUrl?: string | null;
|
||||||
|
entityType?: string | null;
|
||||||
|
entityId?: string | null;
|
||||||
|
eventPayload?: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
readAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotifications(surface: NotificationSurface) {
|
||||||
|
const response = await fetch(`/api/notifications?surface=${encodeURIComponent(surface)}`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`notifications_failed_${response.status}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
return Array.isArray(payload?.deliveries) ? payload.deliveries as NotificationDelivery[] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(surface: NotificationSurface) {
|
||||||
|
const response = await fetch(`/api/notifications/read-all?surface=${encodeURIComponent(surface)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`notifications_read_all_failed_${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(deliveryId: string) {
|
||||||
|
const response = await fetch(`/api/notifications/${encodeURIComponent(deliveryId)}/read`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`notification_read_failed_${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
@ -4678,6 +4678,12 @@ code {
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-head__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-notifications-head h2 {
|
.nodedc-notifications-head h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
@ -4705,6 +4711,32 @@ code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-only-new {
|
||||||
|
min-height: 2.35rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-only-new:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-only-new[data-active="true"] {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #101014;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-only-new[data-active="true"]:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #101014;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-notifications-close:hover {
|
.nodedc-notifications-close:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
@ -4759,14 +4791,21 @@ code {
|
||||||
|
|
||||||
.nodedc-notification-card {
|
.nodedc-notification-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
border-radius: 1.05rem;
|
border-radius: 1.05rem;
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.044)),
|
||||||
|
rgba(20, 20, 24, 0.86);
|
||||||
padding: 0.95rem 1rem;
|
padding: 0.95rem 1rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-notification-card[data-status="new"] {
|
.nodedc-notification-card[data-status="new"] {
|
||||||
background: rgba(195, 255, 102, 0.1);
|
background:
|
||||||
|
linear-gradient(135deg, rgb(var(--nodedc-accent-rgb) / 0.22), rgb(var(--nodedc-accent-rgb) / 0.1)),
|
||||||
|
rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-notification-card__kicker,
|
.nodedc-notification-card__kicker,
|
||||||
|
|
@ -4790,10 +4829,67 @@ code {
|
||||||
|
|
||||||
.nodedc-notification-card__foot {
|
.nodedc-notification-card__foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__content {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__action-button,
|
||||||
|
.nodedc-notification-card__role-select {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.085);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 780;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__action-button:hover:not(:disabled),
|
||||||
|
.nodedc-notification-card__role-select:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__action-button:disabled,
|
||||||
|
.nodedc-notification-card__role-select:disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__role-select-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__role-select {
|
||||||
|
min-width: 10.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__role-select .nodedc-ui-select-trigger__text {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__role-menu {
|
||||||
|
z-index: 15050;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: rgba(28, 28, 32, 0.96);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||||
|
0 1.4rem 3rem rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-ui-profile-card__cover {
|
.nodedc-ui-profile-card__cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ type AdminSection =
|
||||||
| "company";
|
| "company";
|
||||||
type AdminOverlayMode = "admin" | "platform";
|
type AdminOverlayMode = "admin" | "platform";
|
||||||
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
|
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
|
||||||
|
type EngineWorkflowAccessRequestDecisionPatch =
|
||||||
|
Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & {
|
||||||
|
serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"];
|
||||||
|
};
|
||||||
|
|
||||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||||||
|
|
@ -255,11 +259,11 @@ export function AdminOverlay({
|
||||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
onApproveEngineWorkflowAccessRequest: (
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
onRejectEngineWorkflowAccessRequest: (
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
onRetrySync: (syncId: string) => void;
|
onRetrySync: (syncId: string) => void;
|
||||||
onCreateClient: () => void;
|
onCreateClient: () => void;
|
||||||
|
|
@ -3847,11 +3851,11 @@ function InvitesSection({
|
||||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
onApproveEngineWorkflowAccessRequest: (
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
onRejectEngineWorkflowAccessRequest: (
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
@ -4153,11 +4157,11 @@ function AccessRequestsPanel({
|
||||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
onApproveEngineWorkflowAccessRequest: (
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
onRejectEngineWorkflowAccessRequest: (
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
|
@ -4472,20 +4476,20 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
requests: EngineWorkflowAccessRequest[];
|
requests: EngineWorkflowAccessRequest[];
|
||||||
onApproveEngineWorkflowAccessRequest: (
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
onRejectEngineWorkflowAccessRequest: (
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
engineWorkflowAccessRequestId: string,
|
engineWorkflowAccessRequestId: string,
|
||||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h3>NODE.DC Engine: запросы доступа к workflow</h3>
|
<h3>NODE.DC Engine: запросы доступа</h3>
|
||||||
<p className="admin-helper-note">
|
<p className="admin-helper-note">
|
||||||
Эти заявки создаются из Engine, когда workflow хотят пошарить зарегистрированному пользователю без доступа к Engine.
|
Здесь workflow-шаринг и запросы повышения глобальной роли Engine из режима только чтения.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4509,12 +4513,14 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{requests.map((request) => (
|
{requests.map((request) => {
|
||||||
|
const isServiceRoleRequest = request.requestType === "service_role";
|
||||||
|
return (
|
||||||
<tr key={request.id}>
|
<tr key={request.id}>
|
||||||
<td>
|
<td>
|
||||||
<div className="access-request-applicant">
|
<div className="access-request-applicant">
|
||||||
<strong>{request.workflowName}</strong>
|
<strong>{isServiceRoleRequest ? "Повышение роли Engine" : request.workflowName}</strong>
|
||||||
<small>{request.workflowId}</small>
|
<small>{isServiceRoleRequest ? `Текущая роль: ${engineServiceRoleLabel(request.currentServiceRole)}` : request.workflowId}</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -4529,7 +4535,7 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
<small>{request.requesterEmail}</small>
|
<small>{request.requesterEmail}</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{engineWorkflowRoleLabel(request.role)}</td>
|
<td>{isServiceRoleRequest ? engineServiceRoleLabel(request.requestedServiceRole) : engineWorkflowRoleLabel(request.role)}</td>
|
||||||
<td>
|
<td>
|
||||||
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
|
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -4540,7 +4546,10 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
|
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
|
||||||
className="access-request-decision-button access-request-decision-button--accept"
|
className="access-request-decision-button access-request-decision-button--accept"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, {})}
|
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, isServiceRoleRequest
|
||||||
|
? { serviceRole: request.requestedServiceRole ?? "member" }
|
||||||
|
: {}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Check size={16} strokeWidth={2.6} />
|
<Check size={16} strokeWidth={2.6} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -4558,7 +4567,8 @@ function EngineWorkflowAccessRequestsPanel({
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -5020,6 +5030,12 @@ function engineWorkflowRoleLabel(role: EngineWorkflowAccessRequest["role"]): str
|
||||||
return labels[role] ?? role;
|
return labels[role] ?? role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function engineServiceRoleLabel(role?: EngineWorkflowAccessRequest["requestedServiceRole"] | null): string {
|
||||||
|
if (role === "member") return "Участник";
|
||||||
|
if (role === "viewer") return "Гость";
|
||||||
|
return "не назначена";
|
||||||
|
}
|
||||||
|
|
||||||
function sectionTitle(section: AdminSection): string {
|
function sectionTitle(section: AdminSection): string {
|
||||||
const labels: Record<AdminSection, string> = {
|
const labels: Record<AdminSection, string> = {
|
||||||
overview: "Обзор",
|
overview: "Обзор",
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import type { Client } from "../../entities/client/types";
|
||||||
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
||||||
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
|
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
|
||||||
import { initials } from "../../shared/lib/format";
|
import { initials } from "../../shared/lib/format";
|
||||||
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
|
import { NodeDcProfileMenu, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
|
||||||
|
|
||||||
export type LauncherAdminMode = "admin" | "platform";
|
export type LauncherAdminMode = "admin" | "platform";
|
||||||
export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine";
|
export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine";
|
||||||
export type LauncherNotificationStatus = "new" | "approved" | "rejected" | "cancelled";
|
export type LauncherNotificationStatus = "unread" | "read" | "archived";
|
||||||
|
|
||||||
export interface LauncherNotificationItem {
|
export interface LauncherNotificationItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -17,10 +17,23 @@ export interface LauncherNotificationItem {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
meta?: string;
|
meta?: string;
|
||||||
|
displayStatus?: string;
|
||||||
status: LauncherNotificationStatus;
|
status: LauncherNotificationStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
actionKind?: "engine-role-request";
|
||||||
|
requestId?: string;
|
||||||
|
requestedServiceRole?: "viewer" | "member" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EngineRoleActionValue = "choose" | "viewer" | "member";
|
||||||
|
|
||||||
|
const engineRoleActionOptions: Array<NodeDcSelectOption<EngineRoleActionValue>> = [
|
||||||
|
{ value: "choose", label: "Изменить роль" },
|
||||||
|
{ value: "viewer", label: "Гость" },
|
||||||
|
{ value: "member", label: "Участник" },
|
||||||
|
];
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
me,
|
me,
|
||||||
clients,
|
clients,
|
||||||
|
|
@ -38,6 +51,8 @@ export function TopBar({
|
||||||
onLogout,
|
onLogout,
|
||||||
brandLinkUrl = "/",
|
brandLinkUrl = "/",
|
||||||
notifications = [],
|
notifications = [],
|
||||||
|
onMarkNotificationsRead,
|
||||||
|
onResolveEngineRoleRequest,
|
||||||
}: {
|
}: {
|
||||||
me: MeResponse;
|
me: MeResponse;
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
|
@ -55,9 +70,13 @@ export function TopBar({
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
brandLinkUrl?: string;
|
brandLinkUrl?: string;
|
||||||
notifications?: LauncherNotificationItem[];
|
notifications?: LauncherNotificationItem[];
|
||||||
|
onMarkNotificationsRead?: () => void | Promise<unknown>;
|
||||||
|
onResolveEngineRoleRequest?: (requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) => void | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all");
|
const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all");
|
||||||
|
const [notificationsOnlyNew, setNotificationsOnlyNew] = useState(false);
|
||||||
|
const [resolvingNotificationIds, setResolvingNotificationIds] = useState<Record<string, boolean>>({});
|
||||||
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||||
const clientsWithPublicPool = [
|
const clientsWithPublicPool = [
|
||||||
...clients,
|
...clients,
|
||||||
|
|
@ -72,25 +91,60 @@ export function TopBar({
|
||||||
}));
|
}));
|
||||||
const canOpenPlatform = me.launcherRole === "root_admin";
|
const canOpenPlatform = me.launcherRole === "root_admin";
|
||||||
const showLauncherNavigation = me.permissions.canOpenAdmin || canOpenPlatform;
|
const showLauncherNavigation = me.permissions.canOpenAdmin || canOpenPlatform;
|
||||||
const unreadCount = notifications.filter((notification) => notification.status === "new").length;
|
const unreadCount = notifications.filter((notification) => notification.status === "unread").length;
|
||||||
const visibleNotifications = useMemo(
|
const visibleNotifications = useMemo(
|
||||||
() => notifications.filter((notification) => notificationFilter === "all" || notification.kind === notificationFilter),
|
() => notifications.filter((notification) => {
|
||||||
[notificationFilter, notifications]
|
if (notificationsOnlyNew && notification.status !== "unread") return false;
|
||||||
|
return notificationFilter === "all" || notification.kind === notificationFilter;
|
||||||
|
}),
|
||||||
|
[notificationFilter, notifications, notificationsOnlyNew]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function markNotificationsSeen() {
|
||||||
|
if (unreadCount <= 0) return;
|
||||||
|
void Promise.resolve(onMarkNotificationsRead?.());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNotifications() {
|
||||||
|
markNotificationsSeen();
|
||||||
|
setNotificationsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEngineRoleNotification(requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) {
|
||||||
|
if (!onResolveEngineRoleRequest || resolvingNotificationIds[requestId]) return;
|
||||||
|
setResolvingNotificationIds((current) => ({ ...current, [requestId]: true }));
|
||||||
|
Promise.resolve(onResolveEngineRoleRequest(requestId, action, serviceRole, deliveryId))
|
||||||
|
.finally(() => {
|
||||||
|
setResolvingNotificationIds((current) => {
|
||||||
|
const { [requestId]: _done, ...rest } = current;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const notificationsModal = notificationsOpen && typeof document !== "undefined"
|
const notificationsModal = notificationsOpen && typeof document !== "undefined"
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div className="nodedc-notifications-overlay" onMouseDown={() => setNotificationsOpen(false)}>
|
<div className="nodedc-notifications-overlay" onMouseDown={closeNotifications}>
|
||||||
<section className="nodedc-notifications-modal" aria-modal="true" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
|
<section className="nodedc-notifications-modal" aria-modal="true" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
<div className="nodedc-notifications-head">
|
<div className="nodedc-notifications-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Уведомления</h2>
|
<h2>Уведомления</h2>
|
||||||
<span>NODE DC</span>
|
<span>NODE DC</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={() => setNotificationsOpen(false)}>
|
<div className="nodedc-notifications-head__actions">
|
||||||
|
<button
|
||||||
|
className="nodedc-notifications-only-new"
|
||||||
|
type="button"
|
||||||
|
data-active={notificationsOnlyNew ? "true" : "false"}
|
||||||
|
onClick={() => setNotificationsOnlyNew((current) => !current)}
|
||||||
|
>
|
||||||
|
Только новые
|
||||||
|
</button>
|
||||||
|
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={closeNotifications}>
|
||||||
<X size={18} strokeWidth={1.8} />
|
<X size={18} strokeWidth={1.8} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="nodedc-notifications-tabs" role="tablist" aria-label="Фильтр уведомлений">
|
<div className="nodedc-notifications-tabs" role="tablist" aria-label="Фильтр уведомлений">
|
||||||
<NotificationFilterButton active={notificationFilter === "all"} onClick={() => setNotificationFilter("all")}>
|
<NotificationFilterButton active={notificationFilter === "all"} onClick={() => setNotificationFilter("all")}>
|
||||||
|
|
@ -114,17 +168,59 @@ export function TopBar({
|
||||||
<span>История заявок NODE.DC, Operational Core и Engine появится здесь.</span>
|
<span>История заявок NODE.DC, Operational Core и Engine появится здесь.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visibleNotifications.map((notification) => (
|
visibleNotifications.map((notification) => {
|
||||||
<article className="nodedc-notification-card" data-status={notification.status} key={notification.id}>
|
const unread = notification.status === "unread";
|
||||||
|
return (
|
||||||
|
<article className="nodedc-notification-card" data-status={unread ? "new" : notification.status} key={notification.id}>
|
||||||
|
<div className="nodedc-notification-card__content">
|
||||||
<div className="nodedc-notification-card__kicker">{notificationKindLabel(notification.kind)}</div>
|
<div className="nodedc-notification-card__kicker">{notificationKindLabel(notification.kind)}</div>
|
||||||
<div className="nodedc-notification-card__title">{notification.title}</div>
|
<div className="nodedc-notification-card__title">{notification.title}</div>
|
||||||
<div className="nodedc-notification-card__description">{notification.description}</div>
|
<div className="nodedc-notification-card__description">{notification.description}</div>
|
||||||
<div className="nodedc-notification-card__foot">
|
<div className="nodedc-notification-card__foot">
|
||||||
<span>{notificationStatusLabel(notification.status)}</span>
|
<span>{notification.displayStatus || notificationStatusLabel(notification.status)}</span>
|
||||||
{notification.meta ? <span>{notification.meta}</span> : null}
|
{notification.meta ? <span>{notification.meta}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{notification.actionKind === "engine-role-request" &&
|
||||||
|
notification.requestId &&
|
||||||
|
notification.status === "unread" &&
|
||||||
|
onResolveEngineRoleRequest ? (
|
||||||
|
<div className="nodedc-notification-card__actions">
|
||||||
|
{(() => {
|
||||||
|
const requestBusy = Boolean(resolvingNotificationIds[notification.requestId]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="nodedc-notification-card__action-button"
|
||||||
|
type="button"
|
||||||
|
disabled={requestBusy}
|
||||||
|
onClick={() => resolveEngineRoleNotification(notification.requestId!, "reject", undefined, notification.id)}
|
||||||
|
>
|
||||||
|
Отклонить
|
||||||
|
</button>
|
||||||
|
<NodeDcSelect<EngineRoleActionValue>
|
||||||
|
className="nodedc-notification-card__role-select-wrap"
|
||||||
|
triggerClassName="nodedc-notification-card__role-select"
|
||||||
|
menuClassName="nodedc-notification-card__role-menu"
|
||||||
|
value="choose"
|
||||||
|
options={engineRoleActionOptions}
|
||||||
|
label="Изменить роль Engine"
|
||||||
|
minMenuWidth={166}
|
||||||
|
disabled={requestBusy}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === "viewer" || value === "member") {
|
||||||
|
resolveEngineRoleNotification(notification.requestId!, "approve", value, notification.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -284,10 +380,9 @@ function notificationKindLabel(kind: LauncherNotificationKind): string {
|
||||||
|
|
||||||
function notificationStatusLabel(status: LauncherNotificationStatus): string {
|
function notificationStatusLabel(status: LauncherNotificationStatus): string {
|
||||||
const labels: Record<LauncherNotificationStatus, string> = {
|
const labels: Record<LauncherNotificationStatus, string> = {
|
||||||
new: "Входящее",
|
unread: "Входящее",
|
||||||
approved: "Подтверждено",
|
read: "Прочитано",
|
||||||
rejected: "Отклонено",
|
archived: "В архиве",
|
||||||
cancelled: "Отменено",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return labels[status];
|
return labels[status];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue