feat: integrate hub notifications with core
This commit is contained in:
+111
-77
@@ -62,6 +62,7 @@ import {
|
||||
} from "../shared/api/authApi";
|
||||
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
||||
import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi";
|
||||
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationDelivery } from "../shared/api/notificationsApi";
|
||||
import type { CreateAccessRequestCommand } from "../entities/access-request/types";
|
||||
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||
@@ -114,6 +115,7 @@ export function LauncherApp() {
|
||||
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
|
||||
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
|
||||
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
|
||||
const [hubNotifications, setHubNotifications] = useState<LauncherNotificationItem[]>([]);
|
||||
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
|
||||
const runtimeDataRef = useRef(data);
|
||||
const runtimeProfileIdRef = useRef(activeProfileId);
|
||||
@@ -155,7 +157,6 @@ 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(
|
||||
@@ -260,6 +261,30 @@ export function LauncherApp() {
|
||||
return request;
|
||||
}, []);
|
||||
|
||||
const loadHubNotifications = useCallback(async () => {
|
||||
if (!authSession?.authenticated) {
|
||||
setHubNotifications([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deliveries = await fetchNotifications("hub");
|
||||
setHubNotifications(deliveries.map(mapNotificationDeliveryToLauncherItem));
|
||||
} catch (error: unknown) {
|
||||
setHubNotifications([]);
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось загрузить уведомления Hub");
|
||||
}
|
||||
}, [authSession?.authenticated]);
|
||||
|
||||
const markHubNotificationsRead = useCallback(async () => {
|
||||
try {
|
||||
await markAllNotificationsRead("hub");
|
||||
await loadHubNotifications();
|
||||
} catch (error: unknown) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось отметить уведомления Hub прочитанными");
|
||||
}
|
||||
}, [loadHubNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -402,6 +427,32 @@ export function LauncherApp() {
|
||||
};
|
||||
}, [authSession?.authenticated, refreshRuntimeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) {
|
||||
setHubNotifications([]);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadHubNotifications();
|
||||
|
||||
const eventSource = new EventSource("/api/notifications/stream?surface=hub");
|
||||
const refreshHubNotifications = () => {
|
||||
void loadHubNotifications();
|
||||
};
|
||||
|
||||
eventSource.addEventListener("notification.ready", refreshHubNotifications);
|
||||
eventSource.addEventListener("notification.delivery.created", refreshHubNotifications);
|
||||
eventSource.addEventListener("notification.delivery.read", refreshHubNotifications);
|
||||
eventSource.addEventListener("notification.deliveries.read-all", refreshHubNotifications);
|
||||
eventSource.onerror = () => {
|
||||
console.warn("Hub notification stream disconnected; browser will retry automatically");
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [authSession?.authenticated, loadHubNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
@@ -698,14 +749,14 @@ export function LauncherApp() {
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Parameters<typeof approveAdminEngineWorkflowAccessRequest>[1]
|
||||
) {
|
||||
applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||
return applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleRejectEngineWorkflowAccessRequest(
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Parameters<typeof rejectAdminEngineWorkflowAccessRequest>[1]
|
||||
) {
|
||||
applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||
return applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleRetrySync(syncId: string) {
|
||||
@@ -883,7 +934,20 @@ export function LauncherApp() {
|
||||
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
||||
onLogout={handleLogout}
|
||||
brandLinkUrl={data.settings.brand.logoLinkUrl}
|
||||
notifications={notifications}
|
||||
notifications={hubNotifications}
|
||||
onMarkNotificationsRead={markHubNotificationsRead}
|
||||
onResolveEngineRoleRequest={async (requestId, action, serviceRole, deliveryId) => {
|
||||
const outcome = action === "approve"
|
||||
? await handleApproveEngineWorkflowAccessRequest(requestId, { serviceRole: serviceRole ?? "member" })
|
||||
: await handleRejectEngineWorkflowAccessRequest(requestId, {});
|
||||
|
||||
if (!outcome.ok) return;
|
||||
|
||||
if (deliveryId) {
|
||||
await markNotificationRead(deliveryId);
|
||||
}
|
||||
await loadHubNotifications();
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="launcher-main">
|
||||
@@ -1518,82 +1582,52 @@ 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[] = [];
|
||||
function mapNotificationDeliveryToLauncherItem(delivery: NotificationDelivery): LauncherNotificationItem {
|
||||
const requestId = notificationString(delivery.entityId)
|
||||
?? notificationString(delivery.meta?.requestId)
|
||||
?? notificationString(delivery.eventPayload?.requestId);
|
||||
const requestedServiceRole = notificationEngineServiceRole(
|
||||
delivery.meta?.requestedServiceRole ?? delivery.eventPayload?.requestedServiceRole ?? delivery.eventPayload?.serviceRole
|
||||
);
|
||||
|
||||
for (const request of data.accessRequests) {
|
||||
const isOwnRequest = request.email.toLowerCase() === currentEmail;
|
||||
if (!canModerate && !isOwnRequest) 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;
|
||||
|
||||
items.push({
|
||||
id: `tasker:${request.id}`,
|
||||
kind: "operational-core",
|
||||
title: request.status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена",
|
||||
description: `${request.workspaceName} · ${request.inviteeEmail}`,
|
||||
meta: request.inviterName || formatNotificationDate(request.createdAt),
|
||||
status: request.status,
|
||||
createdAt: request.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const request of data.engineWorkflowAccessRequests) {
|
||||
const isRelated =
|
||||
request.requesterUserId === currentUserId ||
|
||||
request.targetUserId === currentUserId ||
|
||||
request.requesterEmail.toLowerCase() === currentEmail ||
|
||||
request.targetEmail.toLowerCase() === currentEmail;
|
||||
if (!canModerate && !isRelated) continue;
|
||||
|
||||
items.push({
|
||||
id: `engine:${request.id}`,
|
||||
kind: "engine",
|
||||
title: request.status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена",
|
||||
description: `${request.workflowName} · ${request.targetEmail}`,
|
||||
meta: request.requesterName || formatNotificationDate(request.createdAt),
|
||||
status: request.status,
|
||||
createdAt: request.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort((left, right) => {
|
||||
if (left.status === "new" && right.status !== "new") return -1;
|
||||
if (left.status !== "new" && right.status === "new") return 1;
|
||||
return Date.parse(right.createdAt) - Date.parse(left.createdAt);
|
||||
});
|
||||
return {
|
||||
id: delivery.id,
|
||||
kind: notificationKindFromDelivery(delivery),
|
||||
title: delivery.title,
|
||||
description: delivery.body,
|
||||
meta: notificationString(delivery.meta?.meta),
|
||||
displayStatus: notificationString(delivery.meta?.displayStatus),
|
||||
status: delivery.status,
|
||||
createdAt: delivery.createdAt,
|
||||
updatedAt: delivery.updatedAt,
|
||||
actionKind: delivery.actionType === "engine.role_upgrade.review" ? "engine-role-request" : undefined,
|
||||
requestId: delivery.actionType === "engine.role_upgrade.review" ? requestId : undefined,
|
||||
requestedServiceRole,
|
||||
};
|
||||
}
|
||||
|
||||
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 notificationKindFromDelivery(delivery: NotificationDelivery): LauncherNotificationItem["kind"] {
|
||||
const source = String(delivery.sourceService || "").toLowerCase();
|
||||
const eventType = String(delivery.eventType || "").toLowerCase();
|
||||
const entityType = String(delivery.entityType || "").toLowerCase();
|
||||
|
||||
if (source === "tasker" || source === "operational-core" || eventType.startsWith("tasker.") || entityType.startsWith("tasker")) {
|
||||
return "operational-core";
|
||||
}
|
||||
|
||||
if (source === "engine" || eventType.startsWith("engine.") || entityType.startsWith("engine_")) {
|
||||
return "engine";
|
||||
}
|
||||
|
||||
return "nodedc";
|
||||
}
|
||||
|
||||
function notificationString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function notificationEngineServiceRole(value: unknown): "viewer" | "member" | null {
|
||||
return value === "viewer" || value === "member" ? value : null;
|
||||
}
|
||||
|
||||
function isAccessRequestPath(pathname: string) {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
export type EngineWorkflowRole = "viewer" | "editor" | "admin";
|
||||
export type EngineServiceRole = "viewer" | "member";
|
||||
export type EngineWorkflowAccessRequestType = "workflow" | "service_role";
|
||||
export type EngineWorkflowAccessRequestStatus = "new" | "approved" | "rejected" | "cancelled";
|
||||
|
||||
export interface EngineWorkflowAccessRequest {
|
||||
id: string;
|
||||
requestType?: EngineWorkflowAccessRequestType;
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
targetUserId: string;
|
||||
targetEmail: string;
|
||||
targetName?: string | null;
|
||||
role: EngineWorkflowRole;
|
||||
requestedServiceRole?: EngineServiceRole | null;
|
||||
currentServiceRole?: EngineServiceRole | null;
|
||||
requesterUserId?: string | null;
|
||||
requesterEmail: string;
|
||||
requesterName: string;
|
||||
|
||||
@@ -407,7 +407,7 @@ export async function rejectAdminTaskerInviteRequest(
|
||||
|
||||
export async function approveAdminEngineWorkflowAccessRequest(
|
||||
engineWorkflowAccessRequestId: string,
|
||||
payload: Partial<Pick<EngineWorkflowAccessRequest, "comment">> = {}
|
||||
payload: Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & { serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"] } = {}
|
||||
): Promise<EngineWorkflowAccessRequestMutationResult> {
|
||||
return requestJson<EngineWorkflowAccessRequestMutationResult>(
|
||||
`/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export type NotificationDeliveryStatus = "unread" | "read" | "archived";
|
||||
export type NotificationSurface = "hub" | "engine" | "operational-core";
|
||||
|
||||
export interface NotificationDelivery {
|
||||
id: string;
|
||||
eventId: string;
|
||||
eventType?: string | null;
|
||||
sourceService?: string | null;
|
||||
recipientUserId?: string | null;
|
||||
recipientEmail?: string | null;
|
||||
surface: NotificationSurface;
|
||||
title: string;
|
||||
body: string;
|
||||
meta?: Record<string, unknown>;
|
||||
status: NotificationDeliveryStatus;
|
||||
unread?: boolean;
|
||||
actionable?: boolean;
|
||||
actionType?: string | null;
|
||||
actionUrl?: string | null;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
eventPayload?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt?: string | null;
|
||||
readAt?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchNotifications(surface: NotificationSurface) {
|
||||
const response = await fetch(`/api/notifications?surface=${encodeURIComponent(surface)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`notifications_failed_${response.status}`);
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload?.deliveries) ? payload.deliveries as NotificationDelivery[] : [];
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(surface: NotificationSurface) {
|
||||
const response = await fetch(`/api/notifications/read-all?surface=${encodeURIComponent(surface)}`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`notifications_read_all_failed_${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function markNotificationRead(deliveryId: string) {
|
||||
const response = await fetch(`/api/notifications/${encodeURIComponent(deliveryId)}/read`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`notification_read_failed_${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
+99
-3
@@ -4678,6 +4678,12 @@ code {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nodedc-notifications-head__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.nodedc-notifications-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
@@ -4705,6 +4711,32 @@ code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-notifications-only-new {
|
||||
min-height: 2.35rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
padding: 0 1rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.nodedc-notifications-only-new:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nodedc-notifications-only-new[data-active="true"] {
|
||||
background: #ffffff;
|
||||
color: #101014;
|
||||
}
|
||||
|
||||
.nodedc-notifications-only-new[data-active="true"]:hover {
|
||||
background: #ffffff;
|
||||
color: #101014;
|
||||
}
|
||||
|
||||
.nodedc-notifications-close:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
@@ -4759,14 +4791,21 @@ code {
|
||||
|
||||
.nodedc-notification-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: 1.05rem;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.044)),
|
||||
rgba(20, 20, 24, 0.86);
|
||||
padding: 0.95rem 1rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.nodedc-notification-card[data-status="new"] {
|
||||
background: rgba(195, 255, 102, 0.1);
|
||||
background:
|
||||
linear-gradient(135deg, rgb(var(--nodedc-accent-rgb) / 0.22), rgb(var(--nodedc-accent-rgb) / 0.1)),
|
||||
rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.nodedc-notification-card__kicker,
|
||||
@@ -4790,10 +4829,67 @@ code {
|
||||
|
||||
.nodedc-notification-card__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__content {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__action-button,
|
||||
.nodedc-notification-card__role-select {
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.085);
|
||||
color: var(--text-primary);
|
||||
padding: 0 0.8rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 780;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__action-button:hover:not(:disabled),
|
||||
.nodedc-notification-card__role-select:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.nodedc-notification-card__action-button:disabled,
|
||||
.nodedc-notification-card__role-select:disabled {
|
||||
opacity: 0.58;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__role-select-wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__role-select {
|
||||
min-width: 10.2rem;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__role-select .nodedc-ui-select-trigger__text {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nodedc-notification-card__role-menu {
|
||||
z-index: 15050;
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(28, 28, 32, 0.96);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 1.4rem 3rem rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.nodedc-ui-profile-card__cover {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
||||
@@ -92,6 +92,10 @@ type AdminSection =
|
||||
| "company";
|
||||
type AdminOverlayMode = "admin" | "platform";
|
||||
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
|
||||
type EngineWorkflowAccessRequestDecisionPatch =
|
||||
Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & {
|
||||
serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"];
|
||||
};
|
||||
|
||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||||
@@ -255,11 +259,11 @@ export function AdminOverlay({
|
||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onApproveEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
onRejectEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
onRetrySync: (syncId: string) => void;
|
||||
onCreateClient: () => void;
|
||||
@@ -3847,11 +3851,11 @@ function InvitesSection({
|
||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onApproveEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
onRejectEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -4153,11 +4157,11 @@ function AccessRequestsPanel({
|
||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onApproveEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
onRejectEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
}) {
|
||||
const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
@@ -4472,20 +4476,20 @@ function EngineWorkflowAccessRequestsPanel({
|
||||
requests: EngineWorkflowAccessRequest[];
|
||||
onApproveEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
onRejectEngineWorkflowAccessRequest: (
|
||||
engineWorkflowAccessRequestId: string,
|
||||
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">>
|
||||
patch: EngineWorkflowAccessRequestDecisionPatch
|
||||
) => void;
|
||||
}) {
|
||||
return (
|
||||
<GlassSurface className="table-shell">
|
||||
<div className="table-toolbar">
|
||||
<div>
|
||||
<h3>NODE.DC Engine: запросы доступа к workflow</h3>
|
||||
<h3>NODE.DC Engine: запросы доступа</h3>
|
||||
<p className="admin-helper-note">
|
||||
Эти заявки создаются из Engine, когда workflow хотят пошарить зарегистрированному пользователю без доступа к Engine.
|
||||
Здесь workflow-шаринг и запросы повышения глобальной роли Engine из режима только чтения.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4509,12 +4513,14 @@ function EngineWorkflowAccessRequestsPanel({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map((request) => (
|
||||
{requests.map((request) => {
|
||||
const isServiceRoleRequest = request.requestType === "service_role";
|
||||
return (
|
||||
<tr key={request.id}>
|
||||
<td>
|
||||
<div className="access-request-applicant">
|
||||
<strong>{request.workflowName}</strong>
|
||||
<small>{request.workflowId}</small>
|
||||
<strong>{isServiceRoleRequest ? "Повышение роли Engine" : request.workflowName}</strong>
|
||||
<small>{isServiceRoleRequest ? `Текущая роль: ${engineServiceRoleLabel(request.currentServiceRole)}` : request.workflowId}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -4529,7 +4535,7 @@ function EngineWorkflowAccessRequestsPanel({
|
||||
<small>{request.requesterEmail}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>{engineWorkflowRoleLabel(request.role)}</td>
|
||||
<td>{isServiceRoleRequest ? engineServiceRoleLabel(request.requestedServiceRole) : engineWorkflowRoleLabel(request.role)}</td>
|
||||
<td>
|
||||
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
|
||||
</td>
|
||||
@@ -4540,7 +4546,10 @@ function EngineWorkflowAccessRequestsPanel({
|
||||
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
|
||||
className="access-request-decision-button access-request-decision-button--accept"
|
||||
type="button"
|
||||
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, {})}
|
||||
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, isServiceRoleRequest
|
||||
? { serviceRole: request.requestedServiceRole ?? "member" }
|
||||
: {}
|
||||
)}
|
||||
>
|
||||
<Check size={16} strokeWidth={2.6} />
|
||||
</button>
|
||||
@@ -4558,7 +4567,8 @@ function EngineWorkflowAccessRequestsPanel({
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -5020,6 +5030,12 @@ function engineWorkflowRoleLabel(role: EngineWorkflowAccessRequest["role"]): str
|
||||
return labels[role] ?? role;
|
||||
}
|
||||
|
||||
function engineServiceRoleLabel(role?: EngineWorkflowAccessRequest["requestedServiceRole"] | null): string {
|
||||
if (role === "member") return "Участник";
|
||||
if (role === "viewer") return "Гость";
|
||||
return "не назначена";
|
||||
}
|
||||
|
||||
function sectionTitle(section: AdminSection): string {
|
||||
const labels: Record<AdminSection, string> = {
|
||||
overview: "Обзор",
|
||||
|
||||
+117
-22
@@ -5,11 +5,11 @@ 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";
|
||||
import { initials } from "../../shared/lib/format";
|
||||
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui";
|
||||
import { NodeDcProfileMenu, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
|
||||
|
||||
export type LauncherAdminMode = "admin" | "platform";
|
||||
export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine";
|
||||
export type LauncherNotificationStatus = "new" | "approved" | "rejected" | "cancelled";
|
||||
export type LauncherNotificationStatus = "unread" | "read" | "archived";
|
||||
|
||||
export interface LauncherNotificationItem {
|
||||
id: string;
|
||||
@@ -17,10 +17,23 @@ export interface LauncherNotificationItem {
|
||||
title: string;
|
||||
description: string;
|
||||
meta?: string;
|
||||
displayStatus?: string;
|
||||
status: LauncherNotificationStatus;
|
||||
createdAt: string;
|
||||
updatedAt?: string | null;
|
||||
actionKind?: "engine-role-request";
|
||||
requestId?: string;
|
||||
requestedServiceRole?: "viewer" | "member" | null;
|
||||
}
|
||||
|
||||
type EngineRoleActionValue = "choose" | "viewer" | "member";
|
||||
|
||||
const engineRoleActionOptions: Array<NodeDcSelectOption<EngineRoleActionValue>> = [
|
||||
{ value: "choose", label: "Изменить роль" },
|
||||
{ value: "viewer", label: "Гость" },
|
||||
{ value: "member", label: "Участник" },
|
||||
];
|
||||
|
||||
export function TopBar({
|
||||
me,
|
||||
clients,
|
||||
@@ -38,6 +51,8 @@ export function TopBar({
|
||||
onLogout,
|
||||
brandLinkUrl = "/",
|
||||
notifications = [],
|
||||
onMarkNotificationsRead,
|
||||
onResolveEngineRoleRequest,
|
||||
}: {
|
||||
me: MeResponse;
|
||||
clients: Client[];
|
||||
@@ -55,9 +70,13 @@ export function TopBar({
|
||||
onLogout?: () => void;
|
||||
brandLinkUrl?: string;
|
||||
notifications?: LauncherNotificationItem[];
|
||||
onMarkNotificationsRead?: () => void | Promise<unknown>;
|
||||
onResolveEngineRoleRequest?: (requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all");
|
||||
const [notificationsOnlyNew, setNotificationsOnlyNew] = useState(false);
|
||||
const [resolvingNotificationIds, setResolvingNotificationIds] = useState<Record<string, boolean>>({});
|
||||
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||
const clientsWithPublicPool = [
|
||||
...clients,
|
||||
@@ -72,24 +91,59 @@ 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 unreadCount = notifications.filter((notification) => notification.status === "unread").length;
|
||||
const visibleNotifications = useMemo(
|
||||
() => notifications.filter((notification) => notificationFilter === "all" || notification.kind === notificationFilter),
|
||||
[notificationFilter, notifications]
|
||||
() => notifications.filter((notification) => {
|
||||
if (notificationsOnlyNew && notification.status !== "unread") return false;
|
||||
return notificationFilter === "all" || notification.kind === notificationFilter;
|
||||
}),
|
||||
[notificationFilter, notifications, notificationsOnlyNew]
|
||||
);
|
||||
|
||||
function markNotificationsSeen() {
|
||||
if (unreadCount <= 0) return;
|
||||
void Promise.resolve(onMarkNotificationsRead?.());
|
||||
}
|
||||
|
||||
function closeNotifications() {
|
||||
markNotificationsSeen();
|
||||
setNotificationsOpen(false);
|
||||
}
|
||||
|
||||
function resolveEngineRoleNotification(requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) {
|
||||
if (!onResolveEngineRoleRequest || resolvingNotificationIds[requestId]) return;
|
||||
setResolvingNotificationIds((current) => ({ ...current, [requestId]: true }));
|
||||
Promise.resolve(onResolveEngineRoleRequest(requestId, action, serviceRole, deliveryId))
|
||||
.finally(() => {
|
||||
setResolvingNotificationIds((current) => {
|
||||
const { [requestId]: _done, ...rest } = current;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const notificationsModal = notificationsOpen && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="nodedc-notifications-overlay" onMouseDown={() => setNotificationsOpen(false)}>
|
||||
<div className="nodedc-notifications-overlay" onMouseDown={closeNotifications}>
|
||||
<section className="nodedc-notifications-modal" aria-modal="true" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<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 className="nodedc-notifications-head__actions">
|
||||
<button
|
||||
className="nodedc-notifications-only-new"
|
||||
type="button"
|
||||
data-active={notificationsOnlyNew ? "true" : "false"}
|
||||
onClick={() => setNotificationsOnlyNew((current) => !current)}
|
||||
>
|
||||
Только новые
|
||||
</button>
|
||||
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={closeNotifications}>
|
||||
<X size={18} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-notifications-tabs" role="tablist" aria-label="Фильтр уведомлений">
|
||||
@@ -114,17 +168,59 @@ export function TopBar({
|
||||
<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}
|
||||
visibleNotifications.map((notification) => {
|
||||
const unread = notification.status === "unread";
|
||||
return (
|
||||
<article className="nodedc-notification-card" data-status={unread ? "new" : notification.status} key={notification.id}>
|
||||
<div className="nodedc-notification-card__content">
|
||||
<div className="nodedc-notification-card__kicker">{notificationKindLabel(notification.kind)}</div>
|
||||
<div className="nodedc-notification-card__title">{notification.title}</div>
|
||||
<div className="nodedc-notification-card__description">{notification.description}</div>
|
||||
<div className="nodedc-notification-card__foot">
|
||||
<span>{notification.displayStatus || notificationStatusLabel(notification.status)}</span>
|
||||
{notification.meta ? <span>{notification.meta}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{notification.actionKind === "engine-role-request" &&
|
||||
notification.requestId &&
|
||||
notification.status === "unread" &&
|
||||
onResolveEngineRoleRequest ? (
|
||||
<div className="nodedc-notification-card__actions">
|
||||
{(() => {
|
||||
const requestBusy = Boolean(resolvingNotificationIds[notification.requestId]);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="nodedc-notification-card__action-button"
|
||||
type="button"
|
||||
disabled={requestBusy}
|
||||
onClick={() => resolveEngineRoleNotification(notification.requestId!, "reject", undefined, notification.id)}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
<NodeDcSelect<EngineRoleActionValue>
|
||||
className="nodedc-notification-card__role-select-wrap"
|
||||
triggerClassName="nodedc-notification-card__role-select"
|
||||
menuClassName="nodedc-notification-card__role-menu"
|
||||
value="choose"
|
||||
options={engineRoleActionOptions}
|
||||
label="Изменить роль Engine"
|
||||
minMenuWidth={166}
|
||||
disabled={requestBusy}
|
||||
onChange={(value) => {
|
||||
if (value === "viewer" || value === "member") {
|
||||
resolveEngineRoleNotification(notification.requestId!, "approve", value, notification.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -284,10 +380,9 @@ function notificationKindLabel(kind: LauncherNotificationKind): string {
|
||||
|
||||
function notificationStatusLabel(status: LauncherNotificationStatus): string {
|
||||
const labels: Record<LauncherNotificationStatus, string> = {
|
||||
new: "Входящее",
|
||||
approved: "Подтверждено",
|
||||
rejected: "Отклонено",
|
||||
cancelled: "Отменено",
|
||||
unread: "Входящее",
|
||||
read: "Прочитано",
|
||||
archived: "В архиве",
|
||||
};
|
||||
|
||||
return labels[status];
|
||||
|
||||
Reference in New Issue
Block a user