feat: integrate hub notifications with core

This commit is contained in:
DCCONSTRUCTIONS
2026-05-26 18:01:39 +03:00
parent de446c1879
commit 8f56fd2ab6
10 changed files with 1484 additions and 126 deletions
+111 -77
View File
@@ -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;
+1 -1
View File
@@ -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`,
+53
View File
@@ -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
View File
@@ -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;
+32 -16
View File
@@ -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
View File
@@ -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];