feat: add engine workflow access requests

This commit is contained in:
DCCONSTRUCTIONS
2026-05-23 14:10:22 +03:00
parent e2c70649c7
commit 179508f4c9
10 changed files with 1065 additions and 46 deletions
+102 -1
View File
@@ -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<typeof approveAdminEngineWorkflowAccessRequest>[1]
) {
applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
}
function handleRejectEngineWorkflowAccessRequest(
engineWorkflowAccessRequestId: string,
patch: Parameters<typeof rejectAdminEngineWorkflowAccessRequest>[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}
/>
<main className="launcher-main">
@@ -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<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) {
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;
}
+38
View File
@@ -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<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: {
userId: string;
serviceId: string;
+7
View File
@@ -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<LauncherData> | 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 : [],
+2
View File
@@ -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"),
+174 -1
View File
@@ -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;
+172 -5
View File
@@ -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<Pick<AccessRequest, "comment">>) => void;
onApproveTaskerInviteRequest: (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;
onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
@@ -563,6 +574,8 @@ export function AdminOverlay({
onRejectAccessRequest={onRejectAccessRequest}
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
/>
) : 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" },
];
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>> = [
{ 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<Pick<AccessRequest, "comment">>) => void;
onApproveTaskerInviteRequest: (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 [role, setRole] = useState<ClientMembershipRole>("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<NodeDcSelectOption<string>> = [
@@ -3867,7 +3900,7 @@ function InvitesSection({
</div>
<p className="admin-helper-note">
{publicInviteTab === "incoming"
? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.`
? `Входящие — общие заявки NODE.DC, Operational Core и Engine. Новых к обработке: ${pendingIncomingRequests}.`
: "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}
</p>
</GlassSurface>
@@ -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<NodeDcSelectOption<string>>;
@@ -4089,19 +4126,31 @@ function AccessRequestsPanel({
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
onApproveTaskerInviteRequest: (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 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 (
<>
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>Входящие запросы доступа</h3>
<h3>NODE.DC: входящие запросы доступа</h3>
<p className="admin-helper-note">
Перед approve выберите целевой контур. Аккаунт уже создан в Authentik и активируется после подтверждения.
</p>
@@ -4243,6 +4292,11 @@ function AccessRequestsPanel({
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
/>
<EngineWorkflowAccessRequestsPanel
requests={engineWorkflowAccessRequests}
onApproveEngineWorkflowAccessRequest={onApproveEngineWorkflowAccessRequest}
onRejectEngineWorkflowAccessRequest={onRejectEngineWorkflowAccessRequest}
/>
</>
);
}
@@ -4277,7 +4331,7 @@ function TaskerInviteRequestsPanel({
<GlassSurface className="table-shell">
<div className="table-toolbar">
<div>
<h3>Запросы workspace-инвайтов</h3>
<h3>Operational Core: входящие запросы доступа</h3>
<p className="admin-helper-note">
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) {
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<EngineWorkflowAccessRequest["role"], string> = {
viewer: "Просмотр",
editor: "Редактор",
admin: "Соавтор",
};
return labels[role] ?? role;
}
function sectionTitle(section: AdminSection): string {
const labels: Record<AdminSection, string> = {
overview: "Обзор",
+164 -39
View File
@@ -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<LauncherNotificationKind | "all">("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(
<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 (
<header className="nodedc-expanded-toolbar-shell">
@@ -122,48 +200,95 @@ export function TopBar({
</div>
<div className="nodedc-expanded-toolbar-right">
<NodeDcProfileMenu
user={me.user}
onSettings={onOpenProfileSettings}
onLogout={onLogout}
trigger={({ open, toggle, setTriggerRef }) => (
<div
ref={setTriggerRef}
className="nodedc-expanded-user-group"
title={`${me.user.name} · ${me.user.email}`}
role="button"
tabIndex={0}
aria-label="Профиль пользователя"
aria-expanded={open}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
}}
>
<span className="nodedc-expanded-nav-button" data-active="false">
<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} />
<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
user={me.user}
onSettings={onOpenProfileSettings}
onLogout={onLogout}
trigger={({ open, toggle, setTriggerRef }) => (
<div
ref={setTriggerRef}
className="nodedc-expanded-profile-trigger"
title={`${me.user.name} · ${me.user.email}`}
role="button"
tabIndex={0}
aria-label="Профиль пользователя"
aria-expanded={open}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
}}
>
<span className="nodedc-expanded-nav-button" data-active="false">
<span>Профиль</span>
</span>
</span>
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
{me.user.avatarUrl ? (
<img className="nodedc-expanded-user-avatar" src={me.user.avatarUrl} alt="" style={{ objectFit: "cover" }} />
) : (
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
)}
</span>
</div>
)}
/>
<span className="nodedc-expanded-user-avatar-button" aria-hidden="true">
{me.user.avatarUrl ? (
<img className="nodedc-expanded-user-avatar" src={me.user.avatarUrl} alt="" style={{ objectFit: "cover" }} />
) : (
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
)}
</span>
</div>
)}
/>
</div>
{notificationsModal}
</div>
</div>
</div>
</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];
}