feat: add engine workflow access requests
This commit is contained in:
parent
e2c70649c7
commit
179508f4c9
|
|
@ -16,6 +16,7 @@ const collectionKeys = [
|
||||||
"accessRequests",
|
"accessRequests",
|
||||||
"revokedAccounts",
|
"revokedAccounts",
|
||||||
"taskerInviteRequests",
|
"taskerInviteRequests",
|
||||||
|
"engineWorkflowAccessRequests",
|
||||||
"syncStatuses",
|
"syncStatuses",
|
||||||
"auditEvents",
|
"auditEvents",
|
||||||
"taskManagerMemberships",
|
"taskManagerMemberships",
|
||||||
|
|
@ -36,6 +37,8 @@ const serviceModuleIds = new Set(["codex_agents"]);
|
||||||
const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
|
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 engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
|
||||||
const publicPoolClientId = "client_public_pool";
|
const publicPoolClientId = "client_public_pool";
|
||||||
const engineAuthentikGroups = [
|
const engineAuthentikGroups = [
|
||||||
"nodedc:engine:admin",
|
"nodedc:engine:admin",
|
||||||
|
|
@ -1130,6 +1133,141 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
return { taskerInviteRequest: request, data };
|
return { taskerInviteRequest: request, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createEngineWorkflowAccessRequest(payload, identity = { name: "NODE.DC Engine", source: "engine" }) {
|
||||||
|
const data = readData();
|
||||||
|
const now = isoNow();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const workflowId = requireString(payload?.workflowId, "workflowId");
|
||||||
|
const workflowName = optionalString(payload?.workflowName, workflowId);
|
||||||
|
const targetEmail = normalizeEmail(requireString(payload?.targetEmail ?? payload?.email, "targetEmail"));
|
||||||
|
const role = normalizeEngineWorkflowRole(payload?.role);
|
||||||
|
|
||||||
|
if (!isValidEmail(targetEmail)) {
|
||||||
|
throw new Error("Введите корректную электронную почту");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = data.users.find((user) => normalizeEmail(user.email) === targetEmail && user.globalStatus === "active");
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error("engine_target_user_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterEmail = normalizeEmail(payload?.requesterEmail ?? actor.email ?? "");
|
||||||
|
const requesterUser =
|
||||||
|
(payload?.requesterUserId ? data.users.find((user) => user.id === payload.requesterUserId) : null) ??
|
||||||
|
(requesterEmail ? data.users.find((user) => normalizeEmail(user.email) === requesterEmail) : null) ??
|
||||||
|
null;
|
||||||
|
const requesterName = optionalString(payload?.requesterName, requesterUser?.name ?? actor.name ?? "NODE.DC Engine");
|
||||||
|
const existingRequest = data.engineWorkflowAccessRequests.find(
|
||||||
|
(request) =>
|
||||||
|
request.status === "new" &&
|
||||||
|
request.workflowId === workflowId &&
|
||||||
|
request.targetUserId === targetUser.id
|
||||||
|
);
|
||||||
|
const request =
|
||||||
|
existingRequest ??
|
||||||
|
{
|
||||||
|
id: uniqueId(data.engineWorkflowAccessRequests, "engine_workflow_access_request", `${workflowId}-${targetEmail}`),
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(request, {
|
||||||
|
workflowId,
|
||||||
|
workflowName,
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
targetEmail,
|
||||||
|
targetName: targetUser.name ?? null,
|
||||||
|
role,
|
||||||
|
requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null),
|
||||||
|
requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru",
|
||||||
|
requesterName,
|
||||||
|
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 workflow" : "Создана заявка доступа Engine workflow",
|
||||||
|
objectType: "engine_workflow_access_request",
|
||||||
|
objectName: `${workflowName}:${targetEmail}`,
|
||||||
|
result: "success",
|
||||||
|
details: `Role: ${role}; requester: ${request.requesterEmail}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return {
|
||||||
|
engineWorkflowAccessRequest: request,
|
||||||
|
affectedUserIds: [request.requesterUserId, targetUser.id, "user_root"].filter((userId) => typeof userId === "string" && userId),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
|
||||||
|
const data = readData();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const request = findEngineWorkflowAccessRequestById(data, engineWorkflowAccessRequestId);
|
||||||
|
const now = isoNow();
|
||||||
|
|
||||||
|
if (request.status === "rejected") {
|
||||||
|
throw new Error("Отклонённую заявку Engine нельзя подтвердить");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = findById(data.users, request.targetUserId, "user");
|
||||||
|
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 workflow",
|
||||||
|
objectType: "engine_workflow_access_request",
|
||||||
|
objectName: `${request.workflowName}:${request.targetEmail}`,
|
||||||
|
result: "success",
|
||||||
|
details: `Workflow: ${request.workflowId}; role: ${request.role}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return { engineWorkflowAccessRequest: request, grant, targetUser, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
|
||||||
|
const data = readData();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const request = findEngineWorkflowAccessRequestById(data, engineWorkflowAccessRequestId);
|
||||||
|
const now = isoNow();
|
||||||
|
|
||||||
|
if (request.status === "approved") {
|
||||||
|
throw new Error("Подтверждённую заявку Engine нельзя отклонить");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.status = "rejected";
|
||||||
|
request.reviewedByUserId = actor.id;
|
||||||
|
request.reviewedAt = now;
|
||||||
|
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
|
||||||
|
request.updatedAt = now;
|
||||||
|
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: "Отклонена заявка доступа Engine workflow",
|
||||||
|
objectType: "engine_workflow_access_request",
|
||||||
|
objectName: `${request.workflowName}:${request.targetEmail}`,
|
||||||
|
result: "warning",
|
||||||
|
details: request.comment ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return { engineWorkflowAccessRequest: request, data };
|
||||||
|
}
|
||||||
|
|
||||||
async function cancelTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) {
|
async function cancelTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
|
|
@ -1866,10 +2004,12 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
approveAccessRequest,
|
approveAccessRequest,
|
||||||
|
approveEngineWorkflowAccessRequest,
|
||||||
approveTaskerInviteRequest,
|
approveTaskerInviteRequest,
|
||||||
buildAuthentikSyncPlan,
|
buildAuthentikSyncPlan,
|
||||||
cancelTaskerInviteRequest,
|
cancelTaskerInviteRequest,
|
||||||
createAccessRequest,
|
createAccessRequest,
|
||||||
|
createEngineWorkflowAccessRequest,
|
||||||
createTaskerInviteRequest,
|
createTaskerInviteRequest,
|
||||||
createClient,
|
createClient,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
|
@ -1883,6 +2023,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
deleteService,
|
deleteService,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
rejectAccessRequest,
|
rejectAccessRequest,
|
||||||
|
rejectEngineWorkflowAccessRequest,
|
||||||
rejectTaskerInviteRequest,
|
rejectTaskerInviteRequest,
|
||||||
acceptInvite,
|
acceptInvite,
|
||||||
commitInviteRegistration,
|
commitInviteRegistration,
|
||||||
|
|
@ -1945,6 +2086,7 @@ function normalizeData(payload) {
|
||||||
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean);
|
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean);
|
||||||
data.serviceModuleEntitlements = data.serviceModuleEntitlements.map(normalizeServiceModuleEntitlement).filter(Boolean);
|
data.serviceModuleEntitlements = data.serviceModuleEntitlements.map(normalizeServiceModuleEntitlement).filter(Boolean);
|
||||||
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
|
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
|
||||||
|
data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2079,6 +2221,37 @@ function normalizeTaskerInviteRequest(payload) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEngineWorkflowAccessRequest(payload) {
|
||||||
|
if (typeof payload !== "object" || payload === null) return null;
|
||||||
|
const now = isoNow();
|
||||||
|
const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : "";
|
||||||
|
const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : "";
|
||||||
|
const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email);
|
||||||
|
const requesterEmail = normalizeEmail(payload.requesterEmail);
|
||||||
|
|
||||||
|
if (!workflowId || !targetUserId || !targetEmail || !requesterEmail) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`),
|
||||||
|
workflowId,
|
||||||
|
workflowName: optionalString(payload.workflowName, workflowId),
|
||||||
|
targetUserId,
|
||||||
|
targetEmail,
|
||||||
|
targetName: nullableStringWithFallback(payload.targetName, null),
|
||||||
|
role: normalizeEngineWorkflowRole(payload.role),
|
||||||
|
requesterUserId: nullableStringWithFallback(payload.requesterUserId, null),
|
||||||
|
requesterEmail,
|
||||||
|
requesterName: optionalString(payload.requesterName, requesterEmail),
|
||||||
|
status: pickEnum(payload.status, engineWorkflowAccessRequestStatuses, "new"),
|
||||||
|
reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null),
|
||||||
|
reviewedAt: nullableStringWithFallback(payload.reviewedAt, null),
|
||||||
|
engineAppliedAt: nullableStringWithFallback(payload.engineAppliedAt, null),
|
||||||
|
comment: nullableStringWithFallback(payload.comment, null),
|
||||||
|
createdAt: optionalString(payload.createdAt, now),
|
||||||
|
updatedAt: optionalString(payload.updatedAt, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSettings(payload) {
|
function normalizeSettings(payload) {
|
||||||
const settings = typeof payload === "object" && payload !== null ? payload : {};
|
const settings = typeof payload === "object" && payload !== null ? payload : {};
|
||||||
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
|
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
|
||||||
|
|
@ -2457,6 +2630,52 @@ function ensureTaskerInviteServiceAccess(data, invite, user, now) {
|
||||||
return grant;
|
return grant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) {
|
||||||
|
const service = findEngineService(data);
|
||||||
|
if (!service) {
|
||||||
|
throw new Error("engine_service_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedAppRole = workflowRole === "viewer" ? "viewer" : "member";
|
||||||
|
data.exceptions = data.exceptions.filter((exception) => !(exception.serviceId === service.id && exception.userId === user.id));
|
||||||
|
const existingGrant = data.grants.find(
|
||||||
|
(grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
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 findEngineService(data) {
|
||||||
|
return data.services.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.id === "service_nodedc" ||
|
||||||
|
candidate.slug === "nodedc" ||
|
||||||
|
candidate.slug === "engine" ||
|
||||||
|
candidate.authentikApplicationSlug === "nodedc-engine"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasTaskManagerDenyException(data, userId) {
|
function hasTaskManagerDenyException(data, userId) {
|
||||||
const service = data.services.find((candidate) => candidate.slug === "task-manager");
|
const service = data.services.find((candidate) => candidate.slug === "task-manager");
|
||||||
if (!service) {
|
if (!service) {
|
||||||
|
|
@ -2696,6 +2915,10 @@ function findTaskerInviteRequestById(data, taskerInviteRequestId) {
|
||||||
return findById(data.taskerInviteRequests, taskerInviteRequestId, "tasker_invite_request");
|
return findById(data.taskerInviteRequests, taskerInviteRequestId, "tasker_invite_request");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findEngineWorkflowAccessRequestById(data, engineWorkflowAccessRequestId) {
|
||||||
|
return findById(data.engineWorkflowAccessRequests, engineWorkflowAccessRequestId, "engine_workflow_access_request");
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAccessRequestTargetClientId(data, value, fallback = publicPoolClientId) {
|
function resolveAccessRequestTargetClientId(data, value, fallback = publicPoolClientId) {
|
||||||
const clientId = optionalString(value, fallback || publicPoolClientId);
|
const clientId = optionalString(value, fallback || publicPoolClientId);
|
||||||
findClientById(data, clientId);
|
findClientById(data, clientId);
|
||||||
|
|
@ -2801,6 +3024,11 @@ function normalizeTaskManagerInviteRole(value) {
|
||||||
return taskManagerInviteRoles.has(normalized) ? normalized : "member";
|
return taskManagerInviteRoles.has(normalized) ? normalized : "member";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEngineWorkflowRole(value) {
|
||||||
|
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||||
|
return engineWorkflowRoles.has(normalized) ? normalized : "viewer";
|
||||||
|
}
|
||||||
|
|
||||||
function isValidEmail(email) {
|
function isValidEmail(email) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,69 @@ app.post("/api/internal/tasker/invite-requests/cancel", asyncRoute(async (req, r
|
||||||
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
|
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.post("/api/internal/engine/workflow-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 workflow 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,
|
||||||
|
});
|
||||||
|
const targetEmail = typeof req.body?.targetEmail === "string" ? req.body.targetEmail.trim().toLowerCase() : "";
|
||||||
|
const targetUser = findInternalAccessUser(snapshot.data, { email: targetEmail });
|
||||||
|
|
||||||
|
if (!requester) {
|
||||||
|
res.status(404).json({ ok: false, error: "requester_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUser || targetUser.globalStatus !== "active") {
|
||||||
|
res.status(404).json({ ok: false, error: "user_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = resolveRequiredGroups(snapshot.data, targetUser);
|
||||||
|
const app = getAppsForUser(groups).find((candidate) => candidate.slug === "nodedc");
|
||||||
|
|
||||||
|
if (app?.hasAccess) {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
alreadyAllowed: true,
|
||||||
|
targetUser: {
|
||||||
|
id: targetUser.id,
|
||||||
|
email: targetUser.email,
|
||||||
|
name: targetUser.name,
|
||||||
|
avatarUrl: targetUser.avatarUrl ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await controlPlaneStore.createEngineWorkflowAccessRequest({
|
||||||
|
workflowId: req.body?.workflow?.id ?? req.body?.workflowId,
|
||||||
|
workflowName: req.body?.workflow?.name ?? req.body?.workflowName,
|
||||||
|
targetEmail,
|
||||||
|
role: req.body?.role,
|
||||||
|
requesterUserId: requester.id,
|
||||||
|
requesterEmail: requester.email,
|
||||||
|
requesterName: requester.name,
|
||||||
|
}, requester);
|
||||||
|
|
||||||
|
publishControlPlaneEvent(
|
||||||
|
"engine.workflow-access-request.created",
|
||||||
|
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id]
|
||||||
|
);
|
||||||
|
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
|
||||||
|
}));
|
||||||
|
|
||||||
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({
|
||||||
|
|
@ -1372,6 +1435,56 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requ
|
||||||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const request = snapshot.data.engineWorkflowAccessRequests.find(
|
||||||
|
(candidate) => candidate.id === req.params.engineWorkflowAccessRequestId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
res.status(404).json({ error: "engine_workflow_access_request_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, {
|
||||||
|
body: {
|
||||||
|
email: request.targetEmail,
|
||||||
|
role: request.role,
|
||||||
|
requestId: request.id,
|
||||||
|
approvedBy: {
|
||||||
|
userId: req.nodedcSession.user?.id,
|
||||||
|
email: req.nodedcSession.user?.email,
|
||||||
|
name: req.nodedcSession.user?.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await controlPlaneStore.approveEngineWorkflowAccessRequest(
|
||||||
|
req.params.engineWorkflowAccessRequestId,
|
||||||
|
{ engineAppliedAt: engineResult.updatedAt ?? new Date().toISOString(), comment: req.body?.comment },
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
const syncResult = await syncUsersToAuthentik(result.data, [result.targetUser.id], req.nodedcSession.user);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.engine-workflow-access-request.approved", [
|
||||||
|
result.engineWorkflowAccessRequest.requesterUserId,
|
||||||
|
result.targetUser.id,
|
||||||
|
]);
|
||||||
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data, engine: engineResult }));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const result = await controlPlaneStore.rejectEngineWorkflowAccessRequest(
|
||||||
|
req.params.engineWorkflowAccessRequestId,
|
||||||
|
req.body,
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [
|
||||||
|
result.engineWorkflowAccessRequest.requesterUserId,
|
||||||
|
]);
|
||||||
|
res.json(scopeAdminMutationResult(req, result));
|
||||||
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
|
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1670,6 +1783,11 @@ function readConfig() {
|
||||||
taskInternalLogoutUrl:
|
taskInternalLogoutUrl:
|
||||||
process.env.TASK_INTERNAL_LOGOUT_URL ??
|
process.env.TASK_INTERNAL_LOGOUT_URL ??
|
||||||
`${(process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`).replace(/\/$/, "")}/api/internal/nodedc/logout/`,
|
`${(process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`).replace(/\/$/, "")}/api/internal/nodedc/logout/`,
|
||||||
|
engineBaseUrl:
|
||||||
|
process.env.NODEDC_ENGINE_INTERNAL_URL ??
|
||||||
|
process.env.NODEDC_ENGINE_BASE_URL ??
|
||||||
|
process.env.ENGINE_BASE_URL ??
|
||||||
|
"https://engine.nodedc.ru",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2232,6 +2350,10 @@ function getTaskBaseUrl() {
|
||||||
return taskBaseUrl.replace(/\/$/, "");
|
return taskBaseUrl.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEngineBaseUrl() {
|
||||||
|
return String(config.engineBaseUrl || "https://engine.nodedc.ru").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
async function requestTaskManagerInternalJson(pathname, init = {}) {
|
async function requestTaskManagerInternalJson(pathname, init = {}) {
|
||||||
if (!config.internalAccessToken) {
|
if (!config.internalAccessToken) {
|
||||||
throw new Error("NODE.DC internal access token is not configured");
|
throw new Error("NODE.DC internal access token is not configured");
|
||||||
|
|
@ -2260,6 +2382,37 @@ async function requestTaskManagerInternalJson(pathname, init = {}) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestEngineInternalJson(pathname, init = {}) {
|
||||||
|
if (!config.internalAccessToken) {
|
||||||
|
throw new Error("NODE.DC internal access token is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = new URL(pathname, `${getEngineBaseUrl()}/`);
|
||||||
|
const hasBody = typeof init.body === "object" && init.body !== null;
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: init.method ?? (hasBody ? "POST" : "GET"),
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${config.internalAccessToken}`,
|
||||||
|
"X-Authentik-Groups": "nodedc_admin nodedc:engine:admin",
|
||||||
|
"X-Authentik-Email": "launcher-internal@nodedc.ru",
|
||||||
|
"X-Authentik-Username": "launcher-internal@nodedc.ru",
|
||||||
|
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
body: hasBody ? JSON.stringify(init.body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = typeof payload?.error === "string" ? payload.error : `Engine internal API failed: ${response.status}`;
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
async function syncTaskManagerUserProfile(user) {
|
async function syncTaskManagerUserProfile(user) {
|
||||||
if (!user?.email || !config.internalAccessToken) {
|
if (!user?.email || !config.internalAccessToken) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -3363,6 +3516,7 @@ 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: [],
|
||||||
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);
|
||||||
|
|
@ -3398,6 +3552,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
|
||||||
accessRequests: [],
|
accessRequests: [],
|
||||||
revokedAccounts: [],
|
revokedAccounts: [],
|
||||||
taskerInviteRequests: [],
|
taskerInviteRequests: [],
|
||||||
|
engineWorkflowAccessRequests: [],
|
||||||
grants: [],
|
grants: [],
|
||||||
exceptions: [],
|
exceptions: [],
|
||||||
serviceModuleEntitlements: [],
|
serviceModuleEntitlements: [],
|
||||||
|
|
@ -3425,6 +3580,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
|
||||||
accessRequests: [],
|
accessRequests: [],
|
||||||
revokedAccounts: [],
|
revokedAccounts: [],
|
||||||
taskerInviteRequests: [],
|
taskerInviteRequests: [],
|
||||||
|
engineWorkflowAccessRequests: [],
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { LauncherServiceView, Service } from "../entities/service/types";
|
||||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types";
|
import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types";
|
||||||
import {
|
import {
|
||||||
approveAdminAccessRequest,
|
approveAdminAccessRequest,
|
||||||
|
approveAdminEngineWorkflowAccessRequest,
|
||||||
approveAdminTaskerInviteRequest,
|
approveAdminTaskerInviteRequest,
|
||||||
createAdminClient,
|
createAdminClient,
|
||||||
createAdminGroup,
|
createAdminGroup,
|
||||||
|
|
@ -24,6 +25,7 @@ import {
|
||||||
reorderAdminServices,
|
reorderAdminServices,
|
||||||
retryAdminSync,
|
retryAdminSync,
|
||||||
rejectAdminAccessRequest,
|
rejectAdminAccessRequest,
|
||||||
|
rejectAdminEngineWorkflowAccessRequest,
|
||||||
rejectAdminTaskerInviteRequest,
|
rejectAdminTaskerInviteRequest,
|
||||||
removeAdminTaskManagerProjectMembership,
|
removeAdminTaskManagerProjectMembership,
|
||||||
removeAdminTaskManagerWorkspaceMembership,
|
removeAdminTaskManagerWorkspaceMembership,
|
||||||
|
|
@ -72,7 +74,7 @@ import type {
|
||||||
} from "../widgets/admin-overlay/AdminOverlay";
|
} from "../widgets/admin-overlay/AdminOverlay";
|
||||||
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||||
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||||
import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar";
|
import { TopBar, type LauncherAdminMode, type LauncherNotificationItem } from "../widgets/top-bar/TopBar";
|
||||||
|
|
||||||
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
||||||
|
|
||||||
|
|
@ -159,6 +161,7 @@ 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(
|
||||||
|
|
@ -697,6 +700,20 @@ export function LauncherApp() {
|
||||||
applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch));
|
applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleApproveEngineWorkflowAccessRequest(
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Parameters<typeof approveAdminEngineWorkflowAccessRequest>[1]
|
||||||
|
) {
|
||||||
|
applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRejectEngineWorkflowAccessRequest(
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Parameters<typeof rejectAdminEngineWorkflowAccessRequest>[1]
|
||||||
|
) {
|
||||||
|
applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||||
|
}
|
||||||
|
|
||||||
function handleRetrySync(syncId: string) {
|
function handleRetrySync(syncId: string) {
|
||||||
applyControlPlaneMutation(retryAdminSync(syncId));
|
applyControlPlaneMutation(retryAdminSync(syncId));
|
||||||
}
|
}
|
||||||
|
|
@ -876,6 +893,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="launcher-main">
|
<main className="launcher-main">
|
||||||
|
|
@ -903,6 +921,8 @@ export function LauncherApp() {
|
||||||
onRejectAccessRequest={handleRejectAccessRequest}
|
onRejectAccessRequest={handleRejectAccessRequest}
|
||||||
onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest}
|
onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest}
|
||||||
onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest}
|
onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest}
|
||||||
|
onApproveEngineWorkflowAccessRequest={handleApproveEngineWorkflowAccessRequest}
|
||||||
|
onRejectEngineWorkflowAccessRequest={handleRejectEngineWorkflowAccessRequest}
|
||||||
onRetrySync={handleRetrySync}
|
onRetrySync={handleRetrySync}
|
||||||
onCreateClient={handleCreateClient}
|
onCreateClient={handleCreateClient}
|
||||||
onUpdateClient={handleUpdateClient}
|
onUpdateClient={handleUpdateClient}
|
||||||
|
|
@ -1546,6 +1566,87 @@ 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[] {
|
||||||
|
const currentUserId = me.user.id;
|
||||||
|
const currentEmail = me.user.email.toLowerCase();
|
||||||
|
const canModerate = me.permissions.canOpenAdmin;
|
||||||
|
const items: LauncherNotificationItem[] = [];
|
||||||
|
|
||||||
|
for (const request of data.accessRequests) {
|
||||||
|
const isOwnRequest = request.email.toLowerCase() === currentEmail;
|
||||||
|
if (!canModerate && !isOwnRequest) continue;
|
||||||
|
if (canModerate && request.status !== "new") continue;
|
||||||
|
|
||||||
|
const applicantName = [request.lastName, request.firstName].filter(Boolean).join(" ") || request.email;
|
||||||
|
items.push({
|
||||||
|
id: `nodedc:${request.id}`,
|
||||||
|
kind: "nodedc",
|
||||||
|
title: request.status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён",
|
||||||
|
description: `${applicantName} · ${request.email}`,
|
||||||
|
meta: request.company || formatNotificationDate(request.createdAt),
|
||||||
|
status: request.status,
|
||||||
|
createdAt: request.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const request of data.taskerInviteRequests) {
|
||||||
|
const isRelated =
|
||||||
|
request.inviterUserId === currentUserId ||
|
||||||
|
request.inviterEmail.toLowerCase() === currentEmail ||
|
||||||
|
request.inviteeEmail.toLowerCase() === currentEmail;
|
||||||
|
if (!canModerate && !isRelated) continue;
|
||||||
|
if (canModerate && request.status !== "new") 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;
|
||||||
|
if (canModerate && request.status !== "new") 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) {
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (!Number.isFinite(timestamp)) return value;
|
||||||
|
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 isAccessRequestPath(pathname: string) {
|
function isAccessRequestPath(pathname: string) {
|
||||||
return /^\/(?:request-access|access-request)\/?$/.test(pathname);
|
return /^\/(?:request-access|access-request)\/?$/.test(pathname);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
export type EngineWorkflowRole = "viewer" | "editor" | "admin";
|
||||||
|
export type EngineWorkflowAccessRequestStatus = "new" | "approved" | "rejected" | "cancelled";
|
||||||
|
|
||||||
|
export interface EngineWorkflowAccessRequest {
|
||||||
|
id: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowName: string;
|
||||||
|
targetUserId: string;
|
||||||
|
targetEmail: string;
|
||||||
|
targetName?: string | null;
|
||||||
|
role: EngineWorkflowRole;
|
||||||
|
requesterUserId?: string | null;
|
||||||
|
requesterEmail: string;
|
||||||
|
requesterName: string;
|
||||||
|
status: EngineWorkflowAccessRequestStatus;
|
||||||
|
reviewedByUserId?: string | null;
|
||||||
|
reviewedAt?: string | null;
|
||||||
|
engineAppliedAt?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { AccessRequest } from "../../entities/access-request/types";
|
import type { AccessRequest } from "../../entities/access-request/types";
|
||||||
import type { ServiceAccessException, ServiceAppRole, ServiceGrant, ServiceModuleEntitlement, ServiceModuleId } from "../../entities/access/types";
|
import type { ServiceAccessException, ServiceAppRole, ServiceGrant, ServiceModuleEntitlement, ServiceModuleId } from "../../entities/access/types";
|
||||||
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
||||||
|
import type { EngineWorkflowAccessRequest } from "../../entities/engine-workflow-access-request/types";
|
||||||
import type { Invite } from "../../entities/invite/types";
|
import type { Invite } from "../../entities/invite/types";
|
||||||
import type { Service } from "../../entities/service/types";
|
import type { Service } from "../../entities/service/types";
|
||||||
import type { SyncStatus } from "../../entities/sync/types";
|
import type { SyncStatus } from "../../entities/sync/types";
|
||||||
|
|
@ -60,6 +61,17 @@ export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationR
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EngineWorkflowAccessRequestMutationResult extends ControlPlaneMutationResult {
|
||||||
|
engineWorkflowAccessRequest: EngineWorkflowAccessRequest;
|
||||||
|
engine?: {
|
||||||
|
ok: boolean;
|
||||||
|
workflowId?: string;
|
||||||
|
userId?: string;
|
||||||
|
role?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskManagerWorkspaceSummary {
|
export interface TaskManagerWorkspaceSummary {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -393,6 +405,32 @@ export async function rejectAdminTaskerInviteRequest(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function approveAdminEngineWorkflowAccessRequest(
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
payload: Partial<Pick<EngineWorkflowAccessRequest, "comment">> = {}
|
||||||
|
): Promise<EngineWorkflowAccessRequestMutationResult> {
|
||||||
|
return requestJson<EngineWorkflowAccessRequestMutationResult>(
|
||||||
|
`/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectAdminEngineWorkflowAccessRequest(
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
payload: Partial<Pick<EngineWorkflowAccessRequest, "comment">> = {}
|
||||||
|
): Promise<EngineWorkflowAccessRequestMutationResult> {
|
||||||
|
return requestJson<EngineWorkflowAccessRequestMutationResult>(
|
||||||
|
`/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/reject`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setAdminUserServiceAccess(payload: {
|
export async function setAdminUserServiceAccess(payload: {
|
||||||
userId: string;
|
userId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAc
|
||||||
import type { AccessRequest } from "../../entities/access-request/types";
|
import type { AccessRequest } from "../../entities/access-request/types";
|
||||||
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant, ServiceModuleEntitlement } from "../../entities/access/types";
|
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant, ServiceModuleEntitlement } from "../../entities/access/types";
|
||||||
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
||||||
|
import type { EngineWorkflowAccessRequest } from "../../entities/engine-workflow-access-request/types";
|
||||||
import type { Invite } from "../../entities/invite/types";
|
import type { Invite } from "../../entities/invite/types";
|
||||||
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
||||||
import { getServiceLaunchLink } from "../../entities/service/links";
|
import { getServiceLaunchLink } from "../../entities/service/links";
|
||||||
|
|
@ -19,6 +20,7 @@ import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } fro
|
||||||
import {
|
import {
|
||||||
mockAuditEvents,
|
mockAuditEvents,
|
||||||
mockAccessRequests,
|
mockAccessRequests,
|
||||||
|
mockEngineWorkflowAccessRequests,
|
||||||
mockTaskerInviteRequests,
|
mockTaskerInviteRequests,
|
||||||
mockClients,
|
mockClients,
|
||||||
mockExceptions,
|
mockExceptions,
|
||||||
|
|
@ -67,6 +69,7 @@ export interface LauncherData {
|
||||||
accessRequests: AccessRequest[];
|
accessRequests: AccessRequest[];
|
||||||
revokedAccounts: RevokedAccount[];
|
revokedAccounts: RevokedAccount[];
|
||||||
taskerInviteRequests: TaskerInviteRequest[];
|
taskerInviteRequests: TaskerInviteRequest[];
|
||||||
|
engineWorkflowAccessRequests: EngineWorkflowAccessRequest[];
|
||||||
syncStatuses: SyncStatus[];
|
syncStatuses: SyncStatus[];
|
||||||
auditEvents: typeof mockAuditEvents;
|
auditEvents: typeof mockAuditEvents;
|
||||||
taskManagerMemberships: TaskManagerMembershipAssignment[];
|
taskManagerMemberships: TaskManagerMembershipAssignment[];
|
||||||
|
|
@ -171,6 +174,7 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({
|
||||||
accessRequests: mockAccessRequests,
|
accessRequests: mockAccessRequests,
|
||||||
revokedAccounts: [],
|
revokedAccounts: [],
|
||||||
taskerInviteRequests: mockTaskerInviteRequests,
|
taskerInviteRequests: mockTaskerInviteRequests,
|
||||||
|
engineWorkflowAccessRequests: mockEngineWorkflowAccessRequests,
|
||||||
syncStatuses: mockSyncStatuses,
|
syncStatuses: mockSyncStatuses,
|
||||||
auditEvents: mockAuditEvents,
|
auditEvents: mockAuditEvents,
|
||||||
settings: defaultLauncherSettings,
|
settings: defaultLauncherSettings,
|
||||||
|
|
@ -220,6 +224,9 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
||||||
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
||||||
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],
|
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],
|
||||||
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
||||||
|
engineWorkflowAccessRequests: Array.isArray(payload.engineWorkflowAccessRequests)
|
||||||
|
? payload.engineWorkflowAccessRequests
|
||||||
|
: mockEngineWorkflowAccessRequests,
|
||||||
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||||
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||||
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
|
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { AuditEvent } from "../../entities/audit/types";
|
import type { AuditEvent } from "../../entities/audit/types";
|
||||||
import type { AccessRequest } from "../../entities/access-request/types";
|
import type { AccessRequest } from "../../entities/access-request/types";
|
||||||
|
import type { EngineWorkflowAccessRequest } from "../../entities/engine-workflow-access-request/types";
|
||||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||||
import type { Client } from "../../entities/client/types";
|
import type { Client } from "../../entities/client/types";
|
||||||
import type { Invite } from "../../entities/invite/types";
|
import type { Invite } from "../../entities/invite/types";
|
||||||
|
|
@ -218,6 +219,7 @@ export const mockInvites: Invite[] = [];
|
||||||
|
|
||||||
export const mockAccessRequests: AccessRequest[] = [];
|
export const mockAccessRequests: AccessRequest[] = [];
|
||||||
export const mockTaskerInviteRequests: TaskerInviteRequest[] = [];
|
export const mockTaskerInviteRequests: TaskerInviteRequest[] = [];
|
||||||
|
export const mockEngineWorkflowAccessRequests: EngineWorkflowAccessRequest[] = [];
|
||||||
|
|
||||||
export const mockSyncStatuses: SyncStatus[] = [
|
export const mockSyncStatuses: SyncStatus[] = [
|
||||||
sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"),
|
sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"),
|
||||||
|
|
|
||||||
|
|
@ -481,10 +481,18 @@ code {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(64, 64, 64, 0.48);
|
background: rgba(64, 64, 64, 0.48);
|
||||||
padding: var(--nodedc-shell-pill-padding);
|
padding: var(--nodedc-shell-pill-padding);
|
||||||
cursor: pointer;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-expanded-profile-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.22rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-expanded-user-group .nodedc-expanded-nav-button {
|
.nodedc-expanded-user-group .nodedc-expanded-nav-button {
|
||||||
min-height: var(--nodedc-shell-control-height);
|
min-height: var(--nodedc-shell-control-height);
|
||||||
padding-inline: 1.2rem;
|
padding-inline: 1.2rem;
|
||||||
|
|
@ -583,6 +591,7 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-expanded-notification-button {
|
.nodedc-expanded-notification-button {
|
||||||
|
position: relative;
|
||||||
height: var(--nodedc-shell-control-height);
|
height: var(--nodedc-shell-control-height);
|
||||||
width: var(--nodedc-shell-control-height);
|
width: var(--nodedc-shell-control-height);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -600,6 +609,22 @@ code {
|
||||||
color: rgba(255, 255, 255, 0.94);
|
color: rgba(255, 255, 255, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.22rem;
|
||||||
|
right: 0.16rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
|
color: rgb(var(--nodedc-on-accent-rgb));
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-expanded-user-avatar-button {
|
.nodedc-expanded-user-avatar-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
|
|
@ -4621,6 +4646,154 @@ code {
|
||||||
gap: 0.32rem;
|
gap: 0.32rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 14000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 28% 18%, rgba(195, 255, 102, 0.06), transparent 30%),
|
||||||
|
rgba(0, 0, 0, 0.56);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-modal {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
width: min(80rem, calc(100vw - 7rem));
|
||||||
|
height: min(52rem, calc(100dvh - 5rem));
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--launcher-radius-modal);
|
||||||
|
background: rgba(13, 13, 16, 0.94);
|
||||||
|
box-shadow: 0 2rem 5rem rgba(0, 0, 0, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.8rem 1.8rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-head span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-close {
|
||||||
|
display: grid;
|
||||||
|
width: 2.35rem;
|
||||||
|
height: 2.35rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 1rem 1.55rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-tab {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px 999px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-tab[data-active="true"] {
|
||||||
|
color: rgb(var(--nodedc-accent-rgb));
|
||||||
|
box-shadow: inset 0 -2px 0 rgb(var(--nodedc-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.2rem 1.55rem 1.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 18rem;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notifications-empty strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border-radius: 1.05rem;
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card[data-status="new"] {
|
||||||
|
background: rgba(195, 255, 102, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__kicker,
|
||||||
|
.nodedc-notification-card__foot {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__description {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-notification-card__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-ui-profile-card__cover {
|
.nodedc-ui-profile-card__cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import {
|
||||||
import type { AccessRequest, AccessRequestStatus } from "../../entities/access-request/types";
|
import type { AccessRequest, AccessRequestStatus } from "../../entities/access-request/types";
|
||||||
import type { ServiceAppRole, ServiceModuleId } from "../../entities/access/types";
|
import type { ServiceAppRole, ServiceModuleId } from "../../entities/access/types";
|
||||||
import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types";
|
import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types";
|
||||||
|
import type { EngineWorkflowAccessRequest } from "../../entities/engine-workflow-access-request/types";
|
||||||
import type { Invite, InviteStatus } from "../../entities/invite/types";
|
import type { Invite, InviteStatus } from "../../entities/invite/types";
|
||||||
import { PUBLIC_POOL_CLIENT_ID, PUBLIC_POOL_CONTEXT_DESCRIPTION, PUBLIC_POOL_CONTEXT_LABEL, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
import { PUBLIC_POOL_CLIENT_ID, PUBLIC_POOL_CONTEXT_DESCRIPTION, PUBLIC_POOL_CONTEXT_LABEL, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
||||||
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
|
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
|
||||||
|
|
@ -192,6 +193,8 @@ export function AdminOverlay({
|
||||||
onRejectAccessRequest,
|
onRejectAccessRequest,
|
||||||
onApproveTaskerInviteRequest,
|
onApproveTaskerInviteRequest,
|
||||||
onRejectTaskerInviteRequest,
|
onRejectTaskerInviteRequest,
|
||||||
|
onApproveEngineWorkflowAccessRequest,
|
||||||
|
onRejectEngineWorkflowAccessRequest,
|
||||||
onRetrySync,
|
onRetrySync,
|
||||||
onCreateClient,
|
onCreateClient,
|
||||||
onUpdateClient,
|
onUpdateClient,
|
||||||
|
|
@ -241,6 +244,14 @@ export function AdminOverlay({
|
||||||
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
||||||
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => void;
|
||||||
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => void;
|
||||||
onRetrySync: (syncId: string) => void;
|
onRetrySync: (syncId: string) => void;
|
||||||
onCreateClient: () => void;
|
onCreateClient: () => void;
|
||||||
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
||||||
|
|
@ -563,6 +574,8 @@ export function AdminOverlay({
|
||||||
onRejectAccessRequest={onRejectAccessRequest}
|
onRejectAccessRequest={onRejectAccessRequest}
|
||||||
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
||||||
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
||||||
|
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
|
||||||
|
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
||||||
|
|
@ -1358,6 +1371,13 @@ const taskerInviteRequestStatusOptions: Array<AdminStatusOption<TaskerInviteRequ
|
||||||
{ value: "cancelled", label: "Отозвано", tone: "red" },
|
{ value: "cancelled", label: "Отозвано", tone: "red" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const engineWorkflowAccessRequestStatusOptions: Array<AdminStatusOption<EngineWorkflowAccessRequest["status"]>> = [
|
||||||
|
{ value: "new", label: "Ожидает", tone: "yellow" },
|
||||||
|
{ value: "approved", label: "Подтверждено", tone: "green" },
|
||||||
|
{ value: "rejected", label: "Отклонено", tone: "red" },
|
||||||
|
{ value: "cancelled", label: "Отозвано", tone: "red" },
|
||||||
|
];
|
||||||
|
|
||||||
const syncStatusOptions: Array<AdminStatusOption<SyncState>> = [
|
const syncStatusOptions: Array<AdminStatusOption<SyncState>> = [
|
||||||
{ value: "synced", label: "Синхронизировано", tone: "green" },
|
{ value: "synced", label: "Синхронизировано", tone: "green" },
|
||||||
{ value: "pending", label: "В очереди", tone: "yellow" },
|
{ value: "pending", label: "В очереди", tone: "yellow" },
|
||||||
|
|
@ -3779,6 +3799,8 @@ function InvitesSection({
|
||||||
onRejectAccessRequest,
|
onRejectAccessRequest,
|
||||||
onApproveTaskerInviteRequest,
|
onApproveTaskerInviteRequest,
|
||||||
onRejectTaskerInviteRequest,
|
onRejectTaskerInviteRequest,
|
||||||
|
onApproveEngineWorkflowAccessRequest,
|
||||||
|
onRejectEngineWorkflowAccessRequest,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|
@ -3798,6 +3820,14 @@ function InvitesSection({
|
||||||
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
||||||
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => void;
|
||||||
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [role, setRole] = useState<ClientMembershipRole>("member");
|
const [role, setRole] = useState<ClientMembershipRole>("member");
|
||||||
|
|
@ -3806,10 +3836,13 @@ function InvitesSection({
|
||||||
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
|
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
|
||||||
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
||||||
const incomingRequestsTotal =
|
const incomingRequestsTotal =
|
||||||
data.accessRequests.length + data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length;
|
data.accessRequests.length +
|
||||||
|
data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length +
|
||||||
|
data.engineWorkflowAccessRequests.filter((request) => request.status !== "cancelled").length;
|
||||||
const pendingIncomingRequests =
|
const pendingIncomingRequests =
|
||||||
data.accessRequests.filter((request) => request.status === "new").length +
|
data.accessRequests.filter((request) => request.status === "new").length +
|
||||||
data.taskerInviteRequests.filter((request) => request.status === "new").length;
|
data.taskerInviteRequests.filter((request) => request.status === "new").length +
|
||||||
|
data.engineWorkflowAccessRequests.filter((request) => request.status === "new").length;
|
||||||
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
|
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
|
||||||
const actor = getUser(data, actorUserId);
|
const actor = getUser(data, actorUserId);
|
||||||
const clientOptions: Array<NodeDcSelectOption<string>> = [
|
const clientOptions: Array<NodeDcSelectOption<string>> = [
|
||||||
|
|
@ -3867,7 +3900,7 @@ function InvitesSection({
|
||||||
</div>
|
</div>
|
||||||
<p className="admin-helper-note">
|
<p className="admin-helper-note">
|
||||||
{publicInviteTab === "incoming"
|
{publicInviteTab === "incoming"
|
||||||
? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.`
|
? `Входящие — общие заявки NODE.DC, Operational Core и Engine. Новых к обработке: ${pendingIncomingRequests}.`
|
||||||
: "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}
|
: "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}
|
||||||
</p>
|
</p>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
@ -3884,6 +3917,8 @@ function InvitesSection({
|
||||||
onRejectAccessRequest={onRejectAccessRequest}
|
onRejectAccessRequest={onRejectAccessRequest}
|
||||||
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
||||||
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
||||||
|
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
|
||||||
|
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -4073,6 +4108,8 @@ function AccessRequestsPanel({
|
||||||
onRejectAccessRequest,
|
onRejectAccessRequest,
|
||||||
onApproveTaskerInviteRequest,
|
onApproveTaskerInviteRequest,
|
||||||
onRejectTaskerInviteRequest,
|
onRejectTaskerInviteRequest,
|
||||||
|
onApproveEngineWorkflowAccessRequest,
|
||||||
|
onRejectEngineWorkflowAccessRequest,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
clientOptions: Array<NodeDcSelectOption<string>>;
|
clientOptions: Array<NodeDcSelectOption<string>>;
|
||||||
|
|
@ -4089,19 +4126,31 @@ function AccessRequestsPanel({
|
||||||
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
||||||
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||||
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => void;
|
||||||
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => 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));
|
||||||
const taskerInviteRequests = data.taskerInviteRequests
|
const taskerInviteRequests = data.taskerInviteRequests
|
||||||
.filter((request) => request.status !== "cancelled")
|
.filter((request) => request.status !== "cancelled")
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
const engineWorkflowAccessRequests = data.engineWorkflowAccessRequests
|
||||||
|
.filter((request) => request.status !== "cancelled")
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h3>Входящие запросы доступа</h3>
|
<h3>NODE.DC: входящие запросы доступа</h3>
|
||||||
<p className="admin-helper-note">
|
<p className="admin-helper-note">
|
||||||
Перед approve выберите целевой контур. Аккаунт уже создан в Authentik и активируется после подтверждения.
|
Перед approve выберите целевой контур. Аккаунт уже создан в Authentik и активируется после подтверждения.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -4243,6 +4292,11 @@ function AccessRequestsPanel({
|
||||||
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
||||||
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
||||||
/>
|
/>
|
||||||
|
<EngineWorkflowAccessRequestsPanel
|
||||||
|
requests={engineWorkflowAccessRequests}
|
||||||
|
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
|
||||||
|
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -4277,7 +4331,7 @@ function TaskerInviteRequestsPanel({
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h3>Запросы workspace-инвайтов</h3>
|
<h3>Operational Core: входящие запросы доступа</h3>
|
||||||
<p className="admin-helper-note">
|
<p className="admin-helper-note">
|
||||||
Self-service админы Operational Core создают эти заявки из настроек workspace. После approve ссылка становится доступна
|
Self-service админы Operational Core создают эти заявки из настроек workspace. После approve ссылка становится доступна
|
||||||
пригласившему пользователю.
|
пригласившему пользователю.
|
||||||
|
|
@ -4385,6 +4439,109 @@ function TaskerInviteRequestsPanel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EngineWorkflowAccessRequestsPanel({
|
||||||
|
requests,
|
||||||
|
onApproveEngineWorkflowAccessRequest,
|
||||||
|
onRejectEngineWorkflowAccessRequest,
|
||||||
|
}: {
|
||||||
|
requests: EngineWorkflowAccessRequest[];
|
||||||
|
onApproveEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => void;
|
||||||
|
onRejectEngineWorkflowAccessRequest: (
|
||||||
|
engineWorkflowAccessRequestId: string,
|
||||||
|
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<GlassSurface className="table-shell">
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<div>
|
||||||
|
<h3>NODE.DC Engine: запросы доступа к workflow</h3>
|
||||||
|
<p className="admin-helper-note">
|
||||||
|
Эти заявки создаются из Engine, когда workflow хотят пошарить зарегистрированному пользователю без доступа к Engine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<div className="access-empty-state">
|
||||||
|
<strong>Запросов Engine пока нет</strong>
|
||||||
|
<span>Если пользователь Engine попросит выдать доступ к workflow, заявка появится здесь.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="access-request-table-scroll">
|
||||||
|
<table className="admin-data-table admin-data-table--access-requests">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Workflow</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Инициатор</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th aria-label="Действия" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requests.map((request) => (
|
||||||
|
<tr key={request.id}>
|
||||||
|
<td>
|
||||||
|
<div className="access-request-applicant">
|
||||||
|
<strong>{request.workflowName}</strong>
|
||||||
|
<small>{request.workflowId}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="access-request-contact">
|
||||||
|
<span>{request.targetName || request.targetEmail}</span>
|
||||||
|
<small>{request.targetEmail}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="access-request-contact">
|
||||||
|
<span>{request.requesterName}</span>
|
||||||
|
<small>{request.requesterEmail}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{engineWorkflowRoleLabel(request.role)}</td>
|
||||||
|
<td>
|
||||||
|
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{request.status === "new" ? (
|
||||||
|
<div className="access-request-decision-cluster">
|
||||||
|
<button
|
||||||
|
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
|
||||||
|
className="access-request-decision-button access-request-decision-button--accept"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, {})}
|
||||||
|
>
|
||||||
|
<Check size={16} strokeWidth={2.6} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={`Отклонить доступ Engine ${request.targetEmail}`}
|
||||||
|
className="access-request-decision-button access-request-decision-button--decline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRejectEngineWorkflowAccessRequest(request.id, {})}
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted-text">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatAccessRequestName(accessRequest: AccessRequest) {
|
function formatAccessRequestName(accessRequest: AccessRequest) {
|
||||||
return [accessRequest.lastName, accessRequest.firstName, accessRequest.middleName].filter(Boolean).join(" ");
|
return [accessRequest.lastName, accessRequest.firstName, accessRequest.middleName].filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
@ -4809,6 +4966,16 @@ function taskerInviteRoleLabel(role: TaskerInviteRequest["role"]): string {
|
||||||
return labels[role] ?? role;
|
return labels[role] ?? role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function engineWorkflowRoleLabel(role: EngineWorkflowAccessRequest["role"]): string {
|
||||||
|
const labels: Record<EngineWorkflowAccessRequest["role"], string> = {
|
||||||
|
viewer: "Просмотр",
|
||||||
|
editor: "Редактор",
|
||||||
|
admin: "Соавтор",
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[role] ?? role;
|
||||||
|
}
|
||||||
|
|
||||||
function sectionTitle(section: AdminSection): string {
|
function sectionTitle(section: AdminSection): string {
|
||||||
const labels: Record<AdminSection, string> = {
|
const labels: Record<AdminSection, string> = {
|
||||||
overview: "Обзор",
|
overview: "Обзор",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { Inbox } from "lucide-react";
|
import { Inbox, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import type { Client } from "../../entities/client/types";
|
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";
|
||||||
|
|
@ -6,6 +8,18 @@ import { initials } from "../../shared/lib/format";
|
||||||
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
|
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
|
||||||
|
|
||||||
export type LauncherAdminMode = "admin" | "platform";
|
export type LauncherAdminMode = "admin" | "platform";
|
||||||
|
export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine";
|
||||||
|
export type LauncherNotificationStatus = "new" | "approved" | "rejected" | "cancelled";
|
||||||
|
|
||||||
|
export interface LauncherNotificationItem {
|
||||||
|
id: string;
|
||||||
|
kind: LauncherNotificationKind;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
meta?: string;
|
||||||
|
status: LauncherNotificationStatus;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
me,
|
me,
|
||||||
|
|
@ -23,6 +37,7 @@ export function TopBar({
|
||||||
onOpenProfileSettings,
|
onOpenProfileSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
brandLinkUrl = "/",
|
brandLinkUrl = "/",
|
||||||
|
notifications = [],
|
||||||
}: {
|
}: {
|
||||||
me: MeResponse;
|
me: MeResponse;
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
|
@ -39,7 +54,10 @@ export function TopBar({
|
||||||
onOpenProfileSettings: () => void;
|
onOpenProfileSettings: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
brandLinkUrl?: string;
|
brandLinkUrl?: string;
|
||||||
|
notifications?: LauncherNotificationItem[];
|
||||||
}) {
|
}) {
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
|
const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all");
|
||||||
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,
|
||||||
|
|
@ -54,6 +72,66 @@ 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 visibleNotifications = useMemo(
|
||||||
|
() => notifications.filter((notification) => notificationFilter === "all" || notification.kind === notificationFilter),
|
||||||
|
[notificationFilter, notifications]
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationsModal = notificationsOpen && typeof document !== "undefined"
|
||||||
|
? createPortal(
|
||||||
|
<div className="nodedc-notifications-overlay" onMouseDown={() => setNotificationsOpen(false)}>
|
||||||
|
<section className="nodedc-notifications-modal" aria-modal="true" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<div className="nodedc-notifications-head">
|
||||||
|
<div>
|
||||||
|
<h2>Уведомления</h2>
|
||||||
|
<span>NODE DC</span>
|
||||||
|
</div>
|
||||||
|
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={() => setNotificationsOpen(false)}>
|
||||||
|
<X size={18} strokeWidth={1.8} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-notifications-tabs" role="tablist" aria-label="Фильтр уведомлений">
|
||||||
|
<NotificationFilterButton active={notificationFilter === "all"} onClick={() => setNotificationFilter("all")}>
|
||||||
|
Все
|
||||||
|
</NotificationFilterButton>
|
||||||
|
<NotificationFilterButton active={notificationFilter === "nodedc"} onClick={() => setNotificationFilter("nodedc")}>
|
||||||
|
NODE.DC
|
||||||
|
</NotificationFilterButton>
|
||||||
|
<NotificationFilterButton active={notificationFilter === "operational-core"} onClick={() => setNotificationFilter("operational-core")}>
|
||||||
|
Operational Core
|
||||||
|
</NotificationFilterButton>
|
||||||
|
<NotificationFilterButton active={notificationFilter === "engine"} onClick={() => setNotificationFilter("engine")}>
|
||||||
|
Engine
|
||||||
|
</NotificationFilterButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-notifications-list">
|
||||||
|
{visibleNotifications.length === 0 ? (
|
||||||
|
<div className="nodedc-notifications-empty">
|
||||||
|
<strong>Новых входящих нет</strong>
|
||||||
|
<span>Заявки NODE.DC, Operational Core и Engine появятся здесь.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleNotifications.map((notification) => (
|
||||||
|
<article className="nodedc-notification-card" data-status={notification.status} key={notification.id}>
|
||||||
|
<div className="nodedc-notification-card__kicker">{notificationKindLabel(notification.kind)}</div>
|
||||||
|
<div className="nodedc-notification-card__title">{notification.title}</div>
|
||||||
|
<div className="nodedc-notification-card__description">{notification.description}</div>
|
||||||
|
<div className="nodedc-notification-card__foot">
|
||||||
|
<span>{notificationStatusLabel(notification.status)}</span>
|
||||||
|
{notification.meta ? <span>{notification.meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="nodedc-expanded-toolbar-shell">
|
<header className="nodedc-expanded-toolbar-shell">
|
||||||
|
|
@ -122,6 +200,19 @@ export function TopBar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nodedc-expanded-toolbar-right">
|
<div className="nodedc-expanded-toolbar-right">
|
||||||
|
<div className="nodedc-expanded-user-group">
|
||||||
|
<button
|
||||||
|
className="nodedc-toolbar-icon-button nodedc-expanded-notification-button"
|
||||||
|
data-active={unreadCount > 0 ? "true" : "false"}
|
||||||
|
type="button"
|
||||||
|
aria-label="Уведомления"
|
||||||
|
onClick={() => setNotificationsOpen(true)}
|
||||||
|
>
|
||||||
|
<span className="nodedc-toolbar-icon-active-dot">
|
||||||
|
<Inbox size={20} strokeWidth={1.7} />
|
||||||
|
</span>
|
||||||
|
{unreadCount > 0 ? <span className="nodedc-notification-badge">{unreadCount > 9 ? "9+" : unreadCount}</span> : null}
|
||||||
|
</button>
|
||||||
<NodeDcProfileMenu
|
<NodeDcProfileMenu
|
||||||
user={me.user}
|
user={me.user}
|
||||||
onSettings={onOpenProfileSettings}
|
onSettings={onOpenProfileSettings}
|
||||||
|
|
@ -129,7 +220,7 @@ export function TopBar({
|
||||||
trigger={({ open, toggle, setTriggerRef }) => (
|
trigger={({ open, toggle, setTriggerRef }) => (
|
||||||
<div
|
<div
|
||||||
ref={setTriggerRef}
|
ref={setTriggerRef}
|
||||||
className="nodedc-expanded-user-group"
|
className="nodedc-expanded-profile-trigger"
|
||||||
title={`${me.user.name} · ${me.user.email}`}
|
title={`${me.user.name} · ${me.user.email}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -146,11 +237,6 @@ export function TopBar({
|
||||||
<span className="nodedc-expanded-nav-button" data-active="false">
|
<span className="nodedc-expanded-nav-button" data-active="false">
|
||||||
<span>Профиль</span>
|
<span>Профиль</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" data-active="false" aria-hidden="true">
|
|
||||||
<span className="nodedc-toolbar-icon-active-dot">
|
|
||||||
<Inbox size={20} strokeWidth={1.7} />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
|
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
|
||||||
{me.user.avatarUrl ? (
|
{me.user.avatarUrl ? (
|
||||||
<img className="nodedc-expanded-user-avatar" src={me.user.avatarUrl} alt="" style={{ objectFit: "cover" }} />
|
<img className="nodedc-expanded-user-avatar" src={me.user.avatarUrl} alt="" style={{ objectFit: "cover" }} />
|
||||||
|
|
@ -162,8 +248,47 @@ export function TopBar({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{notificationsModal}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationFilterButton({
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
children: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className="nodedc-notifications-tab" data-active={active ? "true" : "false"} type="button" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationKindLabel(kind: LauncherNotificationKind): string {
|
||||||
|
const labels: Record<LauncherNotificationKind, string> = {
|
||||||
|
nodedc: "NODE.DC",
|
||||||
|
"operational-core": "Operational Core",
|
||||||
|
engine: "NODE.DC Engine",
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationStatusLabel(status: LauncherNotificationStatus): string {
|
||||||
|
const labels: Record<LauncherNotificationStatus, string> = {
|
||||||
|
new: "Входящее",
|
||||||
|
approved: "Подтверждено",
|
||||||
|
rejected: "Отклонено",
|
||||||
|
cancelled: "Отменено",
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[status];
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue