Standardize modal buttons and delete confirmations

This commit is contained in:
DCCONSTRUCTIONS 2026-05-02 12:57:54 +03:00
parent adad0bd344
commit 17e007f49d
21 changed files with 869 additions and 73 deletions

View File

@ -0,0 +1,40 @@
{
"id": "accent-contrast",
"name": "NodeDcAccentContrast",
"kind": "utility",
"status": "draft-stable",
"summary": "Shared contrast helper that derives readable text color from a component accent color.",
"sourceRefs": [
{
"project": "nodedc_launcher",
"file": "src/shared/lib/accentContrast.ts",
"exports": [
"getReadableNodedcTextRgb",
"createNodedcAccentStyleVars",
"getNodedcRelativeLuminance"
]
},
{
"project": "nodedc_taskmanager",
"file": "plane-src/packages/utils/src/theme/nodedc-accent.ts",
"exports": [
"getReadableNodedcTextRgb",
"applyNodedcAccent"
]
}
],
"visualContract": {
"darkTextRgb": [11, 17, 23],
"lightTextRgb": [245, 247, 251],
"luminanceThreshold": 0.52,
"cssVars": [
"--nodedc-component-accent-rgb",
"--nodedc-component-on-accent-rgb"
]
},
"rules": [
"Any filled accent button or active accent chip must use the computed on-accent text color.",
"Do not hardcode black or white text on an accent fill unless the accent contrast helper owns that decision.",
"Component-local accent variables are allowed when global --nodedc-accent-rgb is not the intended color for that component."
]
}

View File

@ -0,0 +1,61 @@
{
"id": "button",
"name": "Button",
"kind": "primitive",
"status": "draft-stable",
"summary": "Canonical text/action button used by launcher stages, admin editors, and modal footers.",
"sourceRefs": [
{
"project": "nodedc_launcher",
"file": "src/shared/ui/Button.tsx",
"exports": ["Button"]
},
{
"project": "nodedc_launcher",
"file": "src/styles/globals.css",
"classes": [
"button",
"button--primary",
"button--secondary",
"button--danger",
"button--ghost",
"button--accent",
"button[data-surface='modal']"
]
},
{
"project": "nodedc_taskmanager",
"file": "plane-src/apps/web/styles/globals.css",
"classes": [
"nodedc-modal-primary-button",
"nodedc-modal-secondary-button",
"nodedc-modal-danger-button"
]
}
],
"api": {
"props": {
"variant": ["primary", "secondary", "danger", "ghost", "accent"],
"surface": ["default", "modal"],
"accentRgb": "optional RGB tuple used by accent variant",
"icon": "optional leading icon"
}
},
"visualContract": {
"height": "2.65rem default, tokens.size.modalButtonHeight on modal surface",
"radius": "control",
"fontWeight": "800",
"outline": "never",
"modalSecondaryDanger": "transparent until hover",
"modalAccent": "filled component accent with computed on-accent text"
},
"rules": [
"Use Button instead of hand-written button classes for text actions.",
"Use IconButton or circle-action-button for icon-only actions.",
"Inside modals, pass surface='modal' on every footer button.",
"Modal Save and modal destructive confirm use variant='accent' with local accentRgb when the app accent differs.",
"Inline modal delete buttons use variant='danger' with surface='modal'; they are transparent until neutral hover.",
"Do not use red filled delete buttons outside NodeDcDeleteModal confirmation.",
"Filled accent variants must use accent-contrast variables for readable text."
]
}

View File

@ -0,0 +1,74 @@
{
"id": "delete-modal",
"name": "NodeDcDeleteModal",
"kind": "component",
"status": "draft-stable",
"summary": "Canonical dark glass confirmation modal for destructive object deletion.",
"sourceRefs": [
{
"project": "nodedc_launcher",
"file": "src/shared/nodedc-ui/DeleteModal.tsx",
"exports": ["NodeDcDeleteModal"]
},
{
"project": "nodedc_launcher",
"file": "src/styles/globals.css",
"classes": [
"nodedc-delete-modal-layer",
"nodedc-delete-modal",
"nodedc-delete-modal__icon",
"nodedc-delete-modal__foot"
]
},
{
"project": "nodedc_taskmanager",
"file": "plane-src/packages/ui/src/modals/alert-modal.tsx",
"exports": ["AlertModalCore"]
},
{
"project": "nodedc_taskmanager",
"file": "plane-src/apps/web/styles/globals.css",
"classes": [
"nodedc-glass-modal",
"nodedc-modal-alert-icon",
"nodedc-modal-danger-button",
"nodedc-modal-secondary-button"
]
}
],
"anatomy": [
"fixed blurred overlay layer",
"dark glass shell",
"round alert icon",
"title and description copy",
"footer with secondary cancel and destructive confirm"
],
"visualContract": {
"width": "min(34rem, viewport - 2.8rem)",
"radius": "modal",
"background": "transparent black detail glass, not monocolor fill",
"blur": "44px detail glass pseudo-layer blur",
"iconSize": "2.75rem circle",
"iconColor": "component accent, white in launcher",
"buttonHeight": "tokens.size.modalButtonHeight through Button surface='modal'",
"buttonRadius": "control radius",
"cancelButton": "Button variant='secondary' surface='modal'",
"destructiveButton": "Button variant='accent' surface='modal' with computed on-accent text"
},
"rules": [
"Use this component for every delete/destructive removal action.",
"Never delete immediately from a row, footer, dropdown, or editor without this modal.",
"Do not add visible outlines or hard stroke borders to the modal, icon, or buttons.",
"The shell uses nodedc-glass-detail-surface so the panel blurs the actual content under it.",
"The destructive button uses the shared Button accent variant and component-local accent contrast variables, not a hardcoded red fill.",
"Cancel is secondary and stays left of the destructive confirm action.",
"The description must name the object and describe dependent data that will also be removed."
],
"launcherBindings": [
"service showcase deletion",
"client/company deletion",
"client membership removal",
"client group deletion",
"invite deletion"
]
}

View File

@ -39,9 +39,11 @@
}, },
"rules": [ "rules": [
"Delete is left of Save inside the right footer action group.", "Delete is left of Save inside the right footer action group.",
"Footer buttons use the shared Button primitive with surface='modal'.",
"Inline delete buttons inside edit modals use Button variant='danger' surface='modal', stay transparent until hover, and do not use red danger fills.",
"Save uses Button variant='accent' surface='modal' with local white launcher accent and computed on-accent text.",
"Cancel stays on the left.", "Cancel stays on the left.",
"Close action is circular and transparent until hover.", "Close action is circular and transparent until hover.",
"Fields/selects/textareas share the same glass family." "Fields/selects/textareas share the same glass family."
] ]
} }

View File

@ -12,25 +12,24 @@
{ {
"project": "nodedc_launcher", "project": "nodedc_launcher",
"file": "src/styles/globals.css", "file": "src/styles/globals.css",
"classes": ["glass-surface", "admin-panel-nav", "admin-panel-content", "service-content-modal"] "classes": ["glass-surface", "glass-surface--detail", "nodedc-glass-detail-surface", "admin-panel-nav", "admin-panel-content", "service-content-modal"]
}, },
{ {
"project": "nodedc_taskmanager", "project": "nodedc_taskmanager",
"file": "plane-src/apps/web/styles/globals.css", "file": "plane-src/apps/web/styles/globals.css",
"classes": ["nodedc-dropdown-surface", "nodedc-modal-field", "nodedc-settings-card"] "classes": ["nodedc-work-item-property-button", "nodedc-settings-card", "nodedc-modal-field", "nodedc-dropdown-surface"]
} }
], ],
"tokens": { "tokens": {
"radius": "card | modal | control depending on scale", "radius": "card | modal | control depending on scale",
"blur": "panel | modal | dropdown", "blur": "control | panel | modal | dropdown | detail",
"background": "dark matte glass gradient over rgba black", "background": "dark matte glass gradient over rgba black",
"border": "none or transparent soft glass only" "border": "none or transparent soft glass only"
}, },
"rules": [ "rules": [
"Never use hard outline as the main visual boundary.", "Never use hard outline as the main visual boundary.",
"Use background opacity and blur to separate layers.", "Use background opacity, pseudo-layer glass, and backdrop blur to separate layers.",
"For major shells use radius.card or radius.modal.", "For major shells use radius.card or radius.modal.",
"For controls inside shells use radius.control." "For controls inside shells use radius.control."
] ]
} }

View File

@ -38,12 +38,26 @@
"status": "draft-stable", "status": "draft-stable",
"primarySource": "nodedc_launcher" "primarySource": "nodedc_launcher"
}, },
{
"id": "button",
"spec": "components/button.json",
"status": "draft-stable",
"primarySource": "nodedc_launcher",
"taskManagerReference": true
},
{ {
"id": "glass-panel", "id": "glass-panel",
"spec": "components/glass-panel.json", "spec": "components/glass-panel.json",
"status": "draft-stable", "status": "draft-stable",
"primarySource": "nodedc_launcher" "primarySource": "nodedc_launcher"
}, },
{
"id": "accent-contrast",
"spec": "components/accent-contrast.json",
"status": "draft-stable",
"primarySource": "nodedc_launcher",
"taskManagerReference": true
},
{ {
"id": "dropdown-surface", "id": "dropdown-surface",
"spec": "components/dropdown-surface.json", "spec": "components/dropdown-surface.json",
@ -102,6 +116,13 @@
"status": "draft-stable", "status": "draft-stable",
"primarySource": "nodedc_launcher" "primarySource": "nodedc_launcher"
}, },
{
"id": "delete-modal",
"spec": "components/delete-modal.json",
"status": "draft-stable",
"primarySource": "nodedc_launcher",
"taskManagerReference": true
},
{ {
"id": "media-source-field", "id": "media-source-field",
"spec": "components/media-source-field.json", "spec": "components/media-source-field.json",

View File

@ -29,7 +29,17 @@
{ {
"id": "matte-glass", "id": "matte-glass",
"severity": "error", "severity": "error",
"text": "Popup, dropdown, modal, sidebar, and settings surfaces use dark matte glass with blur, not plain transparency or light foreign backgrounds." "text": "Popup, dropdown, modal, sidebar, and settings surfaces use dark matte glass with blur, not plain transparency, monocolor fills, or light foreign backgrounds."
},
{
"id": "accent-contrast",
"severity": "error",
"text": "Filled accent controls must use computed on-accent text from the accent-contrast utility."
},
{
"id": "buttons-shared-module",
"severity": "error",
"text": "Text actions use the shared Button primitive with variant and surface props. Modal footer buttons must use surface='modal'."
}, },
{ {
"id": "status-pills", "id": "status-pills",
@ -41,6 +51,16 @@
"severity": "warning", "severity": "warning",
"text": "Admin data screens use AdminTable anatomy: table-shell, toolbar, editable cells, circular actions, and shared status/date controls." "text": "Admin data screens use AdminTable anatomy: table-shell, toolbar, editable cells, circular actions, and shared status/date controls."
}, },
{
"id": "delete-confirmation",
"severity": "error",
"text": "Every destructive delete/removal action must open NodeDcDeleteModal before mutating data."
},
{
"id": "inline-delete-buttons",
"severity": "error",
"text": "Inline delete buttons inside editors and tables stay transparent by default and receive only neutral glass hover; red filled delete buttons are forbidden outside the confirmation modal."
},
{ {
"id": "taskmanager-calendar", "id": "taskmanager-calendar",
"severity": "warning", "severity": "warning",

View File

@ -7,6 +7,9 @@
"tokensAndCss": [ "tokensAndCss": [
"src/styles/globals.css" "src/styles/globals.css"
], ],
"colorUtilities": [
"src/shared/lib/accentContrast.ts"
],
"topBar": [ "topBar": [
"src/widgets/top-bar/TopBar.tsx" "src/widgets/top-bar/TopBar.tsx"
], ],
@ -25,6 +28,7 @@
"src/shared/ui/PortalDropdown.tsx", "src/shared/ui/PortalDropdown.tsx",
"src/shared/nodedc-ui/Dropdown.tsx", "src/shared/nodedc-ui/Dropdown.tsx",
"src/shared/nodedc-ui/Select.tsx", "src/shared/nodedc-ui/Select.tsx",
"src/shared/nodedc-ui/DeleteModal.tsx",
"src/shared/nodedc-ui/Calendar.tsx", "src/shared/nodedc-ui/Calendar.tsx",
"src/shared/nodedc-ui/ProfileMenu.tsx", "src/shared/nodedc-ui/ProfileMenu.tsx",
"src/shared/nodedc-ui/index.ts" "src/shared/nodedc-ui/index.ts"
@ -61,11 +65,20 @@
"appHeader": [ "appHeader": [
"plane-src/apps/web/core/components/core/app-header.tsx", "plane-src/apps/web/core/components/core/app-header.tsx",
"plane-src/apps/web/ce/components/common/extended-app-header.tsx" "plane-src/apps/web/ce/components/common/extended-app-header.tsx"
],
"deleteModal": [
"plane-src/packages/ui/src/modals/alert-modal.tsx",
"plane-src/packages/ui/src/modals/modal-core.tsx"
] ]
} }
} }
}, },
"crossProjectMapping": [ "crossProjectMapping": [
{
"component": "button",
"launcher": ["Button", "button--accent", "Button surface='modal'", "ServiceContentModal footer", "EntityModalFoot"],
"taskManager": ["nodedc-modal-primary-button", "nodedc-modal-secondary-button", "nodedc-modal-danger-button"]
},
{ {
"component": "dropdown-surface", "component": "dropdown-surface",
"launcher": ["NodeDcDropdown", "nodedc-ui-dropdown-surface", "nodedc-dropdown-surface"], "launcher": ["NodeDcDropdown", "nodedc-ui-dropdown-surface", "nodedc-dropdown-surface"],
@ -90,6 +103,16 @@
"component": "admin-table", "component": "admin-table",
"launcher": ["ServicesSection", "ClientsSection", "UsersSection", "GroupsSection"], "launcher": ["ServicesSection", "ClientsSection", "UsersSection", "GroupsSection"],
"taskManager": ["settings tables and external contour settings classes"] "taskManager": ["settings tables and external contour settings classes"]
},
{
"component": "delete-modal",
"launcher": ["NodeDcDeleteModal", "EntityModalFoot deleteConfig", "ServiceContentModal delete confirmation"],
"taskManager": ["AlertModalCore", "ModalCore", "nodedc-glass-modal"]
},
{
"component": "accent-contrast",
"launcher": ["createNodedcAccentStyleVars", "getReadableNodedcTextRgb"],
"taskManager": ["packages/utils/src/theme/nodedc-accent.ts", "--nodedc-on-accent-rgb"]
} }
] ]
} }

View File

@ -3,6 +3,8 @@
--nodedc-card-passive-rgb: 42 43 46; --nodedc-card-passive-rgb: 42 43 46;
--nodedc-card-active-rgb: 195 255 102; --nodedc-card-active-rgb: 195 255 102;
--nodedc-on-accent-rgb: 11 17 23; --nodedc-on-accent-rgb: 11 17 23;
--nodedc-component-accent-rgb: 247 248 244;
--nodedc-component-on-accent-rgb: 11 17 23;
--nodedc-radius-modal: 1.75rem; --nodedc-radius-modal: 1.75rem;
--nodedc-radius-card: 1.35rem; --nodedc-radius-card: 1.35rem;
--nodedc-radius-control: 1.25rem; --nodedc-radius-control: 1.25rem;
@ -10,6 +12,7 @@
--nodedc-radius-calendar: 1.1rem; --nodedc-radius-calendar: 1.1rem;
--nodedc-radius-circle: 999px; --nodedc-radius-circle: 999px;
--nodedc-toolbar-pill-height: 2.5rem; --nodedc-toolbar-pill-height: 2.5rem;
--nodedc-modal-button-height: 2.75rem;
--nodedc-control-ring: 2.92rem; --nodedc-control-ring: 2.92rem;
--nodedc-control-inset: 5px; --nodedc-control-inset: 5px;
--nodedc-status-width: 8.65rem; --nodedc-status-width: 8.65rem;
@ -20,5 +23,5 @@
--nodedc-dropdown-blur: 44px; --nodedc-dropdown-blur: 44px;
--nodedc-panel-blur: 28px; --nodedc-panel-blur: 28px;
--nodedc-modal-blur: 34px; --nodedc-modal-blur: 34px;
--nodedc-detail-blur: 44px;
} }

View File

@ -4,6 +4,8 @@
"color": { "color": {
"accentRgb": [195, 255, 102], "accentRgb": [195, 255, 102],
"onAccentRgb": [11, 17, 23], "onAccentRgb": [11, 17, 23],
"componentAccentRgb": [247, 248, 244],
"componentOnAccentRgb": [11, 17, 23],
"cardPassiveRgb": [42, 43, 46], "cardPassiveRgb": [42, 43, 46],
"cardActiveRgb": [195, 255, 102], "cardActiveRgb": [195, 255, 102],
"surfaceBlack": "rgba(8, 8, 11, 0.9)", "surfaceBlack": "rgba(8, 8, 11, 0.9)",
@ -45,6 +47,7 @@
"dropdown": "44px", "dropdown": "44px",
"panel": "28px", "panel": "28px",
"modal": "34px", "modal": "34px",
"detail": "44px",
"control": "18px" "control": "18px"
}, },
"shadow": { "shadow": {
@ -76,4 +79,3 @@
} }
} }
} }

View File

@ -227,7 +227,7 @@
{ {
"id": "service_nodedc", "id": "service_nodedc",
"slug": "nodedc", "slug": "nodedc",
"title": "NodeDC", "title": "AGENT CORE",
"subtitle": "Агентная платформа", "subtitle": "Агентная платформа",
"description": "Сборка, запуск и мониторинг агентных workflow.", "description": "Сборка, запуск и мониторинг агентных workflow.",
"fullDescription": "NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.", "fullDescription": "NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
@ -240,7 +240,7 @@
"authentikApplicationSlug": "nodedc", "authentikApplicationSlug": "nodedc",
"authentikGroupName": "service-nodedc", "authentikGroupName": "service-nodedc",
"createdAt": "2026-04-01T10:00:00Z", "createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-05-01T17:59:10.713Z", "updatedAt": "2026-05-02T08:15:08.667Z",
"coverImageUrl": "/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg", "coverImageUrl": "/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg",
"coverMediaKind": "image", "coverMediaKind": "image",
"coverMediaSource": "file", "coverMediaSource": "file",
@ -253,7 +253,7 @@
{ {
"id": "service_task_manager", "id": "service_task_manager",
"slug": "task-manager", "slug": "task-manager",
"title": "Task Manager", "title": "OPERATIONAL CORE",
"subtitle": "Операционный слой", "subtitle": "Операционный слой",
"description": "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.", "description": "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
"fullDescription": "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.", "fullDescription": "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
@ -266,7 +266,7 @@
"authentikApplicationSlug": "task-manager", "authentikApplicationSlug": "task-manager",
"authentikGroupName": "service-task-manager", "authentikGroupName": "service-task-manager",
"createdAt": "2026-04-01T10:00:00Z", "createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-05-01T17:59:10.713Z", "updatedAt": "2026-05-02T08:15:01.508Z",
"coverImageUrl": "/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png", "coverImageUrl": "/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png",
"coverMediaKind": "image", "coverMediaKind": "image",
"coverMediaSource": "file", "coverMediaSource": "file",
@ -275,7 +275,7 @@
{ {
"id": "service_1c", "id": "service_1c",
"slug": "1c-assistant", "slug": "1c-assistant",
"title": "1C Assistant", "title": "1C AI ASSISTANT",
"subtitle": "Бухгалтерский ассистент", "subtitle": "Бухгалтерский ассистент",
"description": "Вопросы к 1С, точные выборки и доказательная навигация по данным.", "description": "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
"fullDescription": "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.", "fullDescription": "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
@ -288,7 +288,7 @@
"authentikApplicationSlug": "1c-assistant", "authentikApplicationSlug": "1c-assistant",
"authentikGroupName": "service-1c-assistant", "authentikGroupName": "service-1c-assistant",
"createdAt": "2026-04-01T10:00:00Z", "createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-05-01T17:59:10.713Z", "updatedAt": "2026-05-02T08:16:05.517Z",
"coverImageUrl": "/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png", "coverImageUrl": "/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png",
"coverMediaKind": "image", "coverMediaKind": "image",
"coverMediaSource": "file", "coverMediaSource": "file",
@ -297,7 +297,7 @@
{ {
"id": "service_tender", "id": "service_tender",
"slug": "tender-agent", "slug": "tender-agent",
"title": "Tender Agent", "title": "TENDER AI AGENT",
"subtitle": "Госзакупки и тендеры", "subtitle": "Госзакупки и тендеры",
"description": "Поиск, анализ и подготовка тендерных решений.", "description": "Поиск, анализ и подготовка тендерных решений.",
"fullDescription": "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.", "fullDescription": "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
@ -310,7 +310,7 @@
"authentikApplicationSlug": "tender-agent", "authentikApplicationSlug": "tender-agent",
"authentikGroupName": "service-tender-agent", "authentikGroupName": "service-tender-agent",
"createdAt": "2026-04-03T10:00:00Z", "createdAt": "2026-04-03T10:00:00Z",
"updatedAt": "2026-05-01T17:59:10.713Z", "updatedAt": "2026-05-02T08:15:32.328Z",
"coverImageUrl": "/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg", "coverImageUrl": "/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg",
"coverMediaKind": "image", "coverMediaKind": "image",
"coverMediaSource": "file", "coverMediaSource": "file",
@ -323,12 +323,12 @@
{ {
"id": "service_digital_twin", "id": "service_digital_twin",
"slug": "digital-twin", "slug": "digital-twin",
"title": "Digital Twin", "title": "DIGITAL TWIN MOSCOW",
"subtitle": "3D и пространственные данные", "subtitle": "3D и пространственные данные",
"description": "Просмотр цифровых двойников, карт и объектных сцен.", "description": "Просмотр цифровых двойников, карт и объектных сцен.",
"fullDescription": "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.", "fullDescription": "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
"url": "https://twin.handhdc.ru", "url": "https://twin.handhdc.ru",
"launchUrl": "https://twin.handhdc.ru/sso/launch", "launchUrl": "https://launch.dcserve.ru/",
"accentColor": "#76E4F7", "accentColor": "#76E4F7",
"fallbackGradient": "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)", "fallbackGradient": "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
"status": "active", "status": "active",
@ -336,7 +336,11 @@
"authentikApplicationSlug": "digital-twin", "authentikApplicationSlug": "digital-twin",
"authentikGroupName": "service-digital-twin", "authentikGroupName": "service-digital-twin",
"createdAt": "2026-04-05T10:00:00Z", "createdAt": "2026-04-05T10:00:00Z",
"updatedAt": "2026-05-01T17:59:10.713Z" "updatedAt": "2026-05-02T08:55:28.665Z",
"coverImageUrl": "/storage/uploads/1777711943125-691830c2-NODEDC_DT_MMAP.png",
"coverMediaKind": "image",
"coverMediaSource": "file",
"coverMediaFileName": "1777711943125-691830c2-NODEDC_DT_MMAP.png"
}, },
{ {
"id": "service_dm", "id": "service_dm",
@ -355,24 +359,6 @@
"authentikGroupName": "service-digital-modules", "authentikGroupName": "service-digital-modules",
"createdAt": "2026-04-10T10:00:00Z", "createdAt": "2026-04-10T10:00:00Z",
"updatedAt": "2026-05-01T17:59:10.713Z" "updatedAt": "2026-05-01T17:59:10.713Z"
},
{
"id": "service_internal",
"slug": "internal-tools",
"title": "Internal Tools",
"subtitle": "Внутренний контур",
"description": "Отключённый сервис для проверки диагностики root-admin.",
"fullDescription": "Не показывается обычным пользователям, виден root-admin в каталоге.",
"url": "https://internal.handhdc.ru",
"launchUrl": null,
"accentColor": "#F97373",
"fallbackGradient": "linear-gradient(135deg, rgba(249, 115, 115, 0.78), rgba(73, 32, 32, 0.92) 43%, #090B0F 86%)",
"status": "disabled",
"order": 70,
"authentikApplicationSlug": "internal-tools",
"authentikGroupName": "service-internal-tools",
"createdAt": "2026-04-12T10:00:00Z",
"updatedAt": "2026-05-01T17:59:10.713Z"
} }
], ],
"grants": [ "grants": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -204,6 +204,13 @@ export function LauncherApp() {
})); }));
} }
function handleDeleteInvite(inviteId: string) {
setData((current) => ({
...current,
invites: current.invites.filter((invite) => invite.id !== inviteId),
}));
}
function handleRetrySync(syncId: string) { function handleRetrySync(syncId: string) {
setData((current) => ({ setData((current) => ({
...current, ...current,
@ -275,6 +282,34 @@ export function LauncherApp() {
})); }));
} }
function handleDeleteClient(clientId: string) {
const nextClientId = data.clients.find((client) => client.id !== clientId)?.id ?? activeClientId;
setData((current) => {
if (current.clients.length <= 1) return current;
const deletedGroupIds = new Set(current.groups.filter((group) => group.clientId === clientId).map((group) => group.id));
return {
...current,
clients: current.clients.filter((client) => client.id !== clientId),
memberships: current.memberships.filter((membership) => membership.clientId !== clientId),
groups: current.groups.filter((group) => group.clientId !== clientId),
grants: current.grants.filter(
(grant) =>
!(grant.targetType === "client" && grant.targetId === clientId) &&
!(grant.targetType === "group" && deletedGroupIds.has(grant.targetId))
),
invites: current.invites.filter((invite) => invite.clientId !== clientId),
syncStatuses: current.syncStatuses.filter((sync) => sync.objectId !== clientId),
};
});
if (activeClientId === clientId) {
setActiveClientId(nextClientId);
}
}
function handleUpdateUser(userId: string, patch: Partial<LauncherUser>) { function handleUpdateUser(userId: string, patch: Partial<LauncherUser>) {
setData((current) => ({ setData((current) => ({
...current, ...current,
@ -305,6 +340,27 @@ export function LauncherApp() {
})); }));
} }
function handleDeleteMembership(membershipId: string) {
setData((current) => {
const membership = current.memberships.find((item) => item.id === membershipId);
if (!membership) return current;
return {
...current,
memberships: current.memberships.filter((item) => item.id !== membershipId),
groups: current.groups.map((group) =>
group.clientId === membership.clientId
? {
...group,
memberIds: group.memberIds.filter((userId) => userId !== membership.userId),
updatedAt: new Date().toISOString(),
}
: group
),
};
});
}
function handleCreateGroup(clientId: string) { function handleCreateGroup(clientId: string) {
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
@ -340,6 +396,14 @@ export function LauncherApp() {
})); }));
} }
function handleDeleteGroup(groupId: string) {
setData((current) => ({
...current,
groups: current.groups.filter((group) => group.id !== groupId),
grants: current.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId)),
}));
}
function handleReorderServices(orderedServiceIds: string[]) { function handleReorderServices(orderedServiceIds: string[]) {
setData((current) => { setData((current) => {
const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10])); const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10]));
@ -443,13 +507,17 @@ export function LauncherApp() {
onSetUserServiceAccess={handleSetUserServiceAccess} onSetUserServiceAccess={handleSetUserServiceAccess}
onCreateInvite={handleCreateInvite} onCreateInvite={handleCreateInvite}
onUpdateInvite={handleUpdateInvite} onUpdateInvite={handleUpdateInvite}
onDeleteInvite={handleDeleteInvite}
onRetrySync={handleRetrySync} onRetrySync={handleRetrySync}
onCreateClient={handleCreateClient} onCreateClient={handleCreateClient}
onUpdateClient={handleUpdateClient} onUpdateClient={handleUpdateClient}
onDeleteClient={handleDeleteClient}
onUpdateUser={handleUpdateUser} onUpdateUser={handleUpdateUser}
onUpdateMembership={handleUpdateMembership} onUpdateMembership={handleUpdateMembership}
onDeleteMembership={handleDeleteMembership}
onCreateGroup={handleCreateGroup} onCreateGroup={handleCreateGroup}
onUpdateGroup={handleUpdateGroup} onUpdateGroup={handleUpdateGroup}
onDeleteGroup={handleDeleteGroup}
onUpdateService={handleUpdateService} onUpdateService={handleUpdateService}
onReorderServices={handleReorderServices} onReorderServices={handleReorderServices}
onCreateService={handleCreateService} onCreateService={handleCreateService}

View File

@ -0,0 +1,38 @@
import type { CSSProperties } from "react";
export type RgbTuple = readonly [number, number, number];
const DARK_TEXT_RGB: RgbTuple = [11, 17, 23];
const LIGHT_TEXT_RGB: RgbTuple = [245, 247, 251];
function clampRgbChannel(value: number): number {
return Math.min(Math.max(Math.round(value), 0), 255);
}
export function formatRgbTuple(rgb: readonly number[]): string {
return rgb.map(clampRgbChannel).join(" ");
}
export function getNodedcRelativeLuminance(rgb: readonly number[]): number {
const [r, g, b] = rgb.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function getReadableNodedcTextRgb(rgb: readonly number[]): RgbTuple {
return getNodedcRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB;
}
export function createNodedcAccentStyleVars(
accentRgb: RgbTuple,
accentVarName = "--nodedc-component-accent-rgb",
onAccentVarName = "--nodedc-component-on-accent-rgb"
): CSSProperties {
return {
[accentVarName]: formatRgbTuple(accentRgb),
[onAccentVarName]: formatRgbTuple(getReadableNodedcTextRgb(accentRgb)),
} as CSSProperties;
}

View File

@ -0,0 +1,100 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle } from "lucide-react";
import { createNodedcAccentStyleVars, type RgbTuple } from "../lib/accentContrast";
import { Button } from "../ui/Button";
const DELETE_MODAL_ACCENT_RGB: RgbTuple = [247, 248, 244];
export interface NodeDcDeleteModalProps {
isOpen: boolean;
title: string;
description: ReactNode;
cancelLabel?: string;
confirmLabel?: string;
submittingLabel?: string;
accentRgb?: RgbTuple;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}
export function NodeDcDeleteModal({
isOpen,
title,
description,
cancelLabel = "Отмена",
confirmLabel = "Удалить",
submittingLabel = "Удаляем",
accentRgb = DELETE_MODAL_ACCENT_RGB,
onClose,
onConfirm,
}: NodeDcDeleteModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!isOpen) {
setIsSubmitting(false);
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || typeof document === "undefined") return null;
async function handleConfirm() {
if (isSubmitting) return;
setIsSubmitting(true);
try {
await onConfirm();
} finally {
if (mountedRef.current) setIsSubmitting(false);
}
}
return createPortal(
<div className="nodedc-delete-modal-layer" role="presentation" onPointerDown={onClose}>
<article
className="nodedc-delete-modal nodedc-glass-detail-surface"
role="dialog"
aria-modal="true"
aria-labelledby="nodedc-delete-modal-title"
style={createNodedcAccentStyleVars(accentRgb, "--nodedc-delete-accent-rgb", "--nodedc-delete-on-accent-rgb")}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="nodedc-delete-modal__body">
<span className="nodedc-delete-modal__icon" aria-hidden="true">
<AlertTriangle size={21} strokeWidth={1.9} />
</span>
<div className="nodedc-delete-modal__copy">
<h3 id="nodedc-delete-modal-title">{title}</h3>
<div className="nodedc-delete-modal__text">{description}</div>
</div>
</div>
<div className="nodedc-delete-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose} disabled={isSubmitting}>
{cancelLabel}
</Button>
<Button variant="accent" surface="modal" accentRgb={accentRgb} type="button" onClick={handleConfirm} disabled={isSubmitting}>
{isSubmitting ? submittingLabel : confirmLabel}
</Button>
</div>
</article>
</div>,
document.body
);
}

View File

@ -1,4 +1,5 @@
export { NodeDcDropdown } from "./Dropdown"; export { NodeDcDropdown } from "./Dropdown";
export { NodeDcDeleteModal, type NodeDcDeleteModalProps } from "./DeleteModal";
export { export {
NodeDcDateField, NodeDcDateField,
NodeDcCalendar, NodeDcCalendar,

View File

@ -1,16 +1,38 @@
import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { createNodedcAccentStyleVars, type RgbTuple } from "../lib/accentContrast";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost" | "accent";
export type ButtonSurface = "default" | "modal";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
surface?: ButtonSurface;
accentRgb?: RgbTuple;
icon?: ReactNode; icon?: ReactNode;
} }
export function Button({ variant = "secondary", icon, className, children, ...props }: ButtonProps) { export function Button({
variant = "secondary",
surface = "default",
accentRgb,
icon,
className,
children,
style,
...props
}: ButtonProps) {
const accentStyle = accentRgb
? createNodedcAccentStyleVars(accentRgb, "--nodedc-button-accent-rgb", "--nodedc-button-on-accent-rgb")
: undefined;
return ( return (
<button className={cn("button", `button--${variant}`, className)} {...props}> <button
className={cn("button", `button--${variant}`, className)}
data-surface={surface}
style={accentStyle ? { ...accentStyle, ...style } : style}
{...props}
>
{icon} {icon}
{children ? <span>{children}</span> : null} {children ? <span>{children}</span> : null}
</button> </button>

View File

@ -3,7 +3,7 @@ import { cn } from "../lib/cn";
interface GlassProps extends HTMLAttributes<HTMLDivElement> { interface GlassProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode; children: ReactNode;
tone?: "default" | "strong" | "soft"; tone?: "default" | "strong" | "soft" | "detail";
} }
export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) { export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) {

View File

@ -4,10 +4,13 @@
--nodedc-card-passive-rgb: 42 43 46; --nodedc-card-passive-rgb: 42 43 46;
--nodedc-card-active-rgb: 195 255 102; --nodedc-card-active-rgb: 195 255 102;
--nodedc-on-accent-rgb: 11 17 23; --nodedc-on-accent-rgb: 11 17 23;
--nodedc-component-accent-rgb: 247 248 244;
--nodedc-component-on-accent-rgb: 11 17 23;
--launcher-radius-modal: 1.75rem; --launcher-radius-modal: 1.75rem;
--launcher-radius-card: 1.35rem; --launcher-radius-card: 1.35rem;
--launcher-radius-control: 1.25rem; --launcher-radius-control: 1.25rem;
--launcher-radius-circle: 999px; --launcher-radius-circle: 999px;
--launcher-modal-button-height: 2.75rem;
--surface-base: rgba(8, 8, 11, 0.78); --surface-base: rgba(8, 8, 11, 0.78);
--surface-strong: rgba(8, 8, 11, 0.9); --surface-strong: rgba(8, 8, 11, 0.9);
--surface-soft: rgba(255, 255, 255, 0.08); --surface-soft: rgba(255, 255, 255, 0.08);
@ -1060,6 +1063,9 @@ code {
} }
.button { .button {
--nodedc-button-accent-rgb: var(--nodedc-component-accent-rgb);
--nodedc-button-on-accent-rgb: var(--nodedc-component-on-accent-rgb);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -1094,8 +1100,45 @@ code {
} }
.button--danger { .button--danger {
background: rgba(255, 116, 116, 0.16); background: transparent;
color: #ffd5d5; color: var(--text-primary);
}
.button--danger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.14);
color: var(--text-primary);
}
.button--accent {
background: rgb(var(--nodedc-button-accent-rgb));
color: rgb(var(--nodedc-button-on-accent-rgb));
}
.button--accent:hover:not(:disabled) {
background: color-mix(in srgb, rgb(var(--nodedc-button-accent-rgb)) 84%, rgba(255, 255, 255, 0.72));
color: rgb(var(--nodedc-button-on-accent-rgb));
}
.button--accent * {
color: rgb(var(--nodedc-button-on-accent-rgb));
}
.button[data-surface="modal"] {
min-height: var(--launcher-modal-button-height);
}
.button[data-surface="modal"].button--secondary,
.button[data-surface="modal"].button--danger,
.button[data-surface="modal"].button--ghost {
background: transparent;
color: var(--text-primary);
}
.button[data-surface="modal"].button--secondary:hover:not(:disabled),
.button[data-surface="modal"].button--danger:hover:not(:disabled),
.button[data-surface="modal"].button--ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
} }
.button:disabled { .button:disabled {
@ -2149,6 +2192,131 @@ code {
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
} }
.nodedc-delete-modal-layer {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: 1.4rem;
background: rgba(0, 0, 0, 0.42);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.nodedc-glass-detail-surface,
.glass-surface--detail,
.glass-card--detail {
position: relative;
isolation: isolate;
border: 0;
outline: none;
background: rgba(7, 7, 10, 0.54);
box-shadow:
0 34px 120px rgba(0, 0, 0, 0.64),
0 10px 32px rgba(0, 0, 0, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.045);
backdrop-filter: blur(40px) saturate(1.12);
-webkit-backdrop-filter: blur(40px) saturate(1.12);
}
.nodedc-glass-detail-surface::before,
.glass-surface--detail::before,
.glass-card--detail::before {
content: "";
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(5, 5, 8, 0.58);
backdrop-filter: blur(44px) saturate(1.16);
-webkit-backdrop-filter: blur(44px) saturate(1.16);
}
.nodedc-glass-detail-surface::after,
.glass-surface--detail::after,
.glass-card--detail::after {
content: "";
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.035), transparent 44%, rgba(255, 255, 255, 0.018));
pointer-events: none;
}
.nodedc-glass-detail-surface > *,
.glass-surface--detail > *,
.glass-card--detail > * {
position: relative;
z-index: 1;
}
.nodedc-delete-modal {
width: min(34rem, calc(100vw - 2.8rem));
overflow: hidden;
border: 0;
border-radius: var(--launcher-radius-modal);
outline: none;
}
.nodedc-delete-modal__body {
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr);
gap: 0.9rem;
padding: 1.25rem 1.25rem 1rem;
}
.nodedc-delete-modal__icon {
display: grid;
width: 2.75rem;
height: 2.75rem;
place-items: center;
border-radius: var(--launcher-radius-circle);
background: rgba(var(--nodedc-delete-accent-rgb), 0.08);
color: rgb(var(--nodedc-delete-accent-rgb));
}
.nodedc-delete-modal__copy {
min-width: 0;
padding-top: 0.08rem;
}
.nodedc-delete-modal__copy h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.02rem;
font-weight: 850;
letter-spacing: 0;
}
.nodedc-delete-modal__text {
margin-top: 0.42rem;
color: var(--text-muted);
font-size: 0.82rem;
font-weight: 650;
line-height: 1.45;
}
.nodedc-delete-modal__text strong {
color: var(--text-primary);
font-weight: 850;
}
.nodedc-delete-modal__foot {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
padding: 0.9rem 1.25rem 1.15rem;
}
.nodedc-delete-modal .button {
min-width: 8.25rem;
border-radius: var(--launcher-radius-control);
}
.service-content-modal { .service-content-modal {
position: relative; position: relative;
display: grid; display: grid;
@ -2363,16 +2531,6 @@ code {
font-weight: 750; font-weight: 750;
} }
.service-content-modal .button--primary {
background: rgba(247, 248, 244, 0.96);
color: rgb(var(--nodedc-on-accent-rgb));
}
.service-content-modal .button--secondary {
background: transparent;
color: var(--text-primary);
}
.admin-token-grid { .admin-token-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -63,7 +63,7 @@ import {
import { uploadStorageFile } from "../../shared/api/storageApi"; import { uploadStorageFile } from "../../shared/api/storageApi";
import { cn } from "../../shared/lib/cn"; import { cn } from "../../shared/lib/cn";
import { formatDate, formatDateTime } from "../../shared/lib/format"; import { formatDate, formatDateTime } from "../../shared/lib/format";
import { NodeDcDateField, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui"; import { NodeDcDateField, NodeDcDeleteModal, NodeDcDropdown, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
import { Button, IconButton } from "../../shared/ui/Button"; import { Button, IconButton } from "../../shared/ui/Button";
import { GlassSurface } from "../../shared/ui/Glass"; import { GlassSurface } from "../../shared/ui/Glass";
@ -118,13 +118,17 @@ export function AdminOverlay({
onSetUserServiceAccess, onSetUserServiceAccess,
onCreateInvite, onCreateInvite,
onUpdateInvite, onUpdateInvite,
onDeleteInvite,
onRetrySync, onRetrySync,
onCreateClient, onCreateClient,
onUpdateClient, onUpdateClient,
onDeleteClient,
onUpdateUser, onUpdateUser,
onUpdateMembership, onUpdateMembership,
onDeleteMembership,
onCreateGroup, onCreateGroup,
onUpdateGroup, onUpdateGroup,
onDeleteGroup,
onUpdateService, onUpdateService,
onReorderServices, onReorderServices,
onCreateService, onCreateService,
@ -137,13 +141,17 @@ export function AdminOverlay({
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void; onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void; onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
onRetrySync: (syncId: string) => void; onRetrySync: (syncId: string) => void;
onCreateClient: () => void; onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void; onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void; onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void; onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
onCreateGroup: (clientId: string) => void; onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void; onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void; onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onReorderServices: (orderedServiceIds: string[]) => void; onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void; onCreateService: () => void;
@ -155,7 +163,9 @@ export function AdminOverlay({
const [selectedClientId, setSelectedClientId] = useState(activeClientId); const [selectedClientId, setSelectedClientId] = useState(activeClientId);
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null); const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
const scopedClientId = isRoot ? selectedClientId : activeClientId; const fallbackClientId = data.clients[0]?.id ?? activeClientId;
const selectedClientExists = data.clients.some((client) => client.id === selectedClientId);
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
const currentClient = getClient(data, scopedClientId); const currentClient = getClient(data, scopedClientId);
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]); const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
const selectedAccessCell = const selectedAccessCell =
@ -163,6 +173,13 @@ export function AdminOverlay({
accessMatrix.cells[0] ?? accessMatrix.cells[0] ??
null; null;
useEffect(() => {
if (isRoot && !selectedClientExists && data.clients.length) {
setSelectedClientId(data.clients[0].id);
setSelectedCell(null);
}
}, [data.clients, isRoot, selectedClientExists]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose(); if (event.key === "Escape") onClose();
@ -250,7 +267,7 @@ export function AdminOverlay({
<div className="admin-panel-content__body"> <div className="admin-panel-content__body">
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null} {activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
{activeSection === "clients" && isRoot ? ( {activeSection === "clients" && isRoot ? (
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} /> <ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} onDeleteClient={onDeleteClient} />
) : null} ) : null}
{activeSection === "users" ? ( {activeSection === "users" ? (
<UsersSection <UsersSection
@ -259,10 +276,17 @@ export function AdminOverlay({
isRoot={isRoot} isRoot={isRoot}
onUpdateUser={onUpdateUser} onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership} onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
/> />
) : null} ) : null}
{activeSection === "groups" ? ( {activeSection === "groups" ? (
<GroupsSection data={data} clientId={scopedClientId} onCreateGroup={onCreateGroup} onUpdateGroup={onUpdateGroup} /> <GroupsSection
data={data}
clientId={scopedClientId}
onCreateGroup={onCreateGroup}
onUpdateGroup={onUpdateGroup}
onDeleteGroup={onDeleteGroup}
/>
) : null} ) : null}
{activeSection === "services" && isRoot ? ( {activeSection === "services" && isRoot ? (
<ServicesSection <ServicesSection
@ -289,6 +313,7 @@ export function AdminOverlay({
actorUserId={me.user.id} actorUserId={me.user.id}
onCreateInvite={onCreateInvite} onCreateInvite={onCreateInvite}
onUpdateInvite={onUpdateInvite} onUpdateInvite={onUpdateInvite}
onDeleteInvite={onDeleteInvite}
/> />
) : null} ) : null}
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null} {activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
@ -348,10 +373,12 @@ function ClientsSection({
data, data,
onCreateClient, onCreateClient,
onUpdateClient, onUpdateClient,
onDeleteClient,
}: { }: {
data: LauncherData; data: LauncherData;
onCreateClient: () => void; onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void; onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void;
}) { }) {
const [editingClientId, setEditingClientId] = useState<string | null>(null); const [editingClientId, setEditingClientId] = useState<string | null>(null);
const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null; const editingClient = data.clients.find((client) => client.id === editingClientId) ?? null;
@ -453,6 +480,11 @@ function ClientsSection({
onUpdateClient(editingClient.id, patch); onUpdateClient(editingClient.id, patch);
setEditingClientId(null); setEditingClientId(null);
}} }}
onDelete={() => {
onDeleteClient(editingClient.id);
setEditingClientId(null);
}}
canDelete={data.clients.length > 1}
/> />
) : null} ) : null}
</> </>
@ -465,12 +497,14 @@ function UsersSection({
isRoot, isRoot,
onUpdateUser, onUpdateUser,
onUpdateMembership, onUpdateMembership,
onDeleteMembership,
}: { }: {
data: LauncherData; data: LauncherData;
clientId: string; clientId: string;
isRoot: boolean; isRoot: boolean;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void; onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void; onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
}) { }) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null); const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const rows = isRoot const rows = isRoot
@ -579,6 +613,10 @@ function UsersSection({
onUpdateMembership(editingRow.membership.id, membershipPatch); onUpdateMembership(editingRow.membership.id, membershipPatch);
setEditingMembershipId(null); setEditingMembershipId(null);
}} }}
onDelete={() => {
onDeleteMembership(editingRow.membership.id);
setEditingMembershipId(null);
}}
/> />
) : null} ) : null}
</> </>
@ -590,11 +628,13 @@ function GroupsSection({
clientId, clientId,
onCreateGroup, onCreateGroup,
onUpdateGroup, onUpdateGroup,
onDeleteGroup,
}: { }: {
data: LauncherData; data: LauncherData;
clientId: string; clientId: string;
onCreateGroup: (clientId: string) => void; onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void; onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void;
}) { }) {
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const groups = data.groups.filter((group) => group.clientId === clientId); const groups = data.groups.filter((group) => group.clientId === clientId);
@ -667,6 +707,10 @@ function GroupsSection({
onUpdateGroup(editingGroup.id, patch); onUpdateGroup(editingGroup.id, patch);
setEditingGroupId(null); setEditingGroupId(null);
}} }}
onDelete={() => {
onDeleteGroup(editingGroup.id);
setEditingGroupId(null);
}}
/> />
) : null} ) : null}
</> </>
@ -747,6 +791,7 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
]; ];
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv"; const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
const modalActionAccentRgb = [247, 248, 244] as const;
function ServicesSection({ function ServicesSection({
data, data,
@ -1131,6 +1176,7 @@ function ServiceContentModal({
const [draft, setDraft] = useState<Service>(service); const [draft, setDraft] = useState<Service>(service);
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null); const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null); const [storageError, setStorageError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => { useEffect(() => {
setDraft(service); setDraft(service);
@ -1265,15 +1311,17 @@ function ServiceContentModal({
</div> </div>
<div className="service-content-modal__foot"> <div className="service-content-modal__foot">
<Button variant="secondary" type="button" onClick={onClose}> <Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Отмена Отмена
</Button> </Button>
<div className="service-content-modal__foot-actions"> <div className="service-content-modal__foot-actions">
<Button variant="danger" type="button" icon={<Trash2 size={16} />} onClick={onDelete}> <Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
Удалить Удалить
</Button> </Button>
<Button <Button
variant="primary" variant="accent"
surface="modal"
accentRgb={modalActionAccentRgb}
type="button" type="button"
disabled={uploadingSlot !== null} disabled={uploadingSlot !== null}
icon={<Save size={16} />} icon={<Save size={16} />}
@ -1300,6 +1348,20 @@ function ServiceContentModal({
</div> </div>
</div> </div>
</article> </article>
<NodeDcDeleteModal
isOpen={deleteOpen}
title="Удалить витрину"
description={
<>
Витрина <strong>{service.title}</strong> будет удалена из каталога, нижней панели и матрицы доступов.
</>
}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
onDelete();
}}
/>
</div> </div>
); );
} }
@ -1308,10 +1370,14 @@ function ClientEditorModal({
client, client,
onClose, onClose,
onSave, onSave,
onDelete,
canDelete,
}: { }: {
client: Client; client: Client;
onClose: () => void; onClose: () => void;
onSave: (patch: Partial<Client>) => void; onSave: (patch: Partial<Client>) => void;
onDelete: () => void;
canDelete: boolean;
}) { }) {
const [draft, setDraft] = useState<Client>(client); const [draft, setDraft] = useState<Client>(client);
@ -1394,7 +1460,24 @@ function ClientEditorModal({
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} /> <textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
</label> </label>
</div> </div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} /> <EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={
canDelete
? {
label: "Удалить",
title: "Удалить компанию",
description: (
<>
Компания <strong>{client.name}</strong>, ее участники, группы, инвайты и клиентские гранты будут удалены из демо-данных.
</>
),
onConfirm: onDelete,
}
: undefined
}
/>
</article> </article>
</div> </div>
); );
@ -1406,12 +1489,14 @@ function UserEditorModal({
client, client,
onClose, onClose,
onSave, onSave,
onDelete,
}: { }: {
user: LauncherUser; user: LauncherUser;
membership: ClientMembership; membership: ClientMembership;
client: Client; client: Client;
onClose: () => void; onClose: () => void;
onSave: (userPatch: Partial<LauncherUser>, membershipPatch: Partial<ClientMembership>) => void; onSave: (userPatch: Partial<LauncherUser>, membershipPatch: Partial<ClientMembership>) => void;
onDelete: () => void;
}) { }) {
const [userDraft, setUserDraft] = useState<LauncherUser>(user); const [userDraft, setUserDraft] = useState<LauncherUser>(user);
const [membershipDraft, setMembershipDraft] = useState<ClientMembership>(membership); const [membershipDraft, setMembershipDraft] = useState<ClientMembership>(membership);
@ -1479,7 +1564,20 @@ function UserEditorModal({
<textarea value={userDraft.notes ?? ""} onChange={(event) => updateUser("notes", event.target.value || null)} rows={4} /> <textarea value={userDraft.notes ?? ""} onChange={(event) => updateUser("notes", event.target.value || null)} rows={4} />
</label> </label>
</div> </div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(userDraft, membershipDraft)} /> <EntityModalFoot
onClose={onClose}
onSave={() => onSave(userDraft, membershipDraft)}
deleteConfig={{
label: "Удалить",
title: "Удалить участника",
description: (
<>
Участник <strong>{user.name}</strong> будет удален из клиента <strong>{client.name}</strong>. Глобальный профиль пользователя останется в демо-справочнике.
</>
),
onConfirm: onDelete,
}}
/>
</article> </article>
</div> </div>
); );
@ -1490,11 +1588,13 @@ function GroupEditorModal({
users, users,
onClose, onClose,
onSave, onSave,
onDelete,
}: { }: {
group: ClientGroup; group: ClientGroup;
users: LauncherUser[]; users: LauncherUser[];
onClose: () => void; onClose: () => void;
onSave: (patch: Partial<ClientGroup>) => void; onSave: (patch: Partial<ClientGroup>) => void;
onDelete: () => void;
}) { }) {
const [draft, setDraft] = useState<ClientGroup>(group); const [draft, setDraft] = useState<ClientGroup>(group);
@ -1543,7 +1643,20 @@ function GroupEditorModal({
</div> </div>
</div> </div>
</div> </div>
<EntityModalFoot onClose={onClose} onSave={() => onSave(draft)} /> <EntityModalFoot
onClose={onClose}
onSave={() => onSave(draft)}
deleteConfig={{
label: "Удалить",
title: "Удалить группу",
description: (
<>
Группа <strong>{group.name}</strong> и привязанные к ней гранты сервисов будут удалены.
</>
),
onConfirm: onDelete,
}}
/>
</article> </article>
</div> </div>
); );
@ -1563,16 +1676,52 @@ function EntityModalHead({ eyebrow, title, onClose }: { eyebrow: string; title:
); );
} }
function EntityModalFoot({ onClose, onSave }: { onClose: () => void; onSave: () => void }) { function EntityModalFoot({
onClose,
onSave,
deleteConfig,
}: {
onClose: () => void;
onSave: () => void;
deleteConfig?: {
label: string;
title: string;
description: ReactNode;
onConfirm: () => void;
};
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
return ( return (
<div className="service-content-modal__foot"> <>
<Button variant="secondary" type="button" onClick={onClose}> <div className="service-content-modal__foot">
Отмена <Button variant="secondary" surface="modal" type="button" onClick={onClose}>
</Button> Отмена
<Button variant="primary" type="button" icon={<Save size={16} />} onClick={onSave}> </Button>
Сохранить <div className="service-content-modal__foot-actions">
</Button> {deleteConfig ? (
</div> <Button variant="danger" surface="modal" type="button" icon={<Trash2 size={16} />} onClick={() => setDeleteOpen(true)}>
{deleteConfig.label}
</Button>
) : null}
<Button variant="accent" surface="modal" accentRgb={modalActionAccentRgb} type="button" icon={<Save size={16} />} onClick={onSave}>
Сохранить
</Button>
</div>
</div>
{deleteConfig ? (
<NodeDcDeleteModal
isOpen={deleteOpen}
title={deleteConfig.title}
description={deleteConfig.description}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
setDeleteOpen(false);
deleteConfig.onConfirm();
}}
/>
) : null}
</>
); );
} }
@ -1821,16 +1970,20 @@ function InvitesSection({
actorUserId, actorUserId,
onCreateInvite, onCreateInvite,
onUpdateInvite, onUpdateInvite,
onDeleteInvite,
}: { }: {
data: LauncherData; data: LauncherData;
clientId: string; clientId: string;
actorUserId: string; actorUserId: string;
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void; onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void; onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
onDeleteInvite: (inviteId: string) => void;
}) { }) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [role, setRole] = useState<ClientMembershipRole>("member"); const [role, setRole] = useState<ClientMembershipRole>("member");
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
const invites = data.invites.filter((invite) => invite.clientId === clientId); const invites = data.invites.filter((invite) => invite.clientId === clientId);
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
const actor = getUser(data, actorUserId); const actor = getUser(data, actorUserId);
function handleCreateInvite() { function handleCreateInvite() {
@ -1884,6 +2037,7 @@ function InvitesSection({
<th>Статус</th> <th>Статус</th>
<th>Ссылка</th> <th>Ссылка</th>
<th>Истекает</th> <th>Истекает</th>
<th aria-label="Удаление" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -1938,11 +2092,35 @@ function InvitesSection({
}} }}
/> />
</td> </td>
<td className="services-admin-table__actions">
<IconButton
label={`Удалить инвайт ${invite.email}`}
className="admin-circle-action services-admin-table__edit"
type="button"
onClick={() => setDeleteInviteId(invite.id)}
>
<Trash2 size={15} />
</IconButton>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</GlassSurface> </GlassSurface>
<NodeDcDeleteModal
isOpen={Boolean(deletingInvite)}
title="Удалить инвайт"
description={
<>
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
</>
}
onClose={() => setDeleteInviteId(null)}
onConfirm={() => {
if (deletingInvite) onDeleteInvite(deletingInvite.id);
setDeleteInviteId(null);
}}
/>
</div> </div>
); );
} }