From 179508f4c973703700cabad9cf709e3734192cb7 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 23 May 2026 14:10:22 +0300 Subject: [PATCH] feat: add engine workflow access requests --- server/control-plane-store.mjs | 228 ++++++++++++++++++ server/dev-server.mjs | 156 ++++++++++++ src/app/LauncherApp.tsx | 103 +++++++- .../engine-workflow-access-request/types.ts | 22 ++ src/shared/api/adminApi.ts | 38 +++ src/shared/api/mockApi.ts | 7 + src/shared/api/mockData.ts | 2 + src/styles/globals.css | 175 +++++++++++++- src/widgets/admin-overlay/AdminOverlay.tsx | 177 +++++++++++++- src/widgets/top-bar/TopBar.tsx | 203 +++++++++++++--- 10 files changed, 1065 insertions(+), 46 deletions(-) create mode 100644 src/entities/engine-workflow-access-request/types.ts diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 597b318..3c1c1e3 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -16,6 +16,7 @@ const collectionKeys = [ "accessRequests", "revokedAccounts", "taskerInviteRequests", + "engineWorkflowAccessRequests", "syncStatuses", "auditEvents", "taskManagerMemberships", @@ -36,6 +37,8 @@ const serviceModuleIds = new Set(["codex_agents"]); const accessRequestStatuses = new Set(["new", "approved", "rejected"]); const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); 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 engineAuthentikGroups = [ "nodedc:engine:admin", @@ -1130,6 +1133,141 @@ export function createControlPlaneStore({ projectRoot }) { 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" }) { const data = readData(); const actor = resolveActor(data, identity); @@ -1866,10 +2004,12 @@ export function createControlPlaneStore({ projectRoot }) { return { approveAccessRequest, + approveEngineWorkflowAccessRequest, approveTaskerInviteRequest, buildAuthentikSyncPlan, cancelTaskerInviteRequest, createAccessRequest, + createEngineWorkflowAccessRequest, createTaskerInviteRequest, createClient, createGroup, @@ -1883,6 +2023,7 @@ export function createControlPlaneStore({ projectRoot }) { deleteService, deleteUser, rejectAccessRequest, + rejectEngineWorkflowAccessRequest, rejectTaskerInviteRequest, acceptInvite, commitInviteRegistration, @@ -1945,6 +2086,7 @@ function normalizeData(payload) { data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean); data.serviceModuleEntitlements = data.serviceModuleEntitlements.map(normalizeServiceModuleEntitlement).filter(Boolean); data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean); + data.engineWorkflowAccessRequests = data.engineWorkflowAccessRequests.map(normalizeEngineWorkflowAccessRequest).filter(Boolean); 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) { const settings = typeof payload === "object" && payload !== null ? payload : {}; const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {}; @@ -2457,6 +2630,52 @@ function ensureTaskerInviteServiceAccess(data, invite, user, now) { 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) { const service = data.services.find((candidate) => candidate.slug === "task-manager"); if (!service) { @@ -2696,6 +2915,10 @@ function findTaskerInviteRequestById(data, taskerInviteRequestId) { 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) { const clientId = optionalString(value, fallback || publicPoolClientId); findClientById(data, clientId); @@ -2801,6 +3024,11 @@ function normalizeTaskManagerInviteRole(value) { 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) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 78e9459..0ef10c3 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -547,6 +547,69 @@ app.post("/api/internal/tasker/invite-requests/cancel", asyncRoute(async (req, r 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) => { if (!isInternalRequestAuthorized(req)) { 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 })); })); +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) => { if (!assertAdminCanManageClient(req, res, req.body?.clientId)) { return; @@ -1670,6 +1783,11 @@ function readConfig() { taskInternalLogoutUrl: process.env.TASK_INTERNAL_LOGOUT_URL ?? `${(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(/\/$/, ""); } +function getEngineBaseUrl() { + return String(config.engineBaseUrl || "https://engine.nodedc.ru").replace(/\/$/, ""); +} + async function requestTaskManagerInternalJson(pathname, init = {}) { if (!config.internalAccessToken) { throw new Error("NODE.DC internal access token is not configured"); @@ -2260,6 +2382,37 @@ async function requestTaskManagerInternalJson(pathname, init = {}) { 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) { if (!user?.email || !config.internalAccessToken) { return null; @@ -3363,6 +3516,7 @@ function scopeControlPlaneData(data, scope) { invites: data.invites.filter((invite) => clientIds.has(invite.clientId)), accessRequests: [], taskerInviteRequests: [], + engineWorkflowAccessRequests: [], grants: data.grants.filter((grant) => { if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "group") return groupIds.has(grant.targetId); @@ -3398,6 +3552,7 @@ function scopeRuntimeControlPlaneData(data, userId) { accessRequests: [], revokedAccounts: [], taskerInviteRequests: [], + engineWorkflowAccessRequests: [], grants: [], exceptions: [], serviceModuleEntitlements: [], @@ -3425,6 +3580,7 @@ function scopeRuntimeControlPlaneData(data, userId) { accessRequests: [], revokedAccounts: [], taskerInviteRequests: [], + engineWorkflowAccessRequests: [], grants: data.grants.filter((grant) => { if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "group") return groupIds.has(grant.targetId); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index bc632b2..baaf788 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -6,6 +6,7 @@ import type { LauncherServiceView, Service } from "../entities/service/types"; import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types"; import { approveAdminAccessRequest, + approveAdminEngineWorkflowAccessRequest, approveAdminTaskerInviteRequest, createAdminClient, createAdminGroup, @@ -24,6 +25,7 @@ import { reorderAdminServices, retryAdminSync, rejectAdminAccessRequest, + rejectAdminEngineWorkflowAccessRequest, rejectAdminTaskerInviteRequest, removeAdminTaskManagerProjectMembership, removeAdminTaskManagerWorkspaceMembership, @@ -72,7 +74,7 @@ import type { } from "../widgets/admin-overlay/AdminOverlay"; import { ServiceRail } from "../widgets/service-rail/ServiceRail"; 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; @@ -159,6 +161,7 @@ export function LauncherApp() { }; }, [authSession, me]); const resolvedClientId = me.activeClientId; + const notifications = useMemo(() => buildLauncherNotifications(data, runtimeMe), [data, runtimeMe]); const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin); const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]); const launcherServices = useMemo( @@ -697,6 +700,20 @@ export function LauncherApp() { applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch)); } + function handleApproveEngineWorkflowAccessRequest( + engineWorkflowAccessRequestId: string, + patch: Parameters[1] + ) { + applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); + } + + function handleRejectEngineWorkflowAccessRequest( + engineWorkflowAccessRequestId: string, + patch: Parameters[1] + ) { + applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); + } + function handleRetrySync(syncId: string) { applyControlPlaneMutation(retryAdminSync(syncId)); } @@ -876,6 +893,7 @@ export function LauncherApp() { onOpenProfileSettings={() => setProfileSettingsOpen(true)} onLogout={handleLogout} brandLinkUrl={data.settings.brand.logoLinkUrl} + notifications={notifications} />
@@ -903,6 +921,8 @@ export function LauncherApp() { onRejectAccessRequest={handleRejectAccessRequest} onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest} onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest} + onApproveEngineWorkflowAccessRequest={handleApproveEngineWorkflowAccessRequest} + onRejectEngineWorkflowAccessRequest={handleRejectEngineWorkflowAccessRequest} onRetrySync={handleRetrySync} onCreateClient={handleCreateClient} onUpdateClient={handleUpdateClient} @@ -1546,6 +1566,87 @@ function parseInviteToken(pathname: string) { return match?.[1] ? decodeURIComponent(match[1]) : null; } +function buildLauncherNotifications(data: LauncherData, me: ReturnType): 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) { return /^\/(?:request-access|access-request)\/?$/.test(pathname); } diff --git a/src/entities/engine-workflow-access-request/types.ts b/src/entities/engine-workflow-access-request/types.ts new file mode 100644 index 0000000..277478e --- /dev/null +++ b/src/entities/engine-workflow-access-request/types.ts @@ -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; +} diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 72d0a74..233e9e5 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -1,6 +1,7 @@ import type { AccessRequest } from "../../entities/access-request/types"; import type { ServiceAccessException, ServiceAppRole, ServiceGrant, ServiceModuleEntitlement, ServiceModuleId } from "../../entities/access/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 { Service } from "../../entities/service/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 { id: string; slug: string; @@ -393,6 +405,32 @@ export async function rejectAdminTaskerInviteRequest( ); } +export async function approveAdminEngineWorkflowAccessRequest( + engineWorkflowAccessRequestId: string, + payload: Partial> = {} +): Promise { + return requestJson( + `/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`, + { + method: "POST", + body: JSON.stringify(payload), + } + ); +} + +export async function rejectAdminEngineWorkflowAccessRequest( + engineWorkflowAccessRequestId: string, + payload: Partial> = {} +): Promise { + return requestJson( + `/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/reject`, + { + method: "POST", + body: JSON.stringify(payload), + } + ); +} + export async function setAdminUserServiceAccess(payload: { userId: string; serviceId: string; diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index 4b19375..dfdde54 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -2,6 +2,7 @@ import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAc import type { AccessRequest } from "../../entities/access-request/types"; import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant, ServiceModuleEntitlement } from "../../entities/access/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 { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants"; import { getServiceLaunchLink } from "../../entities/service/links"; @@ -19,6 +20,7 @@ import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } fro import { mockAuditEvents, mockAccessRequests, + mockEngineWorkflowAccessRequests, mockTaskerInviteRequests, mockClients, mockExceptions, @@ -67,6 +69,7 @@ export interface LauncherData { accessRequests: AccessRequest[]; revokedAccounts: RevokedAccount[]; taskerInviteRequests: TaskerInviteRequest[]; + engineWorkflowAccessRequests: EngineWorkflowAccessRequest[]; syncStatuses: SyncStatus[]; auditEvents: typeof mockAuditEvents; taskManagerMemberships: TaskManagerMembershipAssignment[]; @@ -171,6 +174,7 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({ accessRequests: mockAccessRequests, revokedAccounts: [], taskerInviteRequests: mockTaskerInviteRequests, + engineWorkflowAccessRequests: mockEngineWorkflowAccessRequests, syncStatuses: mockSyncStatuses, auditEvents: mockAuditEvents, settings: defaultLauncherSettings, @@ -220,6 +224,9 @@ export function normalizeLauncherData(data: Partial | null | undef accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests, revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [], taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests, + engineWorkflowAccessRequests: Array.isArray(payload.engineWorkflowAccessRequests) + ? payload.engineWorkflowAccessRequests + : mockEngineWorkflowAccessRequests, syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses, auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents, taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [], diff --git a/src/shared/api/mockData.ts b/src/shared/api/mockData.ts index bb30bae..3002d13 100644 --- a/src/shared/api/mockData.ts +++ b/src/shared/api/mockData.ts @@ -1,5 +1,6 @@ import type { AuditEvent } from "../../entities/audit/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 { Client } from "../../entities/client/types"; import type { Invite } from "../../entities/invite/types"; @@ -218,6 +219,7 @@ export const mockInvites: Invite[] = []; export const mockAccessRequests: AccessRequest[] = []; export const mockTaskerInviteRequests: TaskerInviteRequest[] = []; +export const mockEngineWorkflowAccessRequests: EngineWorkflowAccessRequest[] = []; export const mockSyncStatuses: SyncStatus[] = [ sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"), diff --git a/src/styles/globals.css b/src/styles/globals.css index e72d9d6..50f06c3 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -481,10 +481,18 @@ code { border-radius: 999px; background: rgba(64, 64, 64, 0.48); padding: var(--nodedc-shell-pill-padding); - cursor: pointer; + cursor: default; 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 { min-height: var(--nodedc-shell-control-height); padding-inline: 1.2rem; @@ -583,6 +591,7 @@ code { } .nodedc-expanded-notification-button { + position: relative; height: var(--nodedc-shell-control-height); width: var(--nodedc-shell-control-height); background: transparent; @@ -600,6 +609,22 @@ code { 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 { display: flex; width: 3rem; @@ -4621,6 +4646,154 @@ code { 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 { position: relative; display: grid; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 9df36fa..af3403b 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -43,6 +43,7 @@ import { import type { AccessRequest, AccessRequestStatus } from "../../entities/access-request/types"; import type { ServiceAppRole, ServiceModuleId } from "../../entities/access/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 { 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"; @@ -192,6 +193,8 @@ export function AdminOverlay({ onRejectAccessRequest, onApproveTaskerInviteRequest, onRejectTaskerInviteRequest, + onApproveEngineWorkflowAccessRequest, + onRejectEngineWorkflowAccessRequest, onRetrySync, onCreateClient, onUpdateClient, @@ -241,6 +244,14 @@ export function AdminOverlay({ onRejectAccessRequest: (accessRequestId: string, patch: Partial>) => void; onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; + onApproveEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; + onRejectEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; onRetrySync: (syncId: string) => void; onCreateClient: () => void; onUpdateClient: (clientId: string, patch: Partial) => void; @@ -563,6 +574,8 @@ export function AdminOverlay({ onRejectAccessRequest={onRejectAccessRequest} onApproveTaskerInviteRequest={onApproveTaskerInviteRequest} onRejectTaskerInviteRequest={onRejectTaskerInviteRequest} + onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest} + onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest} /> ) : null} {activeSection === "sync" ? : null} @@ -1358,6 +1371,13 @@ const taskerInviteRequestStatusOptions: Array> = [ + { value: "new", label: "Ожидает", tone: "yellow" }, + { value: "approved", label: "Подтверждено", tone: "green" }, + { value: "rejected", label: "Отклонено", tone: "red" }, + { value: "cancelled", label: "Отозвано", tone: "red" }, +]; + const syncStatusOptions: Array> = [ { value: "synced", label: "Синхронизировано", tone: "green" }, { value: "pending", label: "В очереди", tone: "yellow" }, @@ -3779,6 +3799,8 @@ function InvitesSection({ onRejectAccessRequest, onApproveTaskerInviteRequest, onRejectTaskerInviteRequest, + onApproveEngineWorkflowAccessRequest, + onRejectEngineWorkflowAccessRequest, }: { data: LauncherData; clientId: string; @@ -3798,6 +3820,14 @@ function InvitesSection({ onRejectAccessRequest: (accessRequestId: string, patch: Partial>) => void; onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; + onApproveEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; + onRejectEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; }) { const [email, setEmail] = useState(""); const [role, setRole] = useState("member"); @@ -3806,10 +3836,13 @@ function InvitesSection({ const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming"); const invites = data.invites.filter((invite) => invite.clientId === clientId); 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 = 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 actor = getUser(data, actorUserId); const clientOptions: Array> = [ @@ -3867,7 +3900,7 @@ function InvitesSection({

{publicInviteTab === "incoming" - ? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.` + ? `Входящие — общие заявки NODE.DC, Operational Core и Engine. Новых к обработке: ${pendingIncomingRequests}.` : "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}

@@ -3884,6 +3917,8 @@ function InvitesSection({ onRejectAccessRequest={onRejectAccessRequest} onApproveTaskerInviteRequest={onApproveTaskerInviteRequest} onRejectTaskerInviteRequest={onRejectTaskerInviteRequest} + onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest} + onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest} /> ) : ( <> @@ -4073,6 +4108,8 @@ function AccessRequestsPanel({ onRejectAccessRequest, onApproveTaskerInviteRequest, onRejectTaskerInviteRequest, + onApproveEngineWorkflowAccessRequest, + onRejectEngineWorkflowAccessRequest, }: { data: LauncherData; clientOptions: Array>; @@ -4089,19 +4126,31 @@ function AccessRequestsPanel({ onRejectAccessRequest: (accessRequestId: string, patch: Partial>) => void; onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; + onApproveEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; + onRejectEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; }) { const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)); const taskerInviteRequests = data.taskerInviteRequests .filter((request) => request.status !== "cancelled") .slice() .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 ( <>
-

Входящие запросы доступа

+

NODE.DC: входящие запросы доступа

Перед approve выберите целевой контур. Аккаунт уже создан в Authentik и активируется после подтверждения.

@@ -4243,6 +4292,11 @@ function AccessRequestsPanel({ onApproveTaskerInviteRequest={onApproveTaskerInviteRequest} onRejectTaskerInviteRequest={onRejectTaskerInviteRequest} /> + ); } @@ -4277,7 +4331,7 @@ function TaskerInviteRequestsPanel({
-

Запросы workspace-инвайтов

+

Operational Core: входящие запросы доступа

Self-service админы Operational Core создают эти заявки из настроек workspace. После approve ссылка становится доступна пригласившему пользователю. @@ -4385,6 +4439,109 @@ function TaskerInviteRequestsPanel({ ); } +function EngineWorkflowAccessRequestsPanel({ + requests, + onApproveEngineWorkflowAccessRequest, + onRejectEngineWorkflowAccessRequest, +}: { + requests: EngineWorkflowAccessRequest[]; + onApproveEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; + onRejectEngineWorkflowAccessRequest: ( + engineWorkflowAccessRequestId: string, + patch: Partial> + ) => void; +}) { + return ( + +

+
+

NODE.DC Engine: запросы доступа к workflow

+

+ Эти заявки создаются из Engine, когда workflow хотят пошарить зарегистрированному пользователю без доступа к Engine. +

+
+
+ + {requests.length === 0 ? ( +
+ Запросов Engine пока нет + Если пользователь Engine попросит выдать доступ к workflow, заявка появится здесь. +
+ ) : ( +
+ + + + + + + + + + + + {requests.map((request) => ( + + + + + + + + + ))} + +
WorkflowПользовательИнициаторРольСтатус +
+
+ {request.workflowName} + {request.workflowId} +
+
+
+ {request.targetName || request.targetEmail} + {request.targetEmail} +
+
+
+ {request.requesterName} + {request.requesterEmail} +
+
{engineWorkflowRoleLabel(request.role)} + + + {request.status === "new" ? ( +
+ + +
+ ) : ( + + )} +
+
+ )} + + ); +} + function formatAccessRequestName(accessRequest: AccessRequest) { return [accessRequest.lastName, accessRequest.firstName, accessRequest.middleName].filter(Boolean).join(" "); } @@ -4809,6 +4966,16 @@ function taskerInviteRoleLabel(role: TaskerInviteRequest["role"]): string { return labels[role] ?? role; } +function engineWorkflowRoleLabel(role: EngineWorkflowAccessRequest["role"]): string { + const labels: Record = { + viewer: "Просмотр", + editor: "Редактор", + admin: "Соавтор", + }; + + return labels[role] ?? role; +} + function sectionTitle(section: AdminSection): string { const labels: Record = { overview: "Обзор", diff --git a/src/widgets/top-bar/TopBar.tsx b/src/widgets/top-bar/TopBar.tsx index 54876f7..a14902f 100644 --- a/src/widgets/top-bar/TopBar.tsx +++ b/src/widgets/top-bar/TopBar.tsx @@ -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 { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants"; 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"; 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({ me, @@ -23,6 +37,7 @@ export function TopBar({ onOpenProfileSettings, onLogout, brandLinkUrl = "/", + notifications = [], }: { me: MeResponse; clients: Client[]; @@ -39,7 +54,10 @@ export function TopBar({ onOpenProfileSettings: () => void; onLogout?: () => void; brandLinkUrl?: string; + notifications?: LauncherNotificationItem[]; }) { + const [notificationsOpen, setNotificationsOpen] = useState(false); + const [notificationFilter, setNotificationFilter] = useState("all"); const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId)); const clientsWithPublicPool = [ ...clients, @@ -54,6 +72,66 @@ export function TopBar({ })); const canOpenPlatform = me.launcherRole === "root_admin"; 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( +
setNotificationsOpen(false)}> +
event.stopPropagation()}> +
+
+

Уведомления

+ NODE DC +
+ +
+ +
+ setNotificationFilter("all")}> + Все + + setNotificationFilter("nodedc")}> + NODE.DC + + setNotificationFilter("operational-core")}> + Operational Core + + setNotificationFilter("engine")}> + Engine + +
+ +
+ {visibleNotifications.length === 0 ? ( +
+ Новых входящих нет + Заявки NODE.DC, Operational Core и Engine появятся здесь. +
+ ) : ( + visibleNotifications.map((notification) => ( +
+
{notificationKindLabel(notification.kind)}
+
{notification.title}
+
{notification.description}
+
+ {notificationStatusLabel(notification.status)} + {notification.meta ? {notification.meta} : null} +
+
+ )) + )} +
+
+
, + document.body + ) + : null; return (
@@ -122,48 +200,95 @@ export function TopBar({
- ( -
{ - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - toggle(); - } - }} - > - - Профиль - -
+ {notificationsModal}
); } + +function NotificationFilterButton({ + active, + children, + onClick, +}: { + active: boolean; + children: string; + onClick: () => void; +}) { + return ( + + ); +} + +function notificationKindLabel(kind: LauncherNotificationKind): string { + const labels: Record = { + nodedc: "NODE.DC", + "operational-core": "Operational Core", + engine: "NODE.DC Engine", + }; + + return labels[kind]; +} + +function notificationStatusLabel(status: LauncherNotificationStatus): string { + const labels: Record = { + new: "Входящее", + approved: "Подтверждено", + rejected: "Отклонено", + cancelled: "Отменено", + }; + + return labels[status]; +}