ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: открытый контур и self-service инвайты
This commit is contained in:
@@ -7,6 +7,11 @@ const platformGroups = {
|
||||
taskManagerAdmin: "nodedc:taskmanager:admin",
|
||||
taskManagerUser: "nodedc:taskmanager:user",
|
||||
};
|
||||
const publicPoolClientId = "client_public_pool";
|
||||
const publicPoolClient = {
|
||||
id: publicPoolClientId,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
export function createAuthentikSyncClient({ baseUrl, token }) {
|
||||
const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, "");
|
||||
@@ -172,7 +177,7 @@ export function resolveRequiredGroups(data, user) {
|
||||
return [...groupNames];
|
||||
}
|
||||
|
||||
for (const client of data.clients) {
|
||||
for (const client of getUserRuntimeClients(data, user.id)) {
|
||||
const membership = getRuntimeMembership(data, user.id, client.id);
|
||||
|
||||
if (membership.status !== "active") {
|
||||
@@ -203,6 +208,19 @@ export function resolveRequiredGroups(data, user) {
|
||||
return [...groupNames];
|
||||
}
|
||||
|
||||
function getUserRuntimeClients(data, userId) {
|
||||
const clients = [...data.clients];
|
||||
const hasPublicPoolMembership = data.memberships.some(
|
||||
(membership) => membership.userId === userId && membership.clientId === publicPoolClientId
|
||||
);
|
||||
|
||||
if (hasPublicPoolMembership) {
|
||||
clients.push(publicPoolClient);
|
||||
}
|
||||
|
||||
return clients;
|
||||
}
|
||||
|
||||
function generatePasswordValue() {
|
||||
return `NDC-${randomBytes(15).toString("base64url")}`;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ const collectionKeys = [
|
||||
"grants",
|
||||
"exceptions",
|
||||
"invites",
|
||||
"accessRequests",
|
||||
"taskerInviteRequests",
|
||||
"syncStatuses",
|
||||
"auditEvents",
|
||||
"taskManagerMemberships",
|
||||
@@ -28,6 +30,27 @@ const grantStatuses = new Set(["active", "disabled"]);
|
||||
const exceptionTypes = new Set(["deny", "allow"]);
|
||||
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
|
||||
const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]);
|
||||
const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
|
||||
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
||||
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
|
||||
const publicPoolClientId = "client_public_pool";
|
||||
const publicPoolClient = {
|
||||
id: publicPoolClientId,
|
||||
type: "person",
|
||||
name: "Открытый контур",
|
||||
legalName: "Public access pool",
|
||||
status: "active",
|
||||
contractStartsAt: null,
|
||||
contractEndsAt: null,
|
||||
paidUntil: null,
|
||||
demoEndsAt: null,
|
||||
contactName: "NODE.DC",
|
||||
contactEmail: null,
|
||||
avatarUrl: null,
|
||||
notes: "Системный контур для публичных заявок, публичных инвайтов и self-service пользователей.",
|
||||
createdAt: "2026-05-09T00:00:00.000Z",
|
||||
updatedAt: "2026-05-09T00:00:00.000Z",
|
||||
};
|
||||
const defaultSettings = {
|
||||
brand: {
|
||||
logoLinkUrl: "/",
|
||||
@@ -386,7 +409,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
const actor = resolveActor(data, identity);
|
||||
const now = isoNow();
|
||||
const clientId = requireString(payload?.clientId, "clientId");
|
||||
const client = findById(data.clients, clientId, "client");
|
||||
const client = findClientById(data, clientId);
|
||||
const email = requireString(payload?.email, "email").toLowerCase();
|
||||
const existingUser = data.users.find((item) => item.email.toLowerCase() === email);
|
||||
const user =
|
||||
@@ -429,6 +452,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
userId: user.id,
|
||||
role: pickEnum(payload?.role, membershipRoles, "member"),
|
||||
status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"),
|
||||
invitedByUserId: actor.id,
|
||||
inviteId: null,
|
||||
source: "launcher",
|
||||
sourceTaskerInviteRequestId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -519,7 +546,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
const actor = resolveActor(data, identity);
|
||||
const now = isoNow();
|
||||
const clientId = requireString(payload?.clientId, "clientId");
|
||||
const client = findById(data.clients, clientId, "client");
|
||||
const client = findClientById(data, clientId);
|
||||
const email = requireString(payload?.email, "email").toLowerCase();
|
||||
const role = pickEnum(payload?.role, membershipRoles, "member");
|
||||
const expiresAt = optionalString(payload?.expiresAt, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString());
|
||||
@@ -529,6 +556,11 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
email,
|
||||
role,
|
||||
invitedByUserId: actor.id,
|
||||
source: "launcher",
|
||||
sourceTaskerInviteRequestId: null,
|
||||
sourceTaskerInviteId: null,
|
||||
sourceWorkspaceSlug: null,
|
||||
sourceWorkspaceName: null,
|
||||
token: randomUUID(),
|
||||
expiresAt,
|
||||
status: "created",
|
||||
@@ -551,6 +583,407 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
return { invite, data };
|
||||
}
|
||||
|
||||
async function createAccessRequest(payload) {
|
||||
const data = readData();
|
||||
const now = isoNow();
|
||||
const requestPayload = sanitizeAccessRequestPayload(payload);
|
||||
const email = requestPayload.email.toLowerCase();
|
||||
const existingRequest = data.accessRequests.find(
|
||||
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
|
||||
);
|
||||
|
||||
if (existingRequest) {
|
||||
Object.assign(existingRequest, {
|
||||
...requestPayload,
|
||||
email,
|
||||
updatedAt: now,
|
||||
});
|
||||
addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, {
|
||||
action: "Обновлена публичная заявка",
|
||||
objectType: "access_request",
|
||||
objectName: email,
|
||||
clientId: null,
|
||||
result: "success",
|
||||
details: requestPayload.company,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { accessRequest: existingRequest, data };
|
||||
}
|
||||
|
||||
const accessRequest = {
|
||||
id: uniqueId(data.accessRequests, "access_request", email),
|
||||
...requestPayload,
|
||||
email,
|
||||
status: "new",
|
||||
targetClientId: publicPoolClientId,
|
||||
role: "member",
|
||||
approvedInviteId: null,
|
||||
reviewedByUserId: null,
|
||||
reviewedAt: null,
|
||||
comment: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
data.accessRequests.push(accessRequest);
|
||||
addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, {
|
||||
action: "Создана публичная заявка",
|
||||
objectType: "access_request",
|
||||
objectName: email,
|
||||
clientId: null,
|
||||
result: "success",
|
||||
details: requestPayload.company,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { accessRequest, data };
|
||||
}
|
||||
|
||||
async function updateAccessRequest(accessRequestId, payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const accessRequest = findAccessRequestById(data, accessRequestId);
|
||||
|
||||
if (payload?.targetClientId !== undefined) {
|
||||
accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId);
|
||||
}
|
||||
|
||||
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
||||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||||
accessRequest.updatedAt = isoNow();
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Обновлена публичная заявка",
|
||||
objectType: "access_request",
|
||||
objectName: accessRequest.email,
|
||||
clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId,
|
||||
result: "success",
|
||||
details: `Target: ${accessRequest.targetClientId}; role: ${accessRequest.role}`,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { accessRequest, data };
|
||||
}
|
||||
|
||||
async function approveAccessRequest(accessRequestId, payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const now = isoNow();
|
||||
const accessRequest = findAccessRequestById(data, accessRequestId);
|
||||
|
||||
if (accessRequest.status === "rejected") {
|
||||
throw new Error("Отклонённую заявку нельзя подтвердить без повторного запроса");
|
||||
}
|
||||
|
||||
if (payload?.targetClientId !== undefined) {
|
||||
accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId);
|
||||
} else {
|
||||
accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, accessRequest.targetClientId, publicPoolClientId);
|
||||
}
|
||||
|
||||
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
||||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||||
|
||||
if (accessRequest.status === "approved" && accessRequest.approvedInviteId) {
|
||||
const existingInvite = findById(data.invites, accessRequest.approvedInviteId, "invite");
|
||||
return { accessRequest, invite: existingInvite, data };
|
||||
}
|
||||
|
||||
const client = findClientById(data, accessRequest.targetClientId);
|
||||
const invite = {
|
||||
id: uniqueId(data.invites, "invite", accessRequest.email),
|
||||
clientId: client.id,
|
||||
email: accessRequest.email,
|
||||
role: accessRequest.role,
|
||||
invitedByUserId: actor.id,
|
||||
source: "access_request",
|
||||
sourceTaskerInviteRequestId: null,
|
||||
sourceTaskerInviteId: null,
|
||||
sourceWorkspaceSlug: null,
|
||||
sourceWorkspaceName: null,
|
||||
token: randomUUID(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "created",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
data.invites.push(invite);
|
||||
accessRequest.status = "approved";
|
||||
accessRequest.approvedInviteId = invite.id;
|
||||
accessRequest.reviewedByUserId = actor.id;
|
||||
accessRequest.reviewedAt = now;
|
||||
accessRequest.updatedAt = now;
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Подтверждена публичная заявка",
|
||||
objectType: "access_request",
|
||||
objectName: accessRequest.email,
|
||||
clientId: client.id === publicPoolClientId ? null : client.id,
|
||||
result: "success",
|
||||
details: `Invite: ${invite.id}; target: ${client.name}; role: ${invite.role}`,
|
||||
});
|
||||
markPendingSync(data, invite, "invite", invite.email);
|
||||
|
||||
await writeData(data);
|
||||
return { accessRequest, invite, data };
|
||||
}
|
||||
|
||||
async function rejectAccessRequest(accessRequestId, payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const accessRequest = findAccessRequestById(data, accessRequestId);
|
||||
const now = isoNow();
|
||||
|
||||
if (accessRequest.status === "approved") {
|
||||
throw new Error("Подтверждённую заявку нельзя отклонить");
|
||||
}
|
||||
|
||||
accessRequest.status = "rejected";
|
||||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||||
accessRequest.reviewedByUserId = actor.id;
|
||||
accessRequest.reviewedAt = now;
|
||||
accessRequest.updatedAt = now;
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Отклонена публичная заявка",
|
||||
objectType: "access_request",
|
||||
objectName: accessRequest.email,
|
||||
clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId,
|
||||
result: "warning",
|
||||
details: accessRequest.comment,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { accessRequest, data };
|
||||
}
|
||||
|
||||
async function createTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) {
|
||||
const data = readData();
|
||||
const now = isoNow();
|
||||
const actor = resolveActor(data, identity);
|
||||
const taskerInviteId = requireString(payload?.taskerInviteId, "taskerInviteId");
|
||||
const workspaceSlug = requireString(payload?.workspaceSlug, "workspaceSlug");
|
||||
const workspaceName = optionalString(payload?.workspaceName, workspaceSlug);
|
||||
const inviteeEmail = requireString(payload?.inviteeEmail, "inviteeEmail").toLowerCase();
|
||||
const role = normalizeTaskManagerInviteRole(payload?.role);
|
||||
const existingRequest = data.taskerInviteRequests.find(
|
||||
(request) =>
|
||||
request.taskerInviteId === taskerInviteId ||
|
||||
(request.status === "new" && request.workspaceSlug === workspaceSlug && request.inviteeEmail.toLowerCase() === inviteeEmail)
|
||||
);
|
||||
const request =
|
||||
existingRequest ??
|
||||
{
|
||||
id: uniqueId(data.taskerInviteRequests, "tasker_invite_request", `${workspaceSlug}-${inviteeEmail}`),
|
||||
taskerInviteId,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
Object.assign(request, {
|
||||
taskerInviteId,
|
||||
workspaceId: nullableStringWithFallback(payload?.workspaceId, request.workspaceId ?? null),
|
||||
workspaceSlug,
|
||||
workspaceName,
|
||||
inviteeEmail,
|
||||
role,
|
||||
inviterUserId: nullableStringWithFallback(payload?.inviterUserId, request.inviterUserId ?? null),
|
||||
inviterPlaneUserId: nullableStringWithFallback(payload?.inviterPlaneUserId, request.inviterPlaneUserId ?? null),
|
||||
inviterEmail: requireString(payload?.inviterEmail, "inviterEmail").toLowerCase(),
|
||||
inviterName: optionalString(payload?.inviterName, payload?.inviterEmail ?? "Operational Core user"),
|
||||
status: existingRequest?.status && existingRequest.status !== "rejected" ? existingRequest.status : "new",
|
||||
taskerInviteLink: existingRequest?.taskerInviteLink ?? null,
|
||||
reviewedByUserId: existingRequest?.reviewedByUserId ?? null,
|
||||
reviewedAt: existingRequest?.reviewedAt ?? null,
|
||||
comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null),
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!existingRequest) {
|
||||
data.taskerInviteRequests.push(request);
|
||||
}
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: existingRequest ? "Обновлена заявка workspace-инвайта" : "Создана заявка workspace-инвайта",
|
||||
objectType: "tasker_invite_request",
|
||||
objectName: `${workspaceSlug}:${inviteeEmail}`,
|
||||
result: "success",
|
||||
details: `Role: ${role}`,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { taskerInviteRequest: request, data };
|
||||
}
|
||||
|
||||
async function approveTaskerInviteRequest(taskerInviteRequestId, payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const request = findTaskerInviteRequestById(data, taskerInviteRequestId);
|
||||
const now = isoNow();
|
||||
|
||||
if (request.status === "rejected") {
|
||||
throw new Error("Отклонённую заявку workspace-инвайта нельзя подтвердить");
|
||||
}
|
||||
|
||||
request.status = "approved";
|
||||
request.taskerInviteLink = nullableStringWithFallback(payload?.taskerInviteLink, request.taskerInviteLink ?? null);
|
||||
request.platformInviteId = nullableStringWithFallback(payload?.platformInviteId, request.platformInviteId ?? null);
|
||||
request.platformInviteToken = nullableStringWithFallback(payload?.platformInviteToken, request.platformInviteToken ?? null);
|
||||
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
|
||||
request.reviewedByUserId = actor.id;
|
||||
request.reviewedAt = now;
|
||||
request.updatedAt = now;
|
||||
|
||||
if (request.platformInviteId) {
|
||||
const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId);
|
||||
if (invite) {
|
||||
invite.sourceTaskerInviteLink = request.taskerInviteLink ?? invite.sourceTaskerInviteLink ?? null;
|
||||
invite.updatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Подтверждена заявка workspace-инвайта",
|
||||
objectType: "tasker_invite_request",
|
||||
objectName: `${request.workspaceSlug}:${request.inviteeEmail}`,
|
||||
result: "success",
|
||||
details: request.taskerInviteLink ?? null,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { taskerInviteRequest: request, data };
|
||||
}
|
||||
|
||||
async function ensureTaskerInvitePlatformInvite(taskerInviteRequestId, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const request = findTaskerInviteRequestById(data, taskerInviteRequestId);
|
||||
const now = isoNow();
|
||||
const client = findClientById(data, publicPoolClientId);
|
||||
let invite = request.platformInviteId ? data.invites.find((candidate) => candidate.id === request.platformInviteId) : null;
|
||||
|
||||
if (!invite) {
|
||||
invite = data.invites.find(
|
||||
(candidate) =>
|
||||
candidate.source === "tasker_workspace_invite" &&
|
||||
candidate.sourceTaskerInviteRequestId === request.id &&
|
||||
candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
if (!invite) {
|
||||
invite = {
|
||||
id: uniqueId(data.invites, "invite", `${request.workspaceSlug}-${request.inviteeEmail}`),
|
||||
clientId: client.id,
|
||||
email: request.inviteeEmail,
|
||||
role: "member",
|
||||
invitedByUserId: request.inviterUserId || actor.id,
|
||||
source: "tasker_workspace_invite",
|
||||
sourceTaskerInviteRequestId: request.id,
|
||||
sourceTaskerInviteId: request.taskerInviteId,
|
||||
sourceWorkspaceSlug: request.workspaceSlug,
|
||||
sourceWorkspaceName: request.workspaceName,
|
||||
sourceTaskerInviteLink: request.taskerInviteLink ?? null,
|
||||
token: randomUUID(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "created",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.invites.push(invite);
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Создан platform-инвайт для workspace-инвайта",
|
||||
objectType: "invite",
|
||||
objectName: invite.email,
|
||||
clientId: client.id,
|
||||
result: "success",
|
||||
details: `${request.workspaceSlug}; inviter: ${request.inviterEmail}`,
|
||||
});
|
||||
} else {
|
||||
invite.clientId = client.id;
|
||||
invite.role = "member";
|
||||
invite.invitedByUserId = request.inviterUserId || invite.invitedByUserId || actor.id;
|
||||
invite.source = "tasker_workspace_invite";
|
||||
invite.sourceTaskerInviteRequestId = request.id;
|
||||
invite.sourceTaskerInviteId = request.taskerInviteId;
|
||||
invite.sourceWorkspaceSlug = request.workspaceSlug;
|
||||
invite.sourceWorkspaceName = request.workspaceName;
|
||||
invite.status = invite.status === "revoked" || invite.status === "expired" ? "created" : invite.status;
|
||||
invite.updatedAt = now;
|
||||
}
|
||||
|
||||
request.platformInviteId = invite.id;
|
||||
request.platformInviteToken = invite.token;
|
||||
request.updatedAt = now;
|
||||
|
||||
await writeData(data);
|
||||
return { taskerInviteRequest: request, invite, data };
|
||||
}
|
||||
|
||||
async function rejectTaskerInviteRequest(taskerInviteRequestId, payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const request = findTaskerInviteRequestById(data, taskerInviteRequestId);
|
||||
const now = isoNow();
|
||||
|
||||
if (request.status === "approved") {
|
||||
throw new Error("Подтверждённую заявку workspace-инвайта нельзя отклонить");
|
||||
}
|
||||
|
||||
request.status = "rejected";
|
||||
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
|
||||
request.reviewedByUserId = actor.id;
|
||||
request.reviewedAt = now;
|
||||
request.updatedAt = now;
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Отклонена заявка workspace-инвайта",
|
||||
objectType: "tasker_invite_request",
|
||||
objectName: `${request.workspaceSlug}:${request.inviteeEmail}`,
|
||||
result: "warning",
|
||||
details: request.comment ?? null,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { taskerInviteRequest: request, data };
|
||||
}
|
||||
|
||||
async function cancelTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const request = findTaskerInviteRequestForCancellation(data, payload);
|
||||
|
||||
if (!request) {
|
||||
return { taskerInviteRequest: null, affectedUserIds: [], data };
|
||||
}
|
||||
|
||||
request.status = "cancelled";
|
||||
request.taskerInviteLink = null;
|
||||
request.comment = nullableStringWithFallback(payload?.comment, "Отозвано в Operational Core");
|
||||
request.updatedAt = isoNow();
|
||||
const affectedUserIds = revokeTaskerInviteServiceAccessIfOrphaned(data, request, request.updatedAt);
|
||||
|
||||
if (request.platformInviteId) {
|
||||
const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId);
|
||||
if (invite && invite.status !== "accepted") {
|
||||
invite.status = "revoked";
|
||||
invite.updatedAt = request.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Отозвана заявка workspace-инвайта",
|
||||
objectType: "tasker_invite_request",
|
||||
objectName: `${request.workspaceSlug}:${request.inviteeEmail}`,
|
||||
result: "warning",
|
||||
details: request.comment,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { taskerInviteRequest: request, affectedUserIds, data };
|
||||
}
|
||||
|
||||
async function updateInvite(inviteId, payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
@@ -597,11 +1030,17 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
function getInviteByToken(token) {
|
||||
const data = readData();
|
||||
const invite = findInviteByToken(data, token);
|
||||
const client = findById(data.clients, invite.clientId, "client");
|
||||
const client = findClientById(data, invite.clientId);
|
||||
const existingUser = data.users.find((user) => user.email.toLowerCase() === invite.email.toLowerCase()) ?? null;
|
||||
|
||||
return {
|
||||
invite: toPublicInvite(invite),
|
||||
client: toPublicClient(client),
|
||||
redirectUrl: resolvePublicInviteRedirectUrl(invite),
|
||||
account: {
|
||||
exists: Boolean(existingUser?.authentikUserId),
|
||||
email: invite.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -623,7 +1062,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
async function acceptInvite(token, identity) {
|
||||
const data = readData();
|
||||
const invite = findInviteByToken(data, token);
|
||||
const client = findById(data.clients, invite.clientId, "client");
|
||||
const client = findClientById(data, invite.clientId);
|
||||
const email = requireInviteIdentityEmail(identity);
|
||||
const now = isoNow();
|
||||
|
||||
@@ -641,6 +1080,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
await writeData(data);
|
||||
throw new Error("Срок действия инвайта истёк");
|
||||
}
|
||||
validateTaskerInviteSourceCanBeAccepted(data, invite);
|
||||
|
||||
const actor = resolveActor(data, identity);
|
||||
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||||
@@ -673,6 +1113,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
if (membership) {
|
||||
membership.role = invite.role;
|
||||
membership.status = "active";
|
||||
membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null;
|
||||
membership.inviteId = invite.id;
|
||||
membership.source = invite.source ?? membership.source ?? "launcher";
|
||||
membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null;
|
||||
membership.updatedAt = now;
|
||||
} else {
|
||||
membership = {
|
||||
@@ -681,12 +1125,17 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
status: "active",
|
||||
invitedByUserId: invite.invitedByUserId ?? null,
|
||||
inviteId: invite.id,
|
||||
source: invite.source ?? "launcher",
|
||||
sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.memberships.push(membership);
|
||||
}
|
||||
|
||||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||||
invite.status = "accepted";
|
||||
invite.updatedAt = now;
|
||||
|
||||
@@ -1144,7 +1593,12 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
}
|
||||
|
||||
return {
|
||||
approveAccessRequest,
|
||||
approveTaskerInviteRequest,
|
||||
buildAuthentikSyncPlan,
|
||||
cancelTaskerInviteRequest,
|
||||
createAccessRequest,
|
||||
createTaskerInviteRequest,
|
||||
createClient,
|
||||
createGroup,
|
||||
createInvite,
|
||||
@@ -1155,10 +1609,13 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
deleteInvite,
|
||||
deleteMembership,
|
||||
deleteService,
|
||||
rejectAccessRequest,
|
||||
rejectTaskerInviteRequest,
|
||||
acceptInvite,
|
||||
commitInviteRegistration,
|
||||
getInviteByToken,
|
||||
getSnapshot,
|
||||
ensureTaskerInvitePlatformInvite,
|
||||
prepareInviteRegistration,
|
||||
readData,
|
||||
replaceData,
|
||||
@@ -1170,6 +1627,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
removeTaskManagerProjectMembership,
|
||||
removeTaskManagerWorkspaceMembership,
|
||||
setUserServiceAccess,
|
||||
updateAccessRequest,
|
||||
updateClient,
|
||||
updateGroup,
|
||||
updateInvite,
|
||||
@@ -1197,9 +1655,77 @@ function normalizeData(payload) {
|
||||
...client,
|
||||
integrations: normalizeClientIntegrations(client.integrations),
|
||||
}));
|
||||
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
|
||||
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeAccessRequest(payload) {
|
||||
if (typeof payload !== "object" || payload === null) return null;
|
||||
const now = isoNow();
|
||||
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
|
||||
const firstName = optionalString(payload.firstName, "");
|
||||
const lastName = optionalString(payload.lastName, "");
|
||||
const middleName = optionalString(payload.middleName, "");
|
||||
const phone = optionalString(payload.phone, "");
|
||||
const company = optionalString(payload.company, "");
|
||||
|
||||
if (!email || !firstName || !lastName || !middleName || !phone || !company) return null;
|
||||
|
||||
return {
|
||||
id: optionalString(payload.id, `access_request_${slugify(email)}`),
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
middleName,
|
||||
phone,
|
||||
company,
|
||||
status: pickEnum(payload.status, accessRequestStatuses, "new"),
|
||||
targetClientId: typeof payload.targetClientId === "string" && payload.targetClientId.trim() ? payload.targetClientId.trim() : publicPoolClientId,
|
||||
role: pickEnum(payload.role, membershipRoles, "member"),
|
||||
approvedInviteId: nullableStringWithFallback(payload.approvedInviteId, null),
|
||||
reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null),
|
||||
reviewedAt: nullableStringWithFallback(payload.reviewedAt, null),
|
||||
comment: nullableStringWithFallback(payload.comment, null),
|
||||
createdAt: optionalString(payload.createdAt, now),
|
||||
updatedAt: optionalString(payload.updatedAt, now),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskerInviteRequest(payload) {
|
||||
if (typeof payload !== "object" || payload === null) return null;
|
||||
const now = isoNow();
|
||||
const taskerInviteId = typeof payload.taskerInviteId === "string" ? payload.taskerInviteId.trim() : "";
|
||||
const workspaceSlug = typeof payload.workspaceSlug === "string" ? payload.workspaceSlug.trim() : "";
|
||||
const inviteeEmail = typeof payload.inviteeEmail === "string" ? payload.inviteeEmail.trim().toLowerCase() : "";
|
||||
const inviterEmail = typeof payload.inviterEmail === "string" ? payload.inviterEmail.trim().toLowerCase() : "";
|
||||
|
||||
if (!taskerInviteId || !workspaceSlug || !inviteeEmail || !inviterEmail) return null;
|
||||
|
||||
return {
|
||||
id: optionalString(payload.id, `tasker_invite_request_${slugify(`${workspaceSlug}-${inviteeEmail}`)}`),
|
||||
taskerInviteId,
|
||||
workspaceId: nullableStringWithFallback(payload.workspaceId, null),
|
||||
workspaceSlug,
|
||||
workspaceName: optionalString(payload.workspaceName, workspaceSlug),
|
||||
inviteeEmail,
|
||||
role: normalizeTaskManagerInviteRole(payload.role),
|
||||
inviterUserId: nullableStringWithFallback(payload.inviterUserId, null),
|
||||
inviterPlaneUserId: nullableStringWithFallback(payload.inviterPlaneUserId, null),
|
||||
inviterEmail,
|
||||
inviterName: optionalString(payload.inviterName, inviterEmail),
|
||||
status: pickEnum(payload.status, taskerInviteRequestStatuses, "new"),
|
||||
taskerInviteLink: nullableStringWithFallback(payload.taskerInviteLink, null),
|
||||
platformInviteId: nullableStringWithFallback(payload.platformInviteId, null),
|
||||
platformInviteToken: nullableStringWithFallback(payload.platformInviteToken, null),
|
||||
reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null),
|
||||
reviewedAt: nullableStringWithFallback(payload.reviewedAt, null),
|
||||
comment: nullableStringWithFallback(payload.comment, null),
|
||||
createdAt: optionalString(payload.createdAt, now),
|
||||
updatedAt: optionalString(payload.updatedAt, now),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSettings(payload) {
|
||||
const settings = typeof payload === "object" && payload !== null ? payload : {};
|
||||
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
|
||||
@@ -1430,7 +1956,7 @@ async function writeJsonAtomically(filePath, payload) {
|
||||
|
||||
function assertGrantTargetExists(data, targetType, targetId) {
|
||||
if (targetType === "client") {
|
||||
findById(data.clients, targetId, "client");
|
||||
findClientById(data, targetId);
|
||||
} else if (targetType === "group") {
|
||||
findById(data.groups, targetId, "group");
|
||||
} else {
|
||||
@@ -1467,12 +1993,24 @@ function isInviteExpired(invite) {
|
||||
function toPublicInvite(invite) {
|
||||
return {
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status,
|
||||
source: invite.source ?? "launcher",
|
||||
sourceWorkspaceName: invite.sourceWorkspaceName ?? null,
|
||||
sourceWorkspaceSlug: invite.sourceWorkspaceSlug ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePublicInviteRedirectUrl(invite) {
|
||||
if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) {
|
||||
return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`;
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
function toPublicClient(client) {
|
||||
return {
|
||||
id: client.id,
|
||||
@@ -1481,15 +2019,139 @@ function toPublicClient(client) {
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTaskerInviteServiceAccess(data, invite, user, now) {
|
||||
if (invite.source !== "tasker_workspace_invite") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const service = data.services.find((candidate) => candidate.slug === "task-manager");
|
||||
if (!service) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskerInviteRequest = invite.sourceTaskerInviteRequestId
|
||||
? data.taskerInviteRequests.find((request) => request.id === invite.sourceTaskerInviteRequestId)
|
||||
: null;
|
||||
const requestedAppRole = taskerInviteRequest?.role === "guest" ? "viewer" : "member";
|
||||
const existingGrant = data.grants.find(
|
||||
(grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id
|
||||
);
|
||||
const existingException = data.exceptions.find((exception) => exception.serviceId === service.id && exception.userId === user.id);
|
||||
|
||||
if (existingException?.type === "deny") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingGrant) {
|
||||
existingGrant.status = "active";
|
||||
existingGrant.appRole =
|
||||
existingGrant.appRole === "admin" || existingGrant.appRole === "owner" ? existingGrant.appRole : requestedAppRole;
|
||||
existingGrant.updatedAt = now;
|
||||
markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`);
|
||||
return existingGrant;
|
||||
}
|
||||
|
||||
const grant = {
|
||||
id: uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`),
|
||||
serviceId: service.id,
|
||||
targetType: "user",
|
||||
targetId: user.id,
|
||||
appRole: requestedAppRole,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.grants.push(grant);
|
||||
markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`);
|
||||
return grant;
|
||||
}
|
||||
|
||||
function findTaskerInviteRequestForCancellation(data, payload) {
|
||||
const requestId = nullableString(payload?.requestId);
|
||||
const taskerInviteId = nullableString(payload?.taskerInviteId);
|
||||
const workspaceSlug = nullableString(payload?.workspaceSlug);
|
||||
const inviteeEmail = nullableString(payload?.inviteeEmail)?.toLowerCase() ?? null;
|
||||
|
||||
if (requestId || taskerInviteId) {
|
||||
const request = data.taskerInviteRequests.find(
|
||||
(candidate) => (requestId && candidate.id === requestId) || (taskerInviteId && candidate.taskerInviteId === taskerInviteId)
|
||||
);
|
||||
if (request) return request;
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !inviteeEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.taskerInviteRequests
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.workspaceSlug === workspaceSlug &&
|
||||
candidate.inviteeEmail.toLowerCase() === inviteeEmail
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (left.status !== "cancelled" && right.status === "cancelled") return -1;
|
||||
if (left.status === "cancelled" && right.status !== "cancelled") return 1;
|
||||
return Date.parse(right.updatedAt ?? right.createdAt ?? 0) - Date.parse(left.updatedAt ?? left.createdAt ?? 0);
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
function revokeTaskerInviteServiceAccessIfOrphaned(data, request, now) {
|
||||
const user = data.users.find((candidate) => candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase());
|
||||
const service = data.services.find((candidate) => candidate.slug === "task-manager");
|
||||
|
||||
if (!user || !service) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hasAnotherAcceptedWorkspaceInvite = data.taskerInviteRequests.some((candidate) => {
|
||||
if (candidate.id === request.id) return false;
|
||||
if (candidate.status !== "approved") return false;
|
||||
if (candidate.inviteeEmail.toLowerCase() !== request.inviteeEmail.toLowerCase()) return false;
|
||||
|
||||
const platformInvite = candidate.platformInviteId
|
||||
? data.invites.find((invite) => invite.id === candidate.platformInviteId)
|
||||
: null;
|
||||
return platformInvite?.status === "accepted";
|
||||
});
|
||||
|
||||
if (hasAnotherAcceptedWorkspaceInvite) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const directGrant = data.grants.find(
|
||||
(grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id && grant.status === "active"
|
||||
);
|
||||
|
||||
if (!directGrant || directGrant.appRole === "admin" || directGrant.appRole === "owner") {
|
||||
return [];
|
||||
}
|
||||
|
||||
data.grants = data.grants.filter((grant) => grant.id !== directGrant.id);
|
||||
markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`);
|
||||
markPendingSync(data, user, "user", user.email);
|
||||
|
||||
addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "tasker" }, {
|
||||
action: "Снят доступ Operational Core по workspace-инвайту",
|
||||
objectType: "grant",
|
||||
objectName: `${user.email} / ${service.slug}`,
|
||||
result: "warning",
|
||||
details: `Workspace invite: ${request.workspaceSlug}; cancelled at: ${now}`,
|
||||
});
|
||||
|
||||
return [user.id];
|
||||
}
|
||||
|
||||
function applyInviteRegistration(data, token, payload, { commit, provisioning = null } = {}) {
|
||||
const invite = findInviteByToken(data, token);
|
||||
const client = findById(data.clients, invite.clientId, "client");
|
||||
const client = findClientById(data, invite.clientId);
|
||||
const now = isoNow();
|
||||
const requestedEmail = normalizeInviteRegistrationEmail(payload?.email);
|
||||
const email = invite.email.toLowerCase();
|
||||
const name = optionalString(payload?.name, requestedEmail.split("@")[0]);
|
||||
|
||||
validateInviteCanBeRegistered(invite);
|
||||
validateTaskerInviteSourceCanBeAccepted(data, invite);
|
||||
|
||||
if (!requestedEmail || requestedEmail !== email) {
|
||||
throw new Error("Для этой почты нет активного инвайта");
|
||||
@@ -1528,6 +2190,10 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
|
||||
if (membership) {
|
||||
membership.role = invite.role;
|
||||
membership.status = "active";
|
||||
membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null;
|
||||
membership.inviteId = invite.id;
|
||||
membership.source = invite.source ?? membership.source ?? "launcher";
|
||||
membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null;
|
||||
membership.updatedAt = now;
|
||||
} else {
|
||||
membership = {
|
||||
@@ -1536,12 +2202,17 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
status: "active",
|
||||
invitedByUserId: invite.invitedByUserId ?? null,
|
||||
inviteId: invite.id,
|
||||
source: invite.source ?? "launcher",
|
||||
sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.memberships.push(membership);
|
||||
}
|
||||
|
||||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||||
invite.status = "accepted";
|
||||
invite.updatedAt = now;
|
||||
markPendingSync(data, user, "user", email);
|
||||
@@ -1578,6 +2249,20 @@ function validateInviteCanBeRegistered(invite) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateTaskerInviteSourceCanBeAccepted(data, invite) {
|
||||
if (invite.source !== "tasker_workspace_invite") {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = invite.sourceTaskerInviteRequestId
|
||||
? data.taskerInviteRequests.find((candidate) => candidate.id === invite.sourceTaskerInviteRequestId)
|
||||
: null;
|
||||
|
||||
if (!request || request.status !== "approved") {
|
||||
throw new Error("Workspace-инвайт больше не активен");
|
||||
}
|
||||
}
|
||||
|
||||
function findById(items, id, label) {
|
||||
const item = items.find((candidate) => candidate.id === id);
|
||||
|
||||
@@ -1588,6 +2273,60 @@ function findById(items, id, label) {
|
||||
return item;
|
||||
}
|
||||
|
||||
function findClientById(data, clientId) {
|
||||
if (clientId === publicPoolClientId) {
|
||||
return publicPoolClient;
|
||||
}
|
||||
|
||||
return findById(data.clients, clientId, "client");
|
||||
}
|
||||
|
||||
function findAccessRequestById(data, accessRequestId) {
|
||||
return findById(data.accessRequests, accessRequestId, "access_request");
|
||||
}
|
||||
|
||||
function findTaskerInviteRequestById(data, taskerInviteRequestId) {
|
||||
return findById(data.taskerInviteRequests, taskerInviteRequestId, "tasker_invite_request");
|
||||
}
|
||||
|
||||
function resolveAccessRequestTargetClientId(data, value, fallback = publicPoolClientId) {
|
||||
const clientId = optionalString(value, fallback || publicPoolClientId);
|
||||
findClientById(data, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function sanitizeAccessRequestPayload(payload) {
|
||||
const email = requireString(payload?.email, "email").toLowerCase();
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
throw new Error("Введите корректную электронную почту");
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
firstName: requireString(payload?.firstName, "firstName").slice(0, 80),
|
||||
lastName: requireString(payload?.lastName, "lastName").slice(0, 80),
|
||||
middleName: requireString(payload?.middleName, "middleName").slice(0, 80),
|
||||
phone: requireString(payload?.phone, "phone").slice(0, 80),
|
||||
company: requireString(payload?.company, "company").slice(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskManagerInviteRole(value) {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : value;
|
||||
|
||||
if (normalized === "viewer") return "guest";
|
||||
if (normalized === "owner") return "admin";
|
||||
if (normalized === 5) return "guest";
|
||||
if (normalized === 15) return "member";
|
||||
if (normalized === 20) return "admin";
|
||||
return taskManagerInviteRoles.has(normalized) ? normalized : "member";
|
||||
}
|
||||
|
||||
function isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
function requireString(value, fieldName) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`Field ${fieldName} is required`);
|
||||
|
||||
+258
-3
@@ -20,6 +20,7 @@ const oidcStateCookieName = "nodedc_oidc_state";
|
||||
const maxOidcStateCookieEntries = 8;
|
||||
const sessionCookieName = "nodedc_session";
|
||||
const noStoreCacheControl = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
const publicPoolClientId = "client_public_pool";
|
||||
|
||||
loadEnvFiles([
|
||||
process.env.NODEDC_PLATFORM_ENV,
|
||||
@@ -67,6 +68,16 @@ app.get("/api/public/brand", (_req, res) => {
|
||||
res.json(buildPublicBrandResponse(snapshot.data.settings));
|
||||
});
|
||||
|
||||
app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const result = await controlPlaneStore.createAccessRequest(req.body);
|
||||
publishControlPlaneEvent("access-request.created");
|
||||
res.status(201).json({ accessRequest: result.accessRequest });
|
||||
} catch (error) {
|
||||
sendAccessRequestApiError(res, error);
|
||||
}
|
||||
}));
|
||||
|
||||
app.get("/auth/login", asyncRoute(async (req, res) => {
|
||||
ensureOidcConfigured();
|
||||
|
||||
@@ -395,6 +406,84 @@ app.post("/api/internal/access/check", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/internal/tasker/invite-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 tasker invite request" });
|
||||
const inviterPayload = typeof req.body?.inviter === "object" && req.body.inviter !== null ? req.body.inviter : req.body;
|
||||
const inviter = findInternalAccessUser(snapshot.data, {
|
||||
subject: inviterPayload.subject,
|
||||
email: inviterPayload.email,
|
||||
userId: inviterPayload.userId,
|
||||
});
|
||||
|
||||
if (!inviter) {
|
||||
res.status(404).json({ ok: false, error: "inviter_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = resolveRequiredGroups(snapshot.data, inviter);
|
||||
const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager");
|
||||
const workspacePolicy = resolveTaskManagerWorkspacePolicy(snapshot.data, groups, Boolean(app?.hasAccess), inviter);
|
||||
|
||||
if (!app?.hasAccess || workspacePolicy.inviteApproval !== "nodedc") {
|
||||
res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await controlPlaneStore.createTaskerInviteRequest({
|
||||
taskerInviteId: req.body?.taskerInviteId,
|
||||
workspaceId: req.body?.workspace?.id ?? req.body?.workspaceId,
|
||||
workspaceSlug: req.body?.workspace?.slug ?? req.body?.workspaceSlug,
|
||||
workspaceName: req.body?.workspace?.name ?? req.body?.workspaceName,
|
||||
inviteeEmail: req.body?.invitee?.email ?? req.body?.inviteeEmail,
|
||||
role: req.body?.invitee?.role ?? req.body?.role,
|
||||
inviterUserId: inviter.id,
|
||||
inviterPlaneUserId: inviterPayload.planeUserId,
|
||||
inviterEmail: inviter.email,
|
||||
inviterName: inviter.name,
|
||||
}, inviter);
|
||||
|
||||
publishControlPlaneEvent("tasker.invite-request.created", [inviter.id]);
|
||||
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
|
||||
}));
|
||||
|
||||
app.post("/api/internal/tasker/invite-requests/cancel", 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 result = await controlPlaneStore.cancelTaskerInviteRequest(req.body, {
|
||||
name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core",
|
||||
email: req.body?.cancelledBy?.email,
|
||||
source: "tasker",
|
||||
});
|
||||
const syncResult = await syncUsersToAuthentik(result.data, result.affectedUserIds ?? [], {
|
||||
name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core",
|
||||
email: req.body?.cancelledBy?.email,
|
||||
source: "tasker",
|
||||
});
|
||||
|
||||
if (result.taskerInviteRequest) {
|
||||
publishControlPlaneEvent("tasker.invite-request.cancelled", [
|
||||
result.taskerInviteRequest.inviterUserId,
|
||||
...syncResult.userIds,
|
||||
]);
|
||||
}
|
||||
|
||||
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
|
||||
}));
|
||||
|
||||
app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
|
||||
const { actor } = getLauncherProfileContext(req.nodedcSession);
|
||||
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
|
||||
@@ -478,13 +567,14 @@ app.post("/api/invites/:token/register", asyncRoute(async (req, res) => {
|
||||
);
|
||||
|
||||
publishControlPlaneEvent("invite.registered", [result.user.id]);
|
||||
const redirectUrl = resolveInviteRedirectUrl(result.invite);
|
||||
res.json({
|
||||
...result,
|
||||
user: storeResult.user,
|
||||
data: storeResult.data,
|
||||
provisioning: toProvisioningResponse(provisionedUser),
|
||||
loginUrl: buildLoginRedirectUrl("/", { forceLogin: true, includeReturnTo: true }),
|
||||
redirectUrl: "/",
|
||||
loginUrl: buildLoginRedirectUrl(redirectUrl, { forceLogin: true, includeReturnTo: true }),
|
||||
redirectUrl,
|
||||
authenticated: true,
|
||||
});
|
||||
}));
|
||||
@@ -502,9 +592,44 @@ app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, re
|
||||
const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user);
|
||||
|
||||
publishControlPlaneEvent("invite.accepted", syncResult.userIds);
|
||||
res.json({ ...result, data: syncResult.data });
|
||||
res.json({ ...result, data: syncResult.data, redirectUrl: resolveInviteRedirectUrl(result.invite) });
|
||||
}));
|
||||
|
||||
app.get("/tasker-workspace-invite/:taskerInviteRequestId", (req, res) => {
|
||||
const session = getCurrentSession(req);
|
||||
|
||||
if (!session) {
|
||||
res.redirect(buildLoginRedirectUrl(req.originalUrl, { forceLogin: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeContext = getRuntimeSessionContext(session);
|
||||
const request = controlPlaneStore
|
||||
.getSnapshot({ name: "NODE.DC tasker invite redirect" })
|
||||
.data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId);
|
||||
|
||||
if (!request || request.status !== "approved") {
|
||||
res.status(404).send("Workspace-инвайт не найден или ещё не подтверждён NODE.DC.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.user.email?.toLowerCase() !== request.inviteeEmail.toLowerCase()) {
|
||||
res.status(403).send("Этот workspace-инвайт выписан на другую почту.");
|
||||
return;
|
||||
}
|
||||
|
||||
const handoffToken = createServiceHandoff("task-manager", runtimeContext.user);
|
||||
const taskBaseUrl = getTaskBaseUrl();
|
||||
const targetUrl = new URL("/auth/nodedc/handoff/", taskBaseUrl);
|
||||
targetUrl.searchParams.set("token", handoffToken);
|
||||
targetUrl.searchParams.set(
|
||||
"next_path",
|
||||
`/auth/nodedc/workspace-invite/accept/${encodeURIComponent(request.id)}/`
|
||||
);
|
||||
|
||||
res.redirect(targetUrl.toString());
|
||||
});
|
||||
|
||||
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
|
||||
res.json(scopeAdminSnapshot(req));
|
||||
});
|
||||
@@ -974,6 +1099,94 @@ app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(asyn
|
||||
res.json(scopeAdminMutationResult(req, result));
|
||||
}));
|
||||
|
||||
app.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const result = await controlPlaneStore.updateAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||||
publishControlPlaneEvent("admin.access-request.updated");
|
||||
res.json(scopeAdminMutationResult(req, result));
|
||||
} catch (error) {
|
||||
sendAccessRequestApiError(res, error);
|
||||
}
|
||||
}));
|
||||
|
||||
app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||||
publishControlPlaneEvent("admin.access-request.approved");
|
||||
res.json(scopeAdminMutationResult(req, result));
|
||||
} catch (error) {
|
||||
sendAccessRequestApiError(res, error);
|
||||
}
|
||||
}));
|
||||
|
||||
app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||||
publishControlPlaneEvent("admin.access-request.rejected");
|
||||
res.json(scopeAdminMutationResult(req, result));
|
||||
} catch (error) {
|
||||
sendAccessRequestApiError(res, error);
|
||||
}
|
||||
}));
|
||||
|
||||
app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||
const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId);
|
||||
|
||||
if (!taskerInviteRequest) {
|
||||
res.status(404).json({ error: "tasker_invite_request_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const platformInviteResult = await controlPlaneStore.ensureTaskerInvitePlatformInvite(
|
||||
req.params.taskerInviteRequestId,
|
||||
req.nodedcSession.user
|
||||
);
|
||||
const platformInviteLink = buildPlatformInviteUrl(platformInviteResult.invite);
|
||||
const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/approve/", {
|
||||
body: {
|
||||
taskerInviteId: taskerInviteRequest.taskerInviteId,
|
||||
requestId: taskerInviteRequest.id,
|
||||
platformInviteLink,
|
||||
},
|
||||
});
|
||||
const result = await controlPlaneStore.approveTaskerInviteRequest(
|
||||
req.params.taskerInviteRequestId,
|
||||
{
|
||||
taskerInviteLink: taskerResult.invite?.taskerInviteLink ?? taskerResult.invite?.tasker_invite_link ?? taskerResult.invite?.inviteLink ?? null,
|
||||
platformInviteId: platformInviteResult.invite.id,
|
||||
platformInviteToken: platformInviteResult.invite.token,
|
||||
comment: req.body?.comment,
|
||||
},
|
||||
req.nodedcSession.user
|
||||
);
|
||||
|
||||
publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]);
|
||||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||||
}));
|
||||
|
||||
app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||
const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId);
|
||||
|
||||
if (!taskerInviteRequest) {
|
||||
res.status(404).json({ error: "tasker_invite_request_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/reject/", {
|
||||
body: {
|
||||
taskerInviteId: taskerInviteRequest.taskerInviteId,
|
||||
requestId: taskerInviteRequest.id,
|
||||
comment: req.body?.comment,
|
||||
},
|
||||
});
|
||||
const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
|
||||
|
||||
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]);
|
||||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||||
}));
|
||||
|
||||
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
|
||||
return;
|
||||
@@ -1381,6 +1594,20 @@ function sendInviteApiError(res, error) {
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
function sendAccessRequestApiError(res, error) {
|
||||
const message = error instanceof Error ? error.message : "Заявка недоступна";
|
||||
const status =
|
||||
message.includes("Unknown access_request") || message.includes("не найден")
|
||||
? 404
|
||||
: message.includes("нельзя")
|
||||
? 409
|
||||
: message.includes("required") || message.includes("Введите")
|
||||
? 400
|
||||
: 400;
|
||||
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
function sanitizeSelfProfilePatch(payload) {
|
||||
return {
|
||||
name: payload?.name,
|
||||
@@ -1760,13 +1987,19 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
||||
const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin");
|
||||
const workspaces = resolveTaskManagerWorkspaceAssignments(data, user);
|
||||
const hasLauncherManagedWorkspace = workspaces.some((workspace) => workspace.managedBy === "launcher");
|
||||
const isPublicPoolUser = data.memberships.some(
|
||||
(membership) => membership.userId === user?.id && membership.clientId === publicPoolClientId && membership.status === "active"
|
||||
);
|
||||
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
|
||||
const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "tasker";
|
||||
|
||||
if (!hasTaskManagerAccess) {
|
||||
return {
|
||||
mode,
|
||||
managedBy: defaultManagedBy,
|
||||
defaultManagedBy,
|
||||
inviteApproval: "disabled",
|
||||
defaultInviteApproval,
|
||||
workspaces,
|
||||
canCreateWorkspace: false,
|
||||
reason: "Нет доступа к Operational Core.",
|
||||
@@ -1778,6 +2011,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
||||
mode,
|
||||
managedBy: defaultManagedBy,
|
||||
defaultManagedBy,
|
||||
inviteApproval: "disabled",
|
||||
defaultInviteApproval,
|
||||
workspaces,
|
||||
canCreateWorkspace: false,
|
||||
reason: "Создание рабочих пространств отключено на уровне платформы.",
|
||||
@@ -1789,6 +2024,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
||||
mode,
|
||||
managedBy: "launcher",
|
||||
defaultManagedBy: "launcher",
|
||||
inviteApproval: "launcher",
|
||||
defaultInviteApproval: "launcher",
|
||||
workspaces,
|
||||
canCreateWorkspace: false,
|
||||
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
|
||||
@@ -1800,6 +2037,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
||||
mode,
|
||||
managedBy: defaultManagedBy,
|
||||
defaultManagedBy,
|
||||
inviteApproval: defaultInviteApproval,
|
||||
defaultInviteApproval,
|
||||
workspaces,
|
||||
canCreateWorkspace: false,
|
||||
reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
|
||||
@@ -1810,6 +2049,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
||||
mode,
|
||||
managedBy: "tasker",
|
||||
defaultManagedBy: "tasker",
|
||||
inviteApproval: defaultInviteApproval,
|
||||
defaultInviteApproval,
|
||||
workspaces,
|
||||
canCreateWorkspace: true,
|
||||
reason: "Создание рабочих пространств разрешено платформенной policy.",
|
||||
@@ -2441,6 +2682,8 @@ function scopeControlPlaneData(data, scope) {
|
||||
memberships,
|
||||
groups: data.groups.filter((group) => clientIds.has(group.clientId)),
|
||||
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
|
||||
accessRequests: [],
|
||||
taskerInviteRequests: [],
|
||||
grants: data.grants.filter((grant) => {
|
||||
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
||||
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
||||
@@ -2550,6 +2793,18 @@ function buildLoginRedirectUrl(returnTo, { forceLogin = false, includeReturnTo =
|
||||
return loginUrl.toString();
|
||||
}
|
||||
|
||||
function buildPlatformInviteUrl(invite) {
|
||||
return new URL(`/invite/${encodeURIComponent(invite.token)}`, config.appBaseUrl).toString();
|
||||
}
|
||||
|
||||
function resolveInviteRedirectUrl(invite) {
|
||||
if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) {
|
||||
return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`;
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
function buildOidcLogoutUrl(discovery, returnTo = "/", idToken = null) {
|
||||
const issuerUrl = new URL(discovery.issuer || config.issuer);
|
||||
const logoutUrl = new URL("/if/flow/default-invalidation-flow/", issuerUrl.origin);
|
||||
|
||||
Reference in New Issue
Block a user