diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 1096ce6..ba8013f 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -6,221 +6,87 @@ "name": "DCTOUCH", "legalName": "ООО ДИСИТАЧ", "status": "active", - "demoEndsAt": "2026-05-29T21:00:00.000Z", - "contactName": "Иван Петров", - "contactEmail": "suppert@dctouch.ru", - "notes": "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T21:04:34.270Z", - "contractEndsAt": "2026-05-30T21:00:00.000Z", - "contractStartsAt": "2026-04-25T21:00:00.000Z" - }, - { - "id": "client_roga_kopyta", - "type": "company", - "name": "ООО Рога и Копыта", - "legalName": "ООО Рога и Копыта", - "status": "demo", - "demoEndsAt": "2026-06-01T00:00:00Z", - "contactName": "Мария Иванова", - "contactEmail": "maria@example.ru", - "notes": "Клиент на демо-доступе, подключены только базовые сервисы.", - "createdAt": "2026-04-10T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "client_private_architect", - "type": "person", - "name": "Илья Архитектор", - "legalName": null, - "status": "suspended", - "demoEndsAt": "2026-04-20T00:00:00Z", - "contactName": "Илья Архитектор", - "contactEmail": "ilya@example.ru", - "notes": "Пример приостановленного частного клиента.", - "createdAt": "2026-03-14T10:00:00Z", - "updatedAt": "2026-05-01T21:03:56.861Z", - "contractStartsAt": "2026-04-30T21:00:00.000Z", - "contractEndsAt": "2026-05-30T21:00:00.000Z" + "contractStartsAt": "2026-05-04T00:00:00.000Z", + "contractEndsAt": null, + "paidUntil": null, + "demoEndsAt": null, + "contactName": "DC Touch", + "contactEmail": "dcctouch@gmail.com", + "notes": "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.", + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" } ], "users": [ { "id": "user_root", - "authentikUserId": "ak-root", - "name": "Root Admin", - "email": "root@nodedc.local", + "authentikUserId": "85f83274-6942-4375-b64d-601716d3ae29", + "name": "DC SUDO", + "email": "dcctouch@gmail.com", + "phone": null, + "position": "NODE.DC Super Admin", + "notes": "Главный супер-администратор NODE.DC. Authentik-пользователь уже создан в dev-контуре.", + "avatarUrl": "/storage/uploads/1777901580306-658d5b6b-2026-03-02-19.34.33.png", "globalStatus": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T13:45:07.613Z" }, { - "id": "user_ivan", - "authentikUserId": "ak-ivan", - "name": "Иван Петров", - "email": "ivan@romashka.ru", + "id": "user_silver_psih", + "authentikUserId": "748490e7-a24b-426a-bf97-b348a2db44b4", + "name": "DC SILVER", + "email": "silver_psih@yahoo.com", + "phone": null, + "position": "Manager", + "notes": "Живой пользователь из Plane. Требует создания/синхронизации в Authentik через Launcher flow.", + "avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg", "globalStatus": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "user_vera", - "authentikUserId": "ak-vera", - "name": "Вера Соколова", - "email": "vera@romashka.ru", - "globalStatus": "active", - "createdAt": "2026-04-02T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "user_vasya", - "authentikUserId": "ak-vasya", - "name": "Василий Орлов", - "email": "vasya@romashka.ru", - "globalStatus": "active", - "createdAt": "2026-04-05T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "user_lena", - "authentikUserId": "ak-lena", - "name": "Лена Волкова", - "email": "lena@romashka.ru", - "globalStatus": "active", - "createdAt": "2026-04-08T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "user_maria", - "authentikUserId": "ak-maria", - "name": "Мария Иванова", - "email": "maria@example.ru", - "globalStatus": "active", - "createdAt": "2026-04-10T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "user_blocked", - "authentikUserId": "ak-blocked", - "name": "Олег Заблокирован", - "email": "oleg@romashka.ru", - "globalStatus": "blocked", - "createdAt": "2026-04-12T10:00:00Z", - "updatedAt": "2026-05-01T18:49:59.865Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T14:15:10.555Z" } ], "memberships": [ { - "id": "mem_ivan_romashka", + "id": "mem_dc_touch_dctouch", "clientId": "client_romashka", - "userId": "user_ivan", + "userId": "user_root", "role": "client_owner", "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" }, { - "id": "mem_vera_romashka", + "id": "mem_silver_psih_dctouch", "clientId": "client_romashka", - "userId": "user_vera", - "role": "client_admin", - "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "mem_vasya_romashka", - "clientId": "client_romashka", - "userId": "user_vasya", + "userId": "user_silver_psih", "role": "member", "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "mem_lena_romashka", - "clientId": "client_romashka", - "userId": "user_lena", - "role": "member", - "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "mem_blocked_romashka", - "clientId": "client_romashka", - "userId": "user_blocked", - "role": "member", - "status": "disabled", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "mem_maria_roga", - "clientId": "client_roga_kopyta", - "userId": "user_maria", - "role": "client_owner", - "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "mem_ivan_roga", - "clientId": "client_roga_kopyta", - "userId": "user_ivan", - "role": "client_admin", - "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" } ], "groups": [ { - "id": "group_romashka_leads", + "id": "group_dctouch_admins", "clientId": "client_romashka", - "name": "Руководство", - "description": "Собственники и руководители клиента.", + "name": "Администраторы", + "description": "Администраторы клиента и владельцы платформенного доступа.", "memberIds": [ - "user_ivan", - "user_vera" + "user_root" ], - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" }, { - "id": "group_romashka_accounting", + "id": "group_dctouch_managers", "clientId": "client_romashka", - "name": "Бухгалтерия", - "description": "1C и финансовые сценарии.", + "name": "Менеджеры", + "description": "Рабочая группа менеджеров с доступом к операционному контуру.", "memberIds": [ - "user_lena" + "user_silver_psih" ], - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "group_romashka_ops", - "clientId": "client_romashka", - "name": "Операторы", - "description": "Ежедневная работа в задачах и тендерах.", - "memberIds": [ - "user_vasya", - "user_lena" - ], - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "group_roga_demo", - "clientId": "client_roga_kopyta", - "name": "Демо-команда", - "description": "Пилотный контур клиента.", - "memberIds": [ - "user_maria", - "user_ivan" - ], - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" } ], "services": [ @@ -230,9 +96,9 @@ "title": "AGENT CORE", "subtitle": "Агентная платформа", "description": "Сборка, запуск и мониторинг агентных workflow.", - "fullDescription": "NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.", - "url": "https://dev.handhdc.ru/sso/launch", - "launchUrl": "https://dev.handhdc.ru/sso/launch", + "fullDescription": "Среда для сборки и управления AI-агентами: сценарии, интеграции, ключи, запуск процессов и runtime-мониторинг в одном контуре. Агентные результаты можно передавать в операционный модуль, CRM, рабочие группы и другие системы.", + "url": "https://nodedc.ru/", + "launchUrl": "https://nodedc.ru/", "accentColor": "#B5FF5A", "fallbackGradient": "linear-gradient(128deg, rgba(181, 255, 90, 0.84), rgba(37, 58, 36, 0.86) 42%, #0A0D10 82%)", "status": "active", @@ -240,7 +106,7 @@ "authentikApplicationSlug": "nodedc", "authentikGroupName": "service-nodedc", "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-02T08:15:08.667Z", + "updatedAt": "2026-05-02T11:52:29.667Z", "coverImageUrl": "/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg", "coverMediaKind": "image", "coverMediaSource": "file", @@ -256,7 +122,7 @@ "title": "OPERATIONAL CORE", "subtitle": "Операционный слой", "description": "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.", - "fullDescription": "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.", + "fullDescription": "Операционный контур для совместной работы людей и AI-агентов. Задачи, поручения, согласования и результаты агентных запусков собираются в единую рабочую среду для управления процессами.", "url": "https://tasks.handhdc.ru/sso/launch", "launchUrl": "https://tasks.handhdc.ru/sso/launch", "accentColor": "#D7C8FF", @@ -266,7 +132,7 @@ "authentikApplicationSlug": "task-manager", "authentikGroupName": "service-task-manager", "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-02T08:15:01.508Z", + "updatedAt": "2026-05-02T11:52:46.023Z", "coverImageUrl": "/storage/uploads/1777652545129-cf547e17-NODEDC_TASK.png", "coverMediaKind": "image", "coverMediaSource": "file", @@ -278,7 +144,7 @@ "title": "1C AI ASSISTANT", "subtitle": "Бухгалтерский ассистент", "description": "Вопросы к 1С, точные выборки и доказательная навигация по данным.", - "fullDescription": "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.", + "fullDescription": "Ассистент для работы с данными 1С через естественный язык: операции, остатки, задолженности, документы, налоги, контрагенты и бухгалтерские показатели доступны в формате точных запросов и проверяемых ответов.", "url": "https://1c.handhdc.ru/sso/launch", "launchUrl": "https://1c.handhdc.ru/sso/launch", "accentColor": "#8FD7FF", @@ -288,7 +154,7 @@ "authentikApplicationSlug": "1c-assistant", "authentikGroupName": "service-1c-assistant", "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-02T08:16:05.517Z", + "updatedAt": "2026-05-02T11:53:29.406Z", "coverImageUrl": "/storage/uploads/1777657277366-a9886413-LLM_MANAGER.png", "coverMediaKind": "image", "coverMediaSource": "file", @@ -300,7 +166,7 @@ "title": "TENDER AI AGENT", "subtitle": "Госзакупки и тендеры", "description": "Поиск, анализ и подготовка тендерных решений.", - "fullDescription": "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.", + "fullDescription": "Агент для поиска, отбора и анализа тендеров: собирает данные с площадок, разбирает документы, оценивает риски, участников и условия закупки, после чего передаёт результат в рабочий контур для дальнейшей обработки.", "url": "https://tender.handhdc.ru/sso/launch", "launchUrl": "https://tender.handhdc.ru/sso/launch", "accentColor": "#FFD166", @@ -310,7 +176,7 @@ "authentikApplicationSlug": "tender-agent", "authentikGroupName": "service-tender-agent", "createdAt": "2026-04-03T10:00:00Z", - "updatedAt": "2026-05-02T08:15:32.328Z", + "updatedAt": "2026-05-02T11:53:12.714Z", "coverImageUrl": "/storage/uploads/1777652810531-1f17b7ed-LAP_PURP4k.jpg", "coverMediaKind": "image", "coverMediaSource": "file", @@ -326,7 +192,7 @@ "title": "DIGITAL TWIN MOSCOW", "subtitle": "3D и пространственные данные", "description": "Просмотр цифровых двойников, карт и объектных сцен.", - "fullDescription": "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.", + "fullDescription": "Городская цифровая витрина на базе отсканированных пространств Москвы: 3D-сцены, объекты, слои, инфраструктура, статусы и телеметрия объединяются в интерактивную среду для просмотра, анализа и мониторинга.", "url": "https://launch.dcserve.ru/", "launchUrl": "https://launch.dcserve.ru/", "accentColor": "#76E4F7", @@ -336,7 +202,7 @@ "authentikApplicationSlug": "digital-twin", "authentikGroupName": "service-digital-twin", "createdAt": "2026-04-05T10:00:00Z", - "updatedAt": "2026-05-02T08:55:28.665Z", + "updatedAt": "2026-05-02T11:53:43.705Z", "coverImageUrl": "/storage/uploads/1777711943125-691830c2-NODEDC_DT_MMAP.png", "coverMediaKind": "image", "coverMediaSource": "file", @@ -363,207 +229,821 @@ ], "grants": [ { - "id": "grant_romashka_task", + "id": "grant_dctouch_task_admins", "serviceId": "service_task_manager", - "targetType": "client", - "targetId": "client_romashka", - "appRole": "member", - "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "grant_romashka_nodedc_leads", - "serviceId": "service_nodedc", "targetType": "group", - "targetId": "group_romashka_leads", + "targetId": "group_dctouch_admins", "appRole": "admin", "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" }, { - "id": "grant_romashka_1c_accounting", - "serviceId": "service_1c", + "id": "grant_dctouch_task_managers", + "serviceId": "service_task_manager", "targetType": "group", - "targetId": "group_romashka_accounting", + "targetId": "group_dctouch_managers", "appRole": "member", "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" }, { - "id": "grant_romashka_tender_ops", - "serviceId": "service_tender", + "id": "grant_dctouch_nodedc_admins", + "serviceId": "service_nodedc", "targetType": "group", - "targetId": "group_romashka_ops", - "appRole": "viewer", + "targetId": "group_dctouch_admins", + "appRole": "admin", "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T12:55:13.842Z" }, { - "id": "grant_romashka_twin_vasya", + "id": "grant_digital_twin_user_silver_psih_yahoo_com", "serviceId": "service_digital_twin", "targetType": "user", - "targetId": "user_vasya", - "appRole": "viewer", - "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "grant_roga_task", - "serviceId": "service_task_manager", - "targetType": "client", - "targetId": "client_roga_kopyta", + "targetId": "user_silver_psih", "appRole": "member", "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "createdAt": "2026-05-04T13:33:42.062Z", + "updatedAt": "2026-05-04T13:33:42.062Z" }, { - "id": "grant_roga_nodedc", - "serviceId": "service_nodedc", - "targetType": "client", - "targetId": "client_roga_kopyta", - "appRole": "viewer", - "status": "active", - "createdAt": "2026-04-01T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - } - ], - "exceptions": [ - { - "id": "exception_lena_task_deny", + "id": "grant_task_manager_user_silver_psih_yahoo_com", "serviceId": "service_task_manager", - "userId": "user_lena", - "type": "deny", - "reason": "Индивидуально отключён Task Manager на период ревизии доступа.", - "createdAt": "2026-04-28T10:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - } - ], - "invites": [ - { - "id": "invite_romashka_analyst", - "clientId": "client_romashka", - "email": "analyst@romashka.ru", - "role": "member", - "invitedByUserId": "user_ivan", - "token": "romashka-analyst-demo", - "expiresAt": "2026-05-15T12:00:00Z", - "status": "sent", - "createdAt": "2026-04-30T12:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" - }, - { - "id": "invite_roga_admin", - "clientId": "client_roga_kopyta", - "email": "ops@example.ru", - "role": "client_admin", - "invitedByUserId": "user_maria", - "token": "roga-admin-demo", - "expiresAt": "2026-05-18T12:00:00Z", - "status": "created", - "createdAt": "2026-04-30T14:00:00Z", - "updatedAt": "2026-05-01T09:00:00Z" + "targetType": "user", + "targetId": "user_silver_psih", + "appRole": "member", + "status": "active", + "createdAt": "2026-05-04T14:15:10.296Z", + "updatedAt": "2026-05-04T14:15:10.296Z" } ], + "exceptions": [], + "invites": [], "syncStatuses": [ { - "id": "sync_romashka_auth", + "id": "sync_dctouch_client_authentik", "objectId": "client_romashka", - "objectName": "ООО Ромашка", + "objectName": "DCTOUCH", "objectType": "client", "target": "authentik", "state": "synced", - "lastSyncAt": "2026-05-01T08:00:00Z", + "lastSyncAt": "2026-05-04T12:55:13.842Z", "error": null, - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-04T12:55:13.842Z" }, { - "id": "sync_task_auth", - "objectId": "service_task_manager", - "objectName": "Task Manager", - "objectType": "service", + "id": "sync_dc_touch_authentik", + "objectId": "user_root", + "objectName": "dcctouch@gmail.com", + "objectType": "user", "target": "authentik", "state": "synced", - "lastSyncAt": "2026-05-01T08:00:00Z", + "lastSyncAt": "2026-05-04T13:45:07.613Z", "error": null, - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-04T13:45:07.613Z" }, { - "id": "sync_lena_task", - "objectId": "exception_lena_task_deny", - "objectName": "Deny: Лена / Task Manager", - "objectType": "grant", - "target": "task_manager", + "id": "sync_silver_psih_authentik", + "objectId": "user_silver_psih", + "objectName": "silver_psih@yahoo.com", + "objectType": "user", + "target": "authentik", + "state": "synced", + "lastSyncAt": "2026-05-04T14:15:10.555Z", + "error": null, + "updatedAt": "2026-05-04T14:15:10.555Z" + }, + { + "id": "sync_dctouch_groups_authentik", + "objectId": "client_romashka:groups", + "objectName": "DCTOUCH groups", + "objectType": "group", + "target": "authentik", "state": "pending", "lastSyncAt": null, "error": null, - "updatedAt": "2026-05-01T09:00:00Z" + "updatedAt": "2026-05-04T12:55:13.842Z" }, { - "id": "sync_roga_nodedc", - "objectId": "client_roga_kopyta", - "objectName": "ООО Рога и Копыта", - "objectType": "client", - "target": "nodedc", - "state": "error", - "lastSyncAt": "2026-05-01T08:00:00Z", - "error": "OIDC binding ещё не создан для demo-клиента.", - "updatedAt": "2026-05-01T09:00:00Z" + "id": "sync_task_manager_authentik", + "objectId": "service_task_manager", + "objectName": "OPERATIONAL CORE", + "objectType": "service", + "target": "authentik", + "state": "synced", + "lastSyncAt": "2026-05-04T12:55:13.842Z", + "error": null, + "updatedAt": "2026-05-04T12:55:13.842Z" + }, + { + "id": "sync_grant_service_digital_twin_user_silver_psih", + "objectId": "service_digital_twin:user_silver_psih", + "objectName": "digital-twin:silver_psih@yahoo.com", + "objectType": "grant", + "target": "authentik", + "state": "pending", + "lastSyncAt": null, + "error": null, + "updatedAt": "2026-05-04T13:33:42.062Z" + }, + { + "id": "sync_grant_service_task_manager_user_silver_psih", + "objectId": "service_task_manager:user_silver_psih", + "objectName": "task-manager:silver_psih@yahoo.com", + "objectType": "grant", + "target": "authentik", + "state": "pending", + "lastSyncAt": null, + "error": null, + "updatedAt": "2026-05-04T14:15:10.297Z" } ], "auditEvents": [ { - "id": "audit_1", - "at": "2026-05-01T08:40:00Z", - "actorUserId": "user_root", - "actorName": "Root Admin", - "action": "Создан сервис", - "objectType": "service", - "objectName": "Digital Modules", + "id": "audit_live_seed_control_plane", + "at": "2026-05-04T12:55:13.842Z", + "actorUserId": "system", + "actorName": "NODE.DC seed", + "action": "Применён live seed control-plane", + "objectType": "control_plane", + "objectName": "Launcher users and access", "clientId": "client_romashka", "result": "success", + "details": "Demo-участники удалены из runtime storage. Оставлены dcctouch@gmail.com и silver_psih@yahoo.com." + }, + { + "id": "audit_silver_psih_yahoo_com", + "at": "2026-05-04T13:31:18.416Z", + "actorUserId": "user_silver_psih", + "actorName": "Silver Psy", + "action": "Обновлён профиль пользователя", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", "details": null }, { - "id": "audit_2", - "at": "2026-05-01T08:20:00Z", - "actorUserId": "user_ivan", - "actorName": "Иван Петров", - "action": "Создан invite", - "objectType": "invite", - "objectName": "analyst@romashka.ru", - "clientId": "client_romashka", + "id": "audit_silver_psih_yahoo_com_2", + "at": "2026-05-04T13:31:18.693Z", + "actorUserId": "user_silver_psih", + "actorName": "DC SILVER", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, "result": "success", - "details": "Срок действия до 15.05.2026" + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" }, { - "id": "audit_3", - "at": "2026-04-30T17:10:00Z", + "id": "audit_dcctouch_gmail_com", + "at": "2026-05-04T13:33:03.295Z", "actorUserId": "user_root", - "actorName": "Root Admin", - "action": "Создано deny-исключение", - "objectType": "access", - "objectName": "Лена / Task Manager", - "clientId": "client_romashka", - "result": "warning", - "details": "Индивидуальное правило перекрыло client grant." + "actorName": "DC Touch", + "action": "Обновлён профиль пользователя", + "objectType": "user", + "objectName": "dcctouch@gmail.com", + "clientId": null, + "result": "success", + "details": null }, { - "id": "audit_4", - "at": "2026-04-30T16:00:00Z", + "id": "audit_dcctouch_gmail_com_2", + "at": "2026-05-04T13:33:03.579Z", "actorUserId": "user_root", - "actorName": "Root Admin", - "action": "Ошибка синхронизации", - "objectType": "sync", - "objectName": "ООО Рога и Копыта / NodeDC", - "clientId": "client_romashka", - "result": "error", - "details": "Нет application binding." + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "dcctouch@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:superadmin, nodedc:launcher:admin, nodedc:taskmanager:admin, nodedc:taskmanager:user" + }, + { + "id": "audit_silver_psih_yahoo_com_digital_twin", + "at": "2026-05-04T13:33:42.062Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / digital-twin", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager", + "at": "2026-05-04T13:34:41.114Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_dcctouch_gmail_com_3", + "at": "2026-05-04T13:45:07.614Z", + "actorUserId": "system", + "actorName": "Codex access sync", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "dcctouch@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:superadmin, nodedc:launcher:admin, nodedc:taskmanager:admin, nodedc:taskmanager:user" + }, + { + "id": "audit_silver_psih_yahoo_com_3", + "at": "2026-05-04T13:45:07.954Z", + "actorUserId": "system", + "actorName": "Codex access sync", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_2", + "at": "2026-05-04T13:50:03.850Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_4", + "at": "2026-05-04T13:50:04.107Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_5", + "at": "2026-05-04T14:00:23.198Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_3", + "at": "2026-05-04T14:00:25.625Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_6", + "at": "2026-05-04T14:00:25.848Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_7", + "at": "2026-05-04T14:00:35.727Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_8", + "at": "2026-05-04T14:00:43.300Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_4", + "at": "2026-05-04T14:00:46.785Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_9", + "at": "2026-05-04T14:00:47.011Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_10", + "at": "2026-05-04T14:01:28.244Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_11", + "at": "2026-05-04T14:01:31.878Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_12", + "at": "2026-05-04T14:01:33.920Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_5", + "at": "2026-05-04T14:01:35.987Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_13", + "at": "2026-05-04T14:01:36.184Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_14", + "at": "2026-05-04T14:01:44.613Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_15", + "at": "2026-05-04T14:01:46.978Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_16", + "at": "2026-05-04T14:01:48.992Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_17", + "at": "2026-05-04T14:01:51.455Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_6", + "at": "2026-05-04T14:01:53.407Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_18", + "at": "2026-05-04T14:01:53.622Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_7", + "at": "2026-05-04T14:09:21.076Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_19", + "at": "2026-05-04T14:09:21.315Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_8", + "at": "2026-05-04T14:09:27.239Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_20", + "at": "2026-05-04T14:09:27.516Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_21", + "at": "2026-05-04T14:09:39.517Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_22", + "at": "2026-05-04T14:09:44.175Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_9", + "at": "2026-05-04T14:09:47.316Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_23", + "at": "2026-05-04T14:09:47.519Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_24", + "at": "2026-05-04T14:09:54.512Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_25", + "at": "2026-05-04T14:09:56.599Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_26", + "at": "2026-05-04T14:10:10.975Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_27", + "at": "2026-05-04T14:10:13.786Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_28", + "at": "2026-05-04T14:10:36.419Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_10", + "at": "2026-05-04T14:10:43.069Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_29", + "at": "2026-05-04T14:10:43.376Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_11", + "at": "2026-05-04T14:10:48.109Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_30", + "at": "2026-05-04T14:10:48.333Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_31", + "at": "2026-05-04T14:10:50.741Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_32", + "at": "2026-05-04T14:10:52.704Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_33", + "at": "2026-05-04T14:10:54.283Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_34", + "at": "2026-05-04T14:11:00.944Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_35", + "at": "2026-05-04T14:11:06.473Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_12", + "at": "2026-05-04T14:14:56.539Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_36", + "at": "2026-05-04T14:14:56.867Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_13", + "at": "2026-05-04T14:14:59.525Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_silver_psih_yahoo_com_37", + "at": "2026-05-04T14:14:59.762Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, service-digital-twin" + }, + { + "id": "audit_silver_psih_yahoo_com_task_manager_14", + "at": "2026-05-04T14:15:10.297Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "silver_psih@yahoo.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: member" + }, + { + "id": "audit_silver_psih_yahoo_com_38", + "at": "2026-05-04T14:15:10.555Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" } ] } diff --git a/public/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg b/public/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg new file mode 100644 index 0000000..abaa7fb Binary files /dev/null and b/public/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg differ diff --git a/public/storage/uploads/1777901580306-658d5b6b-2026-03-02-19.34.33.png b/public/storage/uploads/1777901580306-658d5b6b-2026-03-02-19.34.33.png new file mode 100644 index 0000000..052b3a3 Binary files /dev/null and b/public/storage/uploads/1777901580306-658d5b6b-2026-03-02-19.34.33.png differ diff --git a/scripts/seed-live-control-plane.mjs b/scripts/seed-live-control-plane.mjs new file mode 100644 index 0000000..7566670 --- /dev/null +++ b/scripts/seed-live-control-plane.mjs @@ -0,0 +1,236 @@ +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const publicDataPath = join(projectRoot, "public", "storage", "launcher-data.json"); +const distDataPath = join(projectRoot, "dist", "storage", "launcher-data.json"); + +const now = new Date().toISOString(); +const existingData = readJson(publicDataPath); +const services = Array.isArray(existingData.services) ? existingData.services : []; +const existingUsersByEmail = new Map( + (Array.isArray(existingData.users) ? existingData.users : []).map((user) => [String(user.email || "").toLowerCase(), user]) +); +const dcTouchAuthentikUserId = existingUsersByEmail.get("dcctouch@gmail.com")?.authentikUserId ?? null; +const silverPsihAuthentikUserId = existingUsersByEmail.get("silver_psih@yahoo.com")?.authentikUserId ?? null; + +const liveData = { + ...existingData, + clients: [ + { + id: "client_romashka", + type: "company", + name: "DCTOUCH", + legalName: "ООО ДИСИТАЧ", + status: "active", + contractStartsAt: "2026-05-04T00:00:00.000Z", + contractEndsAt: null, + paidUntil: null, + demoEndsAt: null, + contactName: "DC Touch", + contactEmail: "dcctouch@gmail.com", + notes: "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + ], + users: [ + { + id: "user_root", + authentikUserId: dcTouchAuthentikUserId, + name: "DC Touch", + email: "dcctouch@gmail.com", + phone: null, + position: "NODE.DC Super Admin", + notes: "Главный супер-администратор NODE.DC. Authentik-пользователь уже создан в dev-контуре.", + avatarUrl: null, + globalStatus: "active", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + { + id: "user_silver_psih", + authentikUserId: silverPsihAuthentikUserId, + name: "Silver Psy", + email: "silver_psih@yahoo.com", + phone: null, + position: "Manager", + notes: "Живой пользователь из Plane. Требует создания/синхронизации в Authentik через Launcher flow.", + avatarUrl: null, + globalStatus: "active", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + ], + memberships: [ + { + id: "mem_dc_touch_dctouch", + clientId: "client_romashka", + userId: "user_root", + role: "client_owner", + status: "active", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + { + id: "mem_silver_psih_dctouch", + clientId: "client_romashka", + userId: "user_silver_psih", + role: "member", + status: "active", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + ], + groups: [ + { + id: "group_dctouch_admins", + clientId: "client_romashka", + name: "Администраторы", + description: "Администраторы клиента и владельцы платформенного доступа.", + memberIds: ["user_root"], + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + { + id: "group_dctouch_managers", + clientId: "client_romashka", + name: "Менеджеры", + description: "Рабочая группа менеджеров с доступом к операционному контуру.", + memberIds: ["user_silver_psih"], + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + ], + grants: [ + { + id: "grant_dctouch_task_admins", + serviceId: "service_task_manager", + targetType: "group", + targetId: "group_dctouch_admins", + appRole: "admin", + status: "active", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + { + id: "grant_dctouch_task_managers", + serviceId: "service_task_manager", + targetType: "group", + targetId: "group_dctouch_managers", + appRole: "member", + status: "active", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + { + id: "grant_dctouch_nodedc_admins", + serviceId: "service_nodedc", + targetType: "group", + targetId: "group_dctouch_admins", + appRole: "admin", + status: "active", + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: now, + }, + ], + exceptions: [], + invites: [], + syncStatuses: [ + { + id: "sync_dctouch_client_authentik", + objectId: "client_romashka", + objectName: "DCTOUCH", + objectType: "client", + target: "authentik", + state: "synced", + lastSyncAt: now, + error: null, + updatedAt: now, + }, + { + id: "sync_dc_touch_authentik", + objectId: "user_root", + objectName: "dcctouch@gmail.com", + objectType: "user", + target: "authentik", + state: dcTouchAuthentikUserId ? "synced" : "pending", + lastSyncAt: dcTouchAuthentikUserId ? now : null, + error: dcTouchAuthentikUserId ? null : "Пользователь есть в Authentik, но Launcher seed ещё не содержит Authentik UUID.", + updatedAt: now, + }, + { + id: "sync_silver_psih_authentik", + objectId: "user_silver_psih", + objectName: "silver_psih@yahoo.com", + objectType: "user", + target: "authentik", + state: silverPsihAuthentikUserId ? "synced" : "pending", + lastSyncAt: silverPsihAuthentikUserId ? now : null, + error: silverPsihAuthentikUserId + ? null + : "Пользователь найден в Plane, но ещё не создан в Authentik через Launcher invite/sync flow.", + updatedAt: now, + }, + { + id: "sync_dctouch_groups_authentik", + objectId: "client_romashka:groups", + objectName: "DCTOUCH groups", + objectType: "group", + target: "authentik", + state: "pending", + lastSyncAt: null, + error: null, + updatedAt: now, + }, + { + id: "sync_task_manager_authentik", + objectId: "service_task_manager", + objectName: "OPERATIONAL CORE", + objectType: "service", + target: "authentik", + state: "synced", + lastSyncAt: now, + error: null, + updatedAt: now, + }, + ], + auditEvents: [ + { + id: "audit_live_seed_control_plane", + at: now, + actorUserId: "system", + actorName: "NODE.DC seed", + action: "Применён live seed control-plane", + objectType: "control_plane", + objectName: "Launcher users and access", + clientId: "client_romashka", + result: "success", + details: "Demo-участники удалены из runtime storage. Оставлены dcctouch@gmail.com и silver_psih@yahoo.com.", + }, + ], + services, +}; + +await writeJson(publicDataPath, liveData); + +if (existsSync(join(projectRoot, "dist"))) { + await writeJson(distDataPath, liveData); +} + +console.log(`Seeded ${liveData.users.length} users, ${liveData.clients.length} client, ${liveData.groups.length} groups.`); + +function readJson(path) { + if (!existsSync(path)) { + return {}; + } + + return JSON.parse(readFileSync(path, "utf8")); +} + +async function writeJson(path, data) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} diff --git a/server/authentik-sync.mjs b/server/authentik-sync.mjs new file mode 100644 index 0000000..9a766bc --- /dev/null +++ b/server/authentik-sync.mjs @@ -0,0 +1,305 @@ +import { randomBytes } from "node:crypto"; + +const platformGroups = { + superadmin: "nodedc:superadmin", + launcherAdmin: "nodedc:launcher:admin", + launcherUser: "nodedc:launcher:user", + taskManagerAdmin: "nodedc:taskmanager:admin", + taskManagerUser: "nodedc:taskmanager:user", +}; + +export function createAuthentikSyncClient({ baseUrl, token }) { + const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, ""); + + function isConfigured() { + return Boolean(normalizedBaseUrl && token); + } + + async function provisionUser({ data, userId, password, generatePassword = false }) { + ensureConfigured(); + + const user = findById(data.users, userId, "user"); + const requiredGroups = resolveRequiredGroups(data, user); + const groups = await ensureGroups(requiredGroups); + const existingUser = await findUserByIdOrEmail(user.authentikUserId, user.email); + const temporaryPassword = password || (generatePassword && !existingUser ? generatePasswordValue() : null); + const payload = { + username: user.email.toLowerCase(), + email: user.email.toLowerCase(), + name: user.name, + is_active: user.globalStatus === "active", + type: "internal", + groups: groups.map((group) => group.pk), + attributes: { + nodedc_user_id: user.id, + nodedc_source: "launcher-control-plane", + picture: user.avatarUrl || undefined, + avatar_url: user.avatarUrl || undefined, + }, + }; + const authentikUser = existingUser + ? await requestJson(`/api/v3/core/users/${encodeURIComponent(existingUser.pk)}/`, { + method: "PATCH", + body: JSON.stringify(payload), + }) + : await requestJson("/api/v3/core/users/", { + method: "POST", + body: JSON.stringify(payload), + }); + + if (temporaryPassword) { + await setPassword(authentikUser.pk, temporaryPassword); + } + + return { + authentikUserId: String(authentikUser.uuid || authentikUser.uid || authentikUser.pk), + authentikPk: authentikUser.pk, + email: authentikUser.email, + name: authentikUser.name, + groups: requiredGroups, + created: !existingUser, + temporaryPassword, + }; + } + + async function findUserByIdOrEmail(authentikUserId, email) { + if (authentikUserId) { + const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`); + const users = Array.isArray(payload.results) ? payload.results : []; + const existingUser = users.find((user) => { + const identifiers = [user.uuid, user.uid, user.pk].map((value) => String(value || "")); + return identifiers.includes(String(authentikUserId)); + }); + + if (existingUser) { + return existingUser; + } + } + + const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(email)}`); + const users = Array.isArray(payload.results) ? payload.results : []; + return users.find((user) => String(user.email || "").toLowerCase() === email.toLowerCase()) ?? null; + } + + async function ensureGroups(groupNames) { + const groups = []; + + for (const groupName of groupNames) { + groups.push(await ensureGroup(groupName)); + } + + return groups; + } + + async function ensureGroup(groupName) { + const payload = await requestJson(`/api/v3/core/groups/?search=${encodeURIComponent(groupName)}`); + const groups = Array.isArray(payload.results) ? payload.results : []; + const existingGroup = groups.find((group) => group.name === groupName); + + if (existingGroup) { + return existingGroup; + } + + return requestJson("/api/v3/core/groups/", { + method: "POST", + body: JSON.stringify({ + name: groupName, + is_superuser: false, + attributes: { + nodedc_source: "launcher-control-plane", + }, + }), + }); + } + + async function setPassword(userPk, password) { + await requestJson(`/api/v3/core/users/${encodeURIComponent(userPk)}/set_password/`, { + method: "POST", + body: JSON.stringify({ password }), + }); + } + + async function requestJson(path, init = {}) { + ensureConfigured(); + + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${token}`); + headers.set("Accept", "application/json"); + + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${normalizedBaseUrl}${path}`, { + ...init, + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Authentik API ${path} failed: HTTP ${response.status} ${errorText}`); + } + + return response.status === 204 ? null : response.json(); + } + + function ensureConfigured() { + if (!isConfigured()) { + throw new Error("Authentik API is not configured. Set AUTHENTIK_BOOTSTRAP_TOKEN or NODEDC_AUTHENTIK_SERVICE_TOKEN server-side."); + } + } + + return { + isConfigured, + provisionUser, + }; +} + +export function resolveRequiredGroups(data, user) { + const groupNames = new Set(); + + if (user.globalStatus !== "active") { + return []; + } + + groupNames.add(platformGroups.launcherUser); + + if (user.id === "user_root") { + groupNames.add(platformGroups.superadmin); + groupNames.add(platformGroups.launcherAdmin); + groupNames.add(platformGroups.taskManagerAdmin); + groupNames.add(platformGroups.taskManagerUser); + return [...groupNames]; + } + + for (const client of data.clients) { + const membership = getRuntimeMembership(data, user.id, client.id); + + if (membership.status !== "active") { + continue; + } + + const userGroups = getUserGroups(data, user.id, client.id); + + for (const service of data.services) { + const access = computeEffectiveAccess(data, { client, user, membership, userGroups, service }); + + if (!access.allowed) { + continue; + } + + if (service.slug === "task-manager") { + groupNames.add(platformGroups.taskManagerUser); + + if (access.appRole === "admin" || access.appRole === "owner") { + groupNames.add(platformGroups.taskManagerAdmin); + } + } else if (service.authentikGroupName) { + groupNames.add(service.authentikGroupName); + } + } + } + + return [...groupNames]; +} + +function generatePasswordValue() { + return `NDC-${randomBytes(15).toString("base64url")}`; +} + +function computeEffectiveAccess(data, { client, user, membership, userGroups, service }) { + if (client.status === "suspended" || client.status === "expired") { + return { allowed: false }; + } + + if (user.globalStatus === "blocked" || membership.status === "disabled") { + return { allowed: false }; + } + + if (service.status === "disabled" || service.status === "hidden") { + return { allowed: false }; + } + + const deny = data.exceptions.find( + (exception) => exception.serviceId === service.id && exception.userId === user.id && exception.type === "deny" + ); + + if (deny) { + return { allowed: false }; + } + + const allow = data.exceptions.find( + (exception) => exception.serviceId === service.id && exception.userId === user.id && exception.type === "allow" + ); + + if (allow) { + return { allowed: true }; + } + + const userGrant = data.grants.find( + (grant) => + grant.serviceId === service.id && + grant.targetType === "user" && + grant.targetId === user.id && + grant.status === "active" + ); + + if (userGrant) { + return { allowed: true, appRole: userGrant.appRole }; + } + + const groupIds = userGroups.map((group) => group.id); + const groupGrant = data.grants.find( + (grant) => + grant.serviceId === service.id && + grant.targetType === "group" && + groupIds.includes(grant.targetId) && + grant.status === "active" + ); + + if (groupGrant) { + return { allowed: true, appRole: groupGrant.appRole }; + } + + const clientGrant = data.grants.find( + (grant) => + grant.serviceId === service.id && + grant.targetType === "client" && + grant.targetId === client.id && + grant.status === "active" + ); + + if (clientGrant) { + return { allowed: true, appRole: clientGrant.appRole }; + } + + return { allowed: false }; +} + +function getRuntimeMembership(data, userId, clientId) { + return ( + data.memberships.find((membership) => membership.userId === userId && membership.clientId === clientId) ?? { + id: `missing_${clientId}_${userId}`, + clientId, + userId, + role: "member", + status: "disabled", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + ); +} + +function getUserGroups(data, userId, clientId) { + return data.groups.filter((group) => group.clientId === clientId && group.memberIds.includes(userId)); +} + +function findById(items, id, label) { + const item = items.find((candidate) => candidate.id === id); + + if (!item) { + throw new Error(`Unknown ${label}: ${id}`); + } + + return item; +} diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs new file mode 100644 index 0000000..45903f1 --- /dev/null +++ b/server/control-plane-store.mjs @@ -0,0 +1,1103 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const collectionKeys = [ + "clients", + "users", + "memberships", + "groups", + "services", + "grants", + "exceptions", + "invites", + "syncStatuses", + "auditEvents", +]; + +const clientTypes = new Set(["company", "person"]); +const clientStatuses = new Set(["active", "suspended", "demo", "expired"]); +const userStatuses = new Set(["invited", "active", "blocked"]); +const membershipRoles = new Set(["client_owner", "client_admin", "member"]); +const grantTargetTypes = new Set(["client", "group", "user"]); +const appRoles = new Set(["viewer", "member", "admin", "owner"]); +const grantStatuses = new Set(["active", "disabled"]); +const exceptionTypes = new Set(["deny", "allow"]); +const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]); + +export function createControlPlaneStore({ projectRoot }) { + const publicStorageRoot = join(projectRoot, "public", "storage"); + const distStorageRoot = join(projectRoot, "dist", "storage"); + const dataPath = join(publicStorageRoot, "launcher-data.json"); + + function readData() { + if (!existsSync(dataPath)) { + return normalizeData({}); + } + + return normalizeData(JSON.parse(readFileSync(dataPath, "utf8"))); + } + + async function writeData(data) { + const normalizedData = normalizeData(data); + const payload = `${JSON.stringify(normalizedData, null, 2)}\n`; + + await Promise.all( + getWritableStorageRoots().map(async (storageRoot) => { + await mkdir(storageRoot, { recursive: true }); + await writeJsonAtomically(join(storageRoot, "launcher-data.json"), payload); + }) + ); + + return normalizedData; + } + + function getSnapshot(identity) { + const data = readData(); + + return { + actor: resolveActor(data, identity), + counts: Object.fromEntries(collectionKeys.map((key) => [key, data[key].length])), + data, + }; + } + + async function replaceData(payload, identity) { + const data = normalizeData(payload); + const actor = resolveActor(data, identity); + addAuditEvent(data, actor, { + action: "Обновлено control-plane состояние", + objectType: "control_plane", + objectName: "Launcher data", + result: "success", + details: "Полная запись launcher-data.json через backend store.", + }); + + return writeData(data); + } + + async function createClient(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const name = requireString(payload?.name, "name"); + const client = { + id: uniqueId(data.clients, "client", name), + type: pickEnum(payload?.type, clientTypes, "company"), + name, + legalName: nullableString(payload?.legalName), + inn: nullableString(payload?.inn), + status: pickEnum(payload?.status, clientStatuses, "active"), + contractStartsAt: nullableString(payload?.contractStartsAt), + contractEndsAt: nullableString(payload?.contractEndsAt), + paidUntil: nullableString(payload?.paidUntil), + demoEndsAt: nullableString(payload?.demoEndsAt), + contactName: nullableString(payload?.contactName), + contactEmail: nullableString(payload?.contactEmail), + notes: nullableString(payload?.notes), + createdAt: now, + updatedAt: now, + }; + + data.clients.push(client); + addAuditEvent(data, actor, { + action: "Создан клиент", + objectType: "client", + objectName: client.name, + clientId: client.id, + result: "success", + }); + markPendingSync(data, client, "client"); + + await writeData(data); + return { client, data }; + } + + async function updateClient(clientId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const client = findById(data.clients, clientId, "client"); + + client.type = pickEnum(payload?.type, clientTypes, client.type); + client.name = optionalString(payload?.name, client.name); + client.legalName = nullableStringWithFallback(payload?.legalName, client.legalName ?? null); + client.inn = nullableStringWithFallback(payload?.inn, client.inn ?? null); + client.status = pickEnum(payload?.status, clientStatuses, client.status); + client.contractStartsAt = nullableStringWithFallback(payload?.contractStartsAt, client.contractStartsAt ?? null); + client.contractEndsAt = nullableStringWithFallback(payload?.contractEndsAt, client.contractEndsAt ?? null); + client.paidUntil = nullableStringWithFallback(payload?.paidUntil, client.paidUntil ?? null); + client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null); + client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null); + client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null); + client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null); + client.updatedAt = isoNow(); + + addAuditEvent(data, actor, { + action: "Обновлён клиент", + objectType: "client", + objectName: client.name, + clientId: client.id, + result: "success", + }); + markPendingSync(data, client, "client"); + + await writeData(data); + return { client, data }; + } + + async function deleteClient(clientId, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const client = findById(data.clients, clientId, "client"); + + if (data.clients.length <= 1) { + throw new Error("Cannot delete the last client"); + } + + const deletedGroupIds = new Set(data.groups.filter((group) => group.clientId === clientId).map((group) => group.id)); + data.clients = data.clients.filter((item) => item.id !== clientId); + data.memberships = data.memberships.filter((membership) => membership.clientId !== clientId); + data.groups = data.groups.filter((group) => group.clientId !== clientId); + data.grants = data.grants.filter( + (grant) => + !(grant.targetType === "client" && grant.targetId === clientId) && + !(grant.targetType === "group" && deletedGroupIds.has(grant.targetId)) + ); + data.invites = data.invites.filter((invite) => invite.clientId !== clientId); + data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== clientId); + + addAuditEvent(data, actor, { + action: "Удалён клиент", + objectType: "client", + objectName: client.name, + clientId, + result: "warning", + }); + + await writeData(data); + return { client, data }; + } + + async function updateUserProfile(userId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const user = findById(data.users, userId, "user"); + const now = isoNow(); + + user.name = optionalString(payload?.name, user.name); + user.email = optionalString(payload?.email, user.email); + user.phone = nullableStringWithFallback(payload?.phone, user.phone); + user.position = nullableStringWithFallback(payload?.position, user.position); + user.notes = nullableStringWithFallback(payload?.notes, user.notes); + user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null); + user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus); + user.updatedAt = now; + + addAuditEvent(data, actor, { + action: "Обновлён профиль пользователя", + objectType: "user", + objectName: user.email, + result: "success", + }); + markPendingSync(data, user, "user"); + + await writeData(data); + return { user, data }; + } + + async function createUser(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const clientId = requireString(payload?.clientId, "clientId"); + const client = findById(data.clients, clientId, "client"); + const email = requireString(payload?.email, "email").toLowerCase(); + const existingUser = data.users.find((item) => item.email.toLowerCase() === email); + const user = + existingUser ?? + { + id: uniqueId(data.users, "user", email), + authentikUserId: nullableString(payload?.authentikUserId), + name: optionalString(payload?.name, email.split("@")[0]), + email, + phone: nullableString(payload?.phone), + position: nullableString(payload?.position), + notes: nullableString(payload?.notes), + avatarUrl: nullableString(payload?.avatarUrl), + globalStatus: pickEnum(payload?.globalStatus, userStatuses, "active"), + createdAt: now, + updatedAt: now, + }; + + if (existingUser) { + user.name = optionalString(payload?.name, user.name); + user.phone = nullableStringWithFallback(payload?.phone, user.phone ?? null); + user.position = nullableStringWithFallback(payload?.position, user.position ?? null); + user.notes = nullableStringWithFallback(payload?.notes, user.notes ?? null); + user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null); + user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus); + user.updatedAt = now; + } else { + data.users.push(user); + } + + const existingMembership = data.memberships.find((membership) => membership.clientId === clientId && membership.userId === user.id); + + if (existingMembership) { + throw new Error(`User ${email} already belongs to client ${client.name}`); + } + + const membership = { + id: uniqueId(data.memberships, "mem", `${clientId}-${email}`), + clientId, + userId: user.id, + role: pickEnum(payload?.role, membershipRoles, "member"), + status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"), + createdAt: now, + updatedAt: now, + }; + + data.memberships.push(membership); + + if (Array.isArray(payload?.groupIds)) { + for (const group of data.groups) { + if (group.clientId !== clientId || !payload.groupIds.includes(group.id) || group.memberIds.includes(user.id)) { + continue; + } + + group.memberIds.push(user.id); + group.updatedAt = now; + } + } + + addAuditEvent(data, actor, { + action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь", + objectType: "user", + objectName: email, + clientId: client.id, + result: "success", + details: `Role: ${membership.role}; status: ${membership.status}`, + }); + markPendingSync(data, user, "user", email); + + await writeData(data); + return { user, membership, data }; + } + + async function updateMembership(membershipId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const membership = findById(data.memberships, membershipId, "membership"); + const user = findById(data.users, membership.userId, "user"); + + membership.role = pickEnum(payload?.role, membershipRoles, membership.role); + membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status); + membership.updatedAt = isoNow(); + + addAuditEvent(data, actor, { + action: "Обновлено членство", + objectType: "user", + objectName: user.email, + clientId: membership.clientId, + result: "success", + details: `Role: ${membership.role}; status: ${membership.status}`, + }); + markPendingSync(data, user, "user"); + + await writeData(data); + return { membership, data }; + } + + async function deleteMembership(membershipId, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const membership = findById(data.memberships, membershipId, "membership"); + const user = findById(data.users, membership.userId, "user"); + + data.memberships = data.memberships.filter((item) => item.id !== membershipId); + data.groups = data.groups.map((group) => + group.clientId === membership.clientId + ? { + ...group, + memberIds: group.memberIds.filter((userId) => userId !== membership.userId), + updatedAt: isoNow(), + } + : group + ); + + addAuditEvent(data, actor, { + action: "Удалено членство", + objectType: "user", + objectName: user.email, + clientId: membership.clientId, + result: "warning", + }); + markPendingSync(data, user, "user"); + + await writeData(data); + return { membership, data }; + } + + async function createInvite(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const clientId = requireString(payload?.clientId, "clientId"); + const client = findById(data.clients, clientId, "client"); + const email = requireString(payload?.email, "email").toLowerCase(); + const role = pickEnum(payload?.role, membershipRoles, "member"); + const expiresAt = optionalString(payload?.expiresAt, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()); + const invite = { + id: uniqueId(data.invites, "invite", email), + clientId, + email, + role, + invitedByUserId: actor.id, + token: randomUUID(), + expiresAt, + status: "created", + createdAt: now, + updatedAt: now, + }; + + data.invites.push(invite); + addAuditEvent(data, actor, { + action: "Создан инвайт", + objectType: "invite", + objectName: email, + clientId: client.id, + result: "success", + details: `Роль: ${role}`, + }); + markPendingSync(data, invite, "invite", email); + + await writeData(data); + return { invite, data }; + } + + async function updateInvite(inviteId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const invite = findById(data.invites, inviteId, "invite"); + + invite.email = optionalString(payload?.email, invite.email).toLowerCase(); + invite.role = pickEnum(payload?.role, membershipRoles, invite.role); + invite.expiresAt = optionalString(payload?.expiresAt, invite.expiresAt); + invite.status = pickEnum(payload?.status, new Set(["created", "sent", "accepted", "expired", "revoked"]), invite.status); + invite.updatedAt = isoNow(); + + addAuditEvent(data, actor, { + action: "Обновлён инвайт", + objectType: "invite", + objectName: invite.email, + clientId: invite.clientId, + result: "success", + }); + markPendingSync(data, invite, "invite", invite.email); + + await writeData(data); + return { invite, data }; + } + + async function deleteInvite(inviteId, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const invite = findById(data.invites, inviteId, "invite"); + + data.invites = data.invites.filter((item) => item.id !== inviteId); + data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== inviteId); + addAuditEvent(data, actor, { + action: "Удалён инвайт", + objectType: "invite", + objectName: invite.email, + clientId: invite.clientId, + result: "warning", + }); + + await writeData(data); + return { invite, data }; + } + + async function createGroup(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const clientId = requireString(payload?.clientId, "clientId"); + const client = findById(data.clients, clientId, "client"); + const groupName = optionalString(payload?.name, "Новая группа"); + const group = { + id: uniqueId(data.groups, "group", `${clientId}-${groupName}`), + clientId, + name: groupName, + description: nullableString(payload?.description), + memberIds: Array.isArray(payload?.memberIds) ? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId)) : [], + createdAt: now, + updatedAt: now, + }; + + data.groups.push(group); + addAuditEvent(data, actor, { + action: "Создана группа", + objectType: "group", + objectName: group.name, + clientId: client.id, + result: "success", + }); + markPendingSync(data, group, "group"); + + await writeData(data); + return { group, data }; + } + + async function updateGroup(groupId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const group = findById(data.groups, groupId, "group"); + + group.name = optionalString(payload?.name, group.name); + group.description = nullableStringWithFallback(payload?.description, group.description ?? null); + group.memberIds = Array.isArray(payload?.memberIds) + ? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId)) + : group.memberIds; + group.updatedAt = isoNow(); + + addAuditEvent(data, actor, { + action: "Обновлена группа", + objectType: "group", + objectName: group.name, + clientId: group.clientId, + result: "success", + }); + markPendingSync(data, group, "group"); + + await writeData(data); + return { group, data }; + } + + async function deleteGroup(groupId, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const group = findById(data.groups, groupId, "group"); + + data.groups = data.groups.filter((item) => item.id !== groupId); + data.grants = data.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId)); + data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== groupId); + addAuditEvent(data, actor, { + action: "Удалена группа", + objectType: "group", + objectName: group.name, + clientId: group.clientId, + result: "warning", + }); + + await writeData(data); + return { group, data }; + } + + async function upsertGrant(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const serviceId = requireString(payload?.serviceId, "serviceId"); + const targetType = pickEnum(payload?.targetType, grantTargetTypes, "client"); + const targetId = requireString(payload?.targetId, "targetId"); + const service = findById(data.services, serviceId, "service"); + + assertGrantTargetExists(data, targetType, targetId); + + const existingGrant = data.grants.find( + (grant) => grant.serviceId === serviceId && grant.targetType === targetType && grant.targetId === targetId + ); + const grant = + existingGrant ?? + { + id: uniqueId(data.grants, "grant", `${service.slug}-${targetType}-${targetId}`), + serviceId, + targetType, + targetId, + createdAt: now, + }; + + grant.appRole = pickEnum(payload?.appRole, appRoles, existingGrant?.appRole ?? "member"); + grant.status = pickEnum(payload?.status, grantStatuses, existingGrant?.status ?? "active"); + grant.updatedAt = now; + + if (!existingGrant) { + data.grants.push(grant); + } + + addAuditEvent(data, actor, { + action: existingGrant ? "Обновлён доступ" : "Создан доступ", + objectType: "grant", + objectName: `${service.slug}:${targetType}:${targetId}`, + result: "success", + details: `App role: ${grant.appRole}; status: ${grant.status}`, + }); + markPendingSync(data, grant, "grant", `${service.slug}:${targetType}:${targetId}`); + + await writeData(data); + return { grant, data }; + } + + async function upsertException(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const serviceId = requireString(payload?.serviceId, "serviceId"); + const userId = requireString(payload?.userId, "userId"); + const type = pickEnum(payload?.type, exceptionTypes, "deny"); + const service = findById(data.services, serviceId, "service"); + const user = findById(data.users, userId, "user"); + + const existingException = data.exceptions.find( + (exception) => exception.serviceId === serviceId && exception.userId === userId + ); + const exception = + existingException ?? + { + id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`), + serviceId, + userId, + createdAt: now, + }; + + exception.type = type; + exception.reason = nullableString(payload?.reason); + exception.updatedAt = now; + + if (!existingException) { + data.exceptions.push(exception); + } + + addAuditEvent(data, actor, { + action: existingException ? "Обновлено исключение доступа" : "Создано исключение доступа", + objectType: "exception", + objectName: `${service.slug}:${user.email}`, + result: "success", + details: `Type: ${type}`, + }); + markPendingSync(data, exception, "grant", `${service.slug}:${user.email}`); + + await writeData(data); + return { exception, data }; + } + + async function setUserServiceAccess(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const userId = requireString(payload?.userId, "userId"); + const serviceId = requireString(payload?.serviceId, "serviceId"); + const value = requireString(payload?.value, "value"); + const user = findById(data.users, userId, "user"); + const service = findById(data.services, serviceId, "service"); + + const directGrant = data.grants.find( + (grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId + ); + data.grants = data.grants.filter( + (grant) => !(grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId) + ); + data.exceptions = data.exceptions.filter((exception) => !(exception.serviceId === serviceId && exception.userId === userId)); + + if (value === "deny") { + data.exceptions.push({ + id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`), + serviceId, + userId, + type: "deny", + reason: "Создано из матрицы доступа.", + createdAt: now, + updatedAt: now, + }); + } else if (appRoles.has(value) && value !== "owner") { + data.grants.push({ + id: directGrant?.id ?? uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`), + serviceId, + targetType: "user", + targetId: userId, + appRole: value, + status: "active", + createdAt: directGrant?.createdAt ?? now, + updatedAt: now, + }); + } else if (value !== "unset") { + throw new Error(`Unsupported access value: ${value}`); + } + + addAuditEvent(data, actor, { + action: "Обновлён доступ пользователя к сервису", + objectType: "grant", + objectName: `${user.email} / ${service.slug}`, + result: "success", + details: `Value: ${value}`, + }); + markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`); + + await writeData(data); + return { data }; + } + + async function updateService(serviceId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const service = findById(data.services, serviceId, "service"); + + Object.assign(service, sanitizeServicePatch(payload, service)); + Object.assign(service, syncServiceLaunchLink(service)); + service.updatedAt = isoNow(); + + addAuditEvent(data, actor, { + action: "Обновлён сервис", + objectType: "service", + objectName: service.title, + result: "success", + }); + markPendingSync(data, service, "service"); + + await writeData(data); + return { service, data }; + } + + async function reorderServices(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const orderedServiceIds = Array.isArray(payload?.orderedServiceIds) ? payload.orderedServiceIds : []; + const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10])); + const now = isoNow(); + + data.services = data.services.map((service) => ({ + ...service, + order: orderById.get(service.id) ?? service.order, + updatedAt: orderById.has(service.id) ? now : service.updatedAt, + })); + + addAuditEvent(data, actor, { + action: "Изменён порядок сервисов", + objectType: "service", + objectName: "Каталог сервисов", + result: "success", + }); + + await writeData(data); + return { data }; + } + + async function createService(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const nextIndex = data.services.length + 1; + const nextOrder = Math.max(0, ...data.services.map((service) => Number(service.order) || 0)) + 10; + const title = optionalString(payload?.title, "New Service"); + const service = syncServiceLaunchLink({ + id: uniqueId(data.services, "service", title), + slug: optionalString(payload?.slug, `new-service-${nextIndex}`), + title, + subtitle: nullableString(payload?.subtitle) ?? "Новый сервис", + description: optionalString(payload?.description, "Описание сервиса для витрины."), + fullDescription: nullableString(payload?.fullDescription) ?? "Заполните описание, медиа и ссылку запуска в редакторе контента.", + url: optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"), + launchUrl: nullableString(payload?.launchUrl) ?? optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"), + accentColor: nullableString(payload?.accentColor) ?? "#F7F8F4", + fallbackGradient: + nullableString(payload?.fallbackGradient) ?? + "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)", + coverMediaSource: nullableString(payload?.coverMediaSource) ?? "url", + coverMediaKind: nullableString(payload?.coverMediaKind) ?? "image", + ambientMediaSource: nullableString(payload?.ambientMediaSource) ?? "url", + ambientMediaKind: nullableString(payload?.ambientMediaKind) ?? "gif", + status: pickEnum(payload?.status, serviceStatuses, "hidden"), + order: nextOrder, + authentikApplicationSlug: nullableString(payload?.authentikApplicationSlug) ?? `new-service-${nextIndex}`, + authentikGroupName: nullableString(payload?.authentikGroupName) ?? `service-new-${nextIndex}`, + createdAt: now, + updatedAt: now, + }); + + data.services.push(service); + addAuditEvent(data, actor, { + action: "Создан сервис", + objectType: "service", + objectName: service.title, + result: "success", + }); + markPendingSync(data, service, "service"); + + await writeData(data); + return { service, data }; + } + + async function deleteService(serviceId, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const service = findById(data.services, serviceId, "service"); + + data.services = data.services.filter((item) => item.id !== serviceId); + data.grants = data.grants.filter((grant) => grant.serviceId !== serviceId); + data.exceptions = data.exceptions.filter((exception) => exception.serviceId !== serviceId); + data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== serviceId); + addAuditEvent(data, actor, { + action: "Удалён сервис", + objectType: "service", + objectName: service.title, + result: "warning", + }); + + await writeData(data); + return { service, data }; + } + + async function retrySync(syncId, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const syncStatus = findById(data.syncStatuses, syncId, "sync status"); + + syncStatus.state = "pending"; + syncStatus.error = null; + syncStatus.updatedAt = isoNow(); + addAuditEvent(data, actor, { + action: "Повтор sync", + objectType: syncStatus.objectType, + objectName: syncStatus.objectName, + result: "success", + details: `Target: ${syncStatus.target}`, + }); + + await writeData(data); + return { syncStatus, data }; + } + + async function markUserAuthentikProvisioned(userId, provisioning, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const user = findById(data.users, userId, "user"); + const now = isoNow(); + + user.authentikUserId = provisioning.authentikUserId ?? user.authentikUserId ?? null; + user.email = optionalString(provisioning.email, user.email).toLowerCase(); + user.name = optionalString(provisioning.name, user.name); + user.updatedAt = now; + + const syncStatus = data.syncStatuses.find( + (status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id + ); + const objectName = user.email; + + if (syncStatus) { + syncStatus.objectName = objectName; + syncStatus.state = "synced"; + syncStatus.lastSyncAt = now; + syncStatus.error = null; + syncStatus.updatedAt = now; + } else { + data.syncStatuses.push({ + id: uniqueId(data.syncStatuses, "sync", `user-${user.id}`), + objectId: user.id, + objectName, + objectType: "user", + target: "authentik", + state: "synced", + lastSyncAt: now, + error: null, + updatedAt: now, + }); + } + + addAuditEvent(data, actor, { + action: "Пользователь синхронизирован в Authentik", + objectType: "user", + objectName, + result: "success", + details: `Groups: ${(provisioning.groups ?? []).join(", ") || "none"}`, + }); + + await writeData(data); + return { user, data }; + } + + function buildAuthentikSyncPlan() { + const data = readData(); + + return { + mode: "dry-run", + source: "launcher-control-plane", + target: "authentik", + users: data.users.map((user) => ({ + id: user.id, + authentikUserId: user.authentikUserId ?? null, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl ?? null, + active: user.globalStatus === "active", + })), + groups: [ + "nodedc:superadmin", + "nodedc:launcher:admin", + "nodedc:launcher:user", + ...data.services.flatMap((service) => (service.authentikGroupName ? [service.authentikGroupName] : [])), + ...data.groups.map((group) => `client:${group.clientId}:group:${slugify(group.name)}`), + ], + accessProjection: { + services: data.services.length, + grants: data.grants.filter((grant) => grant.status === "active").length, + exceptions: data.exceptions.length, + pendingSyncObjects: data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik" && syncStatus.state === "pending").length, + }, + }; + } + + function getWritableStorageRoots() { + const roots = [publicStorageRoot]; + + if (existsSync(join(projectRoot, "dist"))) { + roots.push(distStorageRoot); + } + + return roots; + } + + return { + buildAuthentikSyncPlan, + createClient, + createGroup, + createInvite, + createService, + createUser, + deleteClient, + deleteGroup, + deleteInvite, + deleteMembership, + deleteService, + getSnapshot, + readData, + replaceData, + reorderServices, + retrySync, + markUserAuthentikProvisioned, + setUserServiceAccess, + updateClient, + updateGroup, + updateInvite, + updateMembership, + updateService, + updateUserProfile, + upsertException, + upsertGrant, + writeData, + }; +} + +function normalizeData(payload) { + const data = typeof payload === "object" && payload !== null ? { ...payload } : {}; + + for (const key of collectionKeys) { + if (!Array.isArray(data[key])) { + data[key] = []; + } + } + + return data; +} + +function resolveActor(data, identity) { + const user = data.users.find( + (item) => + (identity?.sub && item.authentikUserId === identity.sub) || + (identity?.email && item.email.toLowerCase() === identity.email.toLowerCase()) + ); + + if (user) { + return { + id: user.id, + name: user.name, + email: user.email, + source: "launcher", + }; + } + + return { + id: identity?.sub ? `oidc:${identity.sub}` : "system", + name: identity?.name || identity?.email || "System", + email: identity?.email || null, + source: "oidc", + }; +} + +function addAuditEvent(data, actor, event) { + data.auditEvents.push({ + id: uniqueId(data.auditEvents, "audit", event.objectName ?? event.objectType ?? "event"), + at: isoNow(), + actorUserId: actor.id, + actorName: actor.name, + action: event.action, + objectType: event.objectType, + objectName: event.objectName, + clientId: event.clientId ?? null, + result: event.result ?? "success", + details: event.details ?? null, + }); +} + +function markPendingSync(data, object, objectType, objectName = object.name ?? object.email ?? object.id) { + const now = isoNow(); + const existingStatus = data.syncStatuses.find( + (status) => status.target === "authentik" && status.objectType === objectType && status.objectId === object.id + ); + + if (existingStatus) { + existingStatus.objectName = objectName; + existingStatus.state = "pending"; + existingStatus.error = null; + existingStatus.updatedAt = now; + return; + } + + data.syncStatuses.push({ + id: uniqueId(data.syncStatuses, "sync", `${objectType}-${object.id}`), + objectId: object.id, + objectName, + objectType, + target: "authentik", + state: "pending", + lastSyncAt: null, + error: null, + updatedAt: now, + }); +} + +async function writeJsonAtomically(filePath, payload) { + const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + + await writeFile(tempPath, payload, "utf8"); + await rename(tempPath, filePath); +} + +function assertGrantTargetExists(data, targetType, targetId) { + if (targetType === "client") { + findById(data.clients, targetId, "client"); + } else if (targetType === "group") { + findById(data.groups, targetId, "group"); + } else { + findById(data.users, targetId, "user"); + } +} + +function findById(items, id, label) { + const item = items.find((candidate) => candidate.id === id); + + if (!item) { + throw new Error(`Unknown ${label}: ${id}`); + } + + return item; +} + +function requireString(value, fieldName) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Field ${fieldName} is required`); + } + + return value.trim(); +} + +function optionalString(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +function nullableString(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function nullableStringWithFallback(value, fallback) { + return value === undefined ? fallback : nullableString(value); +} + +function pickEnum(value, allowedValues, fallback) { + return typeof value === "string" && allowedValues.has(value) ? value : fallback; +} + +function uniqueId(items, prefix, seed) { + const base = `${prefix}_${slugify(seed)}`; + let candidate = base; + let counter = 1; + const ids = new Set(items.map((item) => item.id)); + + while (ids.has(candidate)) { + counter += 1; + candidate = `${base}_${counter}`; + } + + return candidate; +} + +function sanitizeServicePatch(payload, service) { + const patch = {}; + const stringFields = [ + "slug", + "title", + "subtitle", + "description", + "fullDescription", + "url", + "launchUrl", + "iconUrl", + "coverImageUrl", + "coverMediaKind", + "coverMediaSource", + "coverMediaFileName", + "previewVideoUrl", + "ambientVideoUrl", + "ambientMediaKind", + "ambientMediaSource", + "ambientMediaFileName", + "accentColor", + "fallbackGradient", + "authentikApplicationSlug", + "authentikGroupName", + ]; + + for (const field of stringFields) { + if (field in (payload ?? {})) { + patch[field] = nullableStringWithFallback(payload[field], service[field] ?? null); + } + } + + if (typeof payload?.order === "number") { + patch.order = payload.order; + } + + if ("isAvailableForAllNewClients" in (payload ?? {})) { + patch.isAvailableForAllNewClients = Boolean(payload.isAvailableForAllNewClients); + } + + patch.status = pickEnum(payload?.status, serviceStatuses, service.status); + return patch; +} + +function syncServiceLaunchLink(service) { + const launchLink = String(service.launchUrl || service.url || "").trim(); + + return { + ...service, + url: launchLink, + launchUrl: launchLink || null, + }; +} + +function slugify(value) { + const slug = + String(value) + .normalize("NFKD") + .replace(/[^\w]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase() + .slice(0, 80) || randomUUID().slice(0, 8); + + return slug; +} + +function isoNow() { + return new Date().toISOString(); +} diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 1acaee5..c507505 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -7,6 +7,8 @@ import { dirname, extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createServer as createViteServer } from "vite"; import { createRemoteJWKSet, jwtVerify } from "jose"; +import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs"; +import { createControlPlaneStore } from "./control-plane-store.mjs"; const serverRoot = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(serverRoot, ".."); @@ -25,8 +27,11 @@ loadEnvFiles([ const config = readConfig(); const app = express(); const httpServer = createHttpServer(app); +const controlPlaneStore = createControlPlaneStore({ projectRoot }); +const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken }); const pendingLogins = new Map(); const sessions = new Map(); +const runtimeEventClients = new Set(); let discoveryCache = null; let jwksCache = null; @@ -34,7 +39,12 @@ app.disable("x-powered-by"); app.use(express.json({ limit: maxStorageJsonBodyBytes })); app.get("/healthz", (_req, res) => { - res.json({ ok: true, service: "nodedc-launcher-bff", oidcConfigured: config.oidcConfigured }); + res.json({ + ok: true, + service: "nodedc-launcher-bff", + oidcConfigured: config.oidcConfigured, + authentikApiConfigured: authentikSyncClient.isConfigured(), + }); }); app.get("/auth/login", asyncRoute(async (req, res) => { @@ -165,11 +175,13 @@ app.get("/api/me", (req, res) => { return; } + const runtimeContext = getRuntimeSessionContext(session); + res.json({ authenticated: true, - user: session.user, - groups: session.user.groups, - isSuperAdmin: session.user.groups.includes("nodedc:superadmin"), + user: runtimeContext.user, + groups: runtimeContext.groups, + isSuperAdmin: runtimeContext.groups.includes("nodedc:superadmin"), logoutUrl: "/auth/logout", }); }); @@ -182,7 +194,257 @@ app.get("/api/apps", (req, res) => { return; } - res.json({ apps: getAppsForUser(session.user.groups) }); + res.json({ apps: getAppsForSession(session) }); +}); + +app.get("/api/profile", requireSession, (req, res) => { + const { actor, data } = getLauncherProfileContext(req.nodedcSession); + const user = findLauncherUser(data, actor.id); + + res.json({ + user, + memberships: data.memberships.filter((membership) => membership.userId === user.id), + }); +}); + +app.get("/api/events", requireSession, (req, res) => { + const client = { + id: randomUUID(), + res, + }; + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders?.(); + res.write(`event: nodedc-ready\ndata: ${JSON.stringify({ ok: true })}\n\n`); + + const keepAlive = setInterval(() => { + res.write(": keep-alive\n\n"); + }, 30000); + + runtimeEventClients.add(client); + + req.on("close", () => { + clearInterval(keepAlive); + runtimeEventClients.delete(client); + }); +}); + +app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => { + const { actor } = getLauncherProfileContext(req.nodedcSession); + const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user); + const provisionedUser = await authentikSyncClient.provisionUser({ + data: result.data, + userId: actor.id, + }); + const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user); + + publishControlPlaneEvent("profile.updated", [actor.id]); + res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser) }); +})); + +app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => { + const newPassword = sanitizeNewPassword(req.body?.newPassword); + const { actor, data } = getLauncherProfileContext(req.nodedcSession); + const provisionedUser = await authentikSyncClient.provisionUser({ + data, + userId: actor.id, + password: newPassword, + }); + const result = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user); + + publishControlPlaneEvent("profile.password.updated", [actor.id]); + res.json({ data: result.data, ok: true }); +})); + +app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => { + res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user)); +}); + +app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + res.json({ clients: snapshot.data.clients }); +}); + +app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user); + res.status(201).json(result); +})); + +app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.updateClient(req.params.clientId, req.body, req.nodedcSession.user); + res.json(result); +})); + +app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.deleteClient(req.params.clientId, req.nodedcSession.user); + res.json(result); +})); + +app.get("/api/admin/users", requireLauncherAdmin, (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + res.json({ users: snapshot.data.users, memberships: snapshot.data.memberships }); +}); + +app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.createUser(req.body, req.nodedcSession.user); + let provisioning = null; + + if (req.body?.provisionAuth !== false) { + const provisionedUser = await authentikSyncClient.provisionUser({ + data: result.data, + userId: result.user.id, + password: sanitizePassword(req.body?.password), + generatePassword: req.body?.generatePassword !== false, + }); + const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisionedUser, req.nodedcSession.user); + result.data = storeResult.data; + provisioning = toProvisioningResponse(provisionedUser); + } + + publishControlPlaneEvent("admin.user.created", [result.user.id]); + res.status(201).json({ ...result, provisioning }); +})); + +app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user); + publishControlPlaneEvent("admin.user.updated", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const provisionedUser = await authentikSyncClient.provisionUser({ + data: snapshot.data, + userId: req.params.userId, + password: sanitizePassword(req.body?.password), + generatePassword: req.body?.generatePassword === true, + }); + const result = await controlPlaneStore.markUserAuthentikProvisioned(req.params.userId, provisionedUser, req.nodedcSession.user); + + publishControlPlaneEvent("admin.user.provisioned", [req.params.userId]); + res.json({ ...result, provisioning: toProvisioningResponse(provisionedUser) }); +})); + +app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user); + publishControlPlaneEvent("admin.membership.updated", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.deleteMembership(req.params.membershipId, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user); + publishControlPlaneEvent("admin.membership.deleted", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.post("/api/admin/invites", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.createInvite(req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.invite.created"); + res.status(201).json(result); +})); + +app.patch("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.updateInvite(req.params.inviteId, req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.invite.updated"); + res.json(result); +})); + +app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.deleteInvite(req.params.inviteId, req.nodedcSession.user); + publishControlPlaneEvent("admin.invite.deleted"); + res.json(result); +})); + +app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.createGroup(req.body, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user); + publishControlPlaneEvent("admin.group.created", syncResult.userIds); + res.status(201).json({ ...result, data: syncResult.data }); +})); + +app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const previousMemberIds = beforeSnapshot.data.groups.find((group) => group.id === req.params.groupId)?.memberIds ?? []; + const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik( + result.data, + [...previousMemberIds, ...result.group.memberIds], + req.nodedcSession.user + ); + publishControlPlaneEvent("admin.group.updated", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.deleteGroup(req.params.groupId, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user); + publishControlPlaneEvent("admin.group.deleted", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.post("/api/admin/services", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.createService(req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.service.created"); + res.status(201).json(result); +})); + +app.patch("/api/admin/services/reorder", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.reorderServices(req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.service.reordered"); + res.json(result); +})); + +app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.updateService(req.params.serviceId, req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.service.updated"); + res.json(result); +})); + +app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.deleteService(req.params.serviceId, req.nodedcSession.user); + publishControlPlaneEvent("admin.service.deleted"); + res.json(result); +})); + +app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik( + result.data, + resolveGrantTargetUserIds(result.data, result.grant.targetType, result.grant.targetId), + req.nodedcSession.user + ); + publishControlPlaneEvent("admin.access.grant.updated", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.upsertException(req.body, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik(result.data, [result.exception.userId], req.nodedcSession.user); + publishControlPlaneEvent("admin.access.exception.updated", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user); + const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user); + publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + +app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, asyncRoute(async (req, res) => { + const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user); + publishControlPlaneEvent("admin.sync.retry"); + res.json(result); +})); + +app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, (_req, res) => { + res.json(controlPlaneStore.buildAuthentikSyncPlan()); }); app.post("/api/storage/upload", asyncRoute(async (req, res) => { @@ -190,8 +452,9 @@ app.post("/api/storage/upload", asyncRoute(async (req, res) => { res.json(result); })); -app.post("/api/storage/data", asyncRoute(async (req, res) => { +app.post("/api/storage/data", requireLauncherAdmin, asyncRoute(async (req, res) => { await saveLauncherData(req.body); + publishControlPlaneEvent("storage.data.updated"); res.json({ ok: true, url: "/storage/launcher-data.json" }); })); @@ -234,6 +497,15 @@ function readConfig() { cookieDomain: process.env.LAUNCHER_COOKIE_DOMAIN || undefined, cookieSecure: process.env.COOKIE_SECURE === "true", oidcConfigured: Boolean(issuer && clientId && clientSecret), + authentikBaseUrl: + process.env.NODEDC_AUTHENTIK_BASE_URL ?? + process.env.AUTHENTIK_BASE_URL ?? + (process.env.AUTH_DOMAIN ? `http://${process.env.AUTH_DOMAIN}` : ""), + authentikApiToken: + process.env.NODEDC_AUTHENTIK_SERVICE_TOKEN ?? + process.env.AUTHENTIK_SERVICE_TOKEN ?? + process.env.AUTHENTIK_BOOTSTRAP_TOKEN ?? + "", }; } @@ -314,6 +586,7 @@ async function verifyIdToken(discovery, idToken, nonce) { function normalizeUser(claims) { const groups = normalizeGroups(claims.groups); const email = typeof claims.email === "string" ? claims.email : ""; + const avatarUrl = firstStringClaim(claims.picture, claims.avatar_url, claims.avatar); const name = typeof claims.name === "string" && claims.name ? claims.name @@ -326,10 +599,106 @@ function normalizeUser(claims) { email, name, preferredUsername: typeof claims.preferred_username === "string" ? claims.preferred_username : null, + avatarUrl, groups, }; } +function firstStringClaim(...values) { + for (const value of values) { + if (typeof value === "string" && value) return value; + } + + return null; +} + +function sanitizePassword(value) { + return typeof value === "string" && value.length >= 8 ? value : null; +} + +function sanitizeNewPassword(value) { + if (typeof value !== "string" || value.length < 8) { + throw new Error("Новый пароль должен быть не короче 8 символов"); + } + + return value; +} + +function sanitizeSelfProfilePatch(payload) { + return { + name: payload?.name, + email: payload?.email, + phone: payload?.phone, + position: payload?.position, + avatarUrl: payload?.avatarUrl, + }; +} + +function toProvisioningResponse(provisionedUser) { + return { + authentikUserId: provisionedUser.authentikUserId, + email: provisionedUser.email, + name: provisionedUser.name, + groups: provisionedUser.groups, + created: provisionedUser.created, + temporaryPassword: provisionedUser.temporaryPassword, + }; +} + +async function syncUsersToAuthentik(data, userIds, identity) { + let latestData = data; + const uniqueUserIds = [...new Set(userIds.filter((userId) => typeof userId === "string" && userId))]; + + for (const userId of uniqueUserIds) { + if (!latestData.users.some((user) => user.id === userId)) { + continue; + } + + const provisionedUser = await authentikSyncClient.provisionUser({ data: latestData, userId }); + const result = await controlPlaneStore.markUserAuthentikProvisioned(userId, provisionedUser, identity); + latestData = result.data; + } + + return { data: latestData, userIds: uniqueUserIds }; +} + +function resolveGrantTargetUserIds(data, targetType, targetId) { + if (targetType === "user") { + return [targetId]; + } + + if (targetType === "group") { + return data.groups.find((group) => group.id === targetId)?.memberIds ?? []; + } + + if (targetType === "client") { + return data.memberships.filter((membership) => membership.clientId === targetId).map((membership) => membership.userId); + } + + return []; +} + +function publishControlPlaneEvent(action, affectedUserIds = []) { + publishRuntimeEvent({ + type: "control-plane.updated", + action, + affectedUserIds: [...new Set(affectedUserIds.filter((userId) => typeof userId === "string" && userId))], + emittedAt: new Date().toISOString(), + }); +} + +function publishRuntimeEvent(payload) { + const message = `event: nodedc-runtime\ndata: ${JSON.stringify(payload)}\n\n`; + + for (const client of runtimeEventClients) { + try { + client.res.write(message); + } catch { + runtimeEventClients.delete(client); + } + } +} + function normalizeGroups(groupsClaim) { if (Array.isArray(groupsClaim)) { return [...new Set(groupsClaim.filter((group) => typeof group === "string"))]; @@ -342,6 +711,47 @@ function normalizeGroups(groupsClaim) { return []; } +function getRuntimeSessionContext(session) { + const fallback = { + user: session.user, + groups: session.user.groups, + }; + + try { + const snapshot = controlPlaneStore.getSnapshot(session.user); + + if (snapshot.actor.source !== "launcher") { + return fallback; + } + + const user = snapshot.data.users.find((candidate) => candidate.id === snapshot.actor.id); + + if (!user) { + return fallback; + } + + const groups = resolveRequiredGroups(snapshot.data, user); + + return { + groups, + user: { + ...session.user, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl ?? session.user.avatarUrl, + groups, + }, + }; + } catch (error) { + console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher"); + return fallback; + } +} + +function getAppsForSession(session) { + return getAppsForUser(getRuntimeSessionContext(session).groups); +} + function getAppsForUser(userGroups) { const groupSet = new Set(userGroups); const catalog = getAppCatalog(); @@ -407,7 +817,7 @@ function getAppCatalog() { } function specialRequiredGroups(slug) { - if (slug === "launcher" || slug === "nodedc") return ["nodedc:launcher:admin", "nodedc:launcher:user"]; + if (slug === "launcher") return ["nodedc:launcher:admin", "nodedc:launcher:user"]; if (slug === "task-manager") return ["nodedc:taskmanager:admin", "nodedc:taskmanager:user"]; return []; } @@ -465,12 +875,7 @@ async function saveUploadedFile(payload) { } async function saveLauncherData(payload) { - await Promise.all( - getWritableStorageRoots().map(async (storageRoot) => { - await mkdir(storageRoot, { recursive: true }); - await writeFile(join(storageRoot, "launcher-data.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8"); - }) - ); + await controlPlaneStore.writeData(payload); } function getWritableStorageRoots() { @@ -563,6 +968,65 @@ function parseCookies(cookieHeader) { ); } +function requireLauncherAdmin(req, res, next) { + const session = getCurrentSession(req); + + if (!session) { + res.status(401).json({ authenticated: false, loginUrl: "/auth/login" }); + return; + } + + const runtimeContext = getRuntimeSessionContext(session); + + if (!isLauncherAdmin(runtimeContext.groups)) { + res.status(403).json({ error: "Недостаточно прав Launcher admin" }); + return; + } + + req.nodedcSession = { ...session, user: runtimeContext.user }; + next(); +} + +function requireSession(req, res, next) { + const session = getCurrentSession(req); + + if (!session) { + res.status(401).json({ authenticated: false, loginUrl: "/auth/login" }); + return; + } + + const runtimeContext = getRuntimeSessionContext(session); + req.nodedcSession = { ...session, user: runtimeContext.user }; + next(); +} + +function getLauncherProfileContext(session) { + const snapshot = controlPlaneStore.getSnapshot(session.user); + + if (snapshot.actor.source !== "launcher") { + throw new Error("Профиль пользователя не найден в Launcher control-plane"); + } + + return { + actor: snapshot.actor, + data: snapshot.data, + }; +} + +function findLauncherUser(data, userId) { + const user = data.users.find((candidate) => candidate.id === userId); + + if (!user) { + throw new Error(`Unknown Launcher user: ${userId}`); + } + + return user; +} + +function isLauncherAdmin(groups) { + return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin"); +} + function cookieOptions(maxAgeMs) { const options = { httpOnly: true, diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 167f05a..57aec22 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -3,8 +3,30 @@ import type { Client } from "../entities/client/types"; import type { Invite } from "../entities/invite/types"; import { syncServiceLaunchLink } from "../entities/service/links"; import type { LauncherServiceView, Service } from "../entities/service/types"; -import type { SyncStatus } from "../entities/sync/types"; import type { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types"; +import { + createAdminClient, + createAdminGroup, + createAdminInvite, + createAdminService, + createAdminUser, + deleteAdminClient, + deleteAdminGroup, + deleteAdminInvite, + deleteAdminMembership, + deleteAdminService, + fetchControlPlaneSnapshot, + reorderAdminServices, + retryAdminSync, + setAdminUserServiceAccess, + updateAdminClient, + updateAdminGroup, + updateAdminInvite, + updateAdminMembership, + updateAdminService, + updateAdminUserProfile, + type ControlPlaneMutationResult, +} from "../shared/api/adminApi"; import { buildLauncherServices, buildMe, @@ -12,9 +34,22 @@ import { profileOptions, type LauncherData, } from "../shared/api/mockApi"; -import { fetchAuthSession, fetchAvailableApps, type AuthSession, type LauncherAuthApp } from "../shared/api/authApi"; -import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi"; -import { AdminOverlay, type SetUserServiceAccessCommand } from "../widgets/admin-overlay/AdminOverlay"; +import { + fetchAuthSession, + fetchAvailableApps, + type AuthenticatedSession, + type AuthSession, + type LauncherAuthApp, +} from "../shared/api/authApi"; +import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; +import { loadPersistedLauncherData } from "../shared/api/storageApi"; +import { + AdminOverlay, + type AccessAssignmentValue, + type CreateUserCommand, + type SetUserServiceAccessCommand, +} from "../widgets/admin-overlay/AdminOverlay"; +import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel"; import { ServiceRail } from "../widgets/service-rail/ServiceRail"; import { ServiceStage } from "../widgets/service-stage/ServiceStage"; import { TopBar } from "../widgets/top-bar/TopBar"; @@ -25,12 +60,14 @@ export function LauncherApp() { const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId); const [selectedServiceId, setSelectedServiceId] = useState(); const [adminOpen, setAdminOpen] = useState(false); - const [storageHydrated, setStorageHydrated] = useState(false); const [authSession, setAuthSession] = useState(null); const [authApps, setAuthApps] = useState(null); const [authError, setAuthError] = useState(null); + const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); + const [pendingAccessAssignments, setPendingAccessAssignments] = useState>({}); const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); + const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0]; const runtimeMe = useMemo(() => { if (!authSession?.authenticated) return me; @@ -39,14 +76,16 @@ export function LauncherApp() { user: { ...me.user, authentikUserId: authSession.user.sub, - email: authSession.user.email || me.user.email, - name: authSession.user.name || me.user.name, + email: me.user.email || authSession.user.email, + name: me.user.name || authSession.user.name, + avatarUrl: me.user.avatarUrl ?? authSession.user.avatarUrl, }, mockAuthentikClaims: { ...me.mockAuthentikClaims, sub: authSession.user.sub, email: authSession.user.email || me.mockAuthentikClaims.email, name: authSession.user.name || me.mockAuthentikClaims.name, + avatarUrl: authSession.user.avatarUrl ?? null, groups: authSession.groups, }, }; @@ -150,17 +189,16 @@ export function LauncherApp() { useEffect(() => { if (!authSession?.authenticated) return; - const nextProfileId = authSession.isSuperAdmin ? "user_root" : "user_vasya"; - const nextProfile = profileOptions.find((profile) => profile.userId === nextProfileId); + const nextContext = resolveAuthenticatedContext(data, authSession, activeProfileId, activeClientId); - if (activeProfileId !== nextProfileId) { - setActiveProfileId(nextProfileId); + if (activeProfileId !== nextContext.profileId) { + setActiveProfileId(nextContext.profileId); } - if (nextProfile && activeClientId !== nextProfile.defaultClientId) { - setActiveClientId(nextProfile.defaultClientId); + if (activeClientId !== nextContext.clientId) { + setActiveClientId(nextContext.clientId); } - }, [activeClientId, activeProfileId, authSession]); + }, [activeClientId, activeProfileId, authSession, data]); useEffect(() => { let isMounted = true; @@ -170,11 +208,6 @@ export function LauncherApp() { if (isMounted && persistedData) { setData(syncLauncherServiceLinks(persistedData)); } - }) - .finally(() => { - if (isMounted) { - setStorageHydrated(true); - } }); return () => { @@ -183,16 +216,78 @@ export function LauncherApp() { }, []); useEffect(() => { - if (!storageHydrated) return; + if (!authSession?.authenticated || !canUseAdminApi(authSession)) return; - const saveTimer = window.setTimeout(() => { - persistLauncherData(data).catch((error: unknown) => { - console.warn(error instanceof Error ? error.message : "Не удалось сохранить состояние витрины"); + let isMounted = true; + + fetchControlPlaneSnapshot() + .then((snapshot) => { + if (isMounted) { + setData(syncLauncherServiceLinks(snapshot.data)); + } + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : "Не удалось загрузить control-plane snapshot"); }); - }, 350); - return () => window.clearTimeout(saveTimer); - }, [data, storageHydrated]); + return () => { + isMounted = false; + }; + }, [authSession]); + + useEffect(() => { + if (!authSession?.authenticated) return; + + let isMounted = true; + + const refreshRuntimeState = async () => { + try { + const nextSession = await fetchAuthSession(); + + if (!isMounted) return; + + setAuthSession(nextSession); + setAuthError(null); + + if (!nextSession.authenticated) { + setAuthApps([]); + return; + } + + const [persistedData, apps] = await Promise.all([ + canUseAdminApi(nextSession) + ? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data) + : loadPersistedLauncherData(), + fetchAvailableApps(), + ]); + + if (!isMounted) return; + + if (persistedData) { + setData(syncLauncherServiceLinks(persistedData)); + } + + setAuthApps(apps); + } catch (error: unknown) { + console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher"); + } + }; + + const eventSource = new EventSource("/api/events"); + + eventSource.addEventListener("nodedc-runtime", () => { + void refreshRuntimeState(); + }); + + eventSource.onerror = () => { + console.warn("Launcher event stream disconnected; browser will retry automatically"); + }; + + return () => { + isMounted = false; + eventSource.close(); + }; + }, [authSession?.authenticated]); function handleProfileChange(userId: string) { const profile = profileOptions.find((option) => option.userId === userId); @@ -227,200 +322,84 @@ export function LauncherApp() { }); } + function applyControlPlaneMutation(request: Promise) { + request + .then((result) => { + setData(syncLauncherServiceLinks(result.data)); + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию"); + }); + } + function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) { - setData((current) => { - const now = new Date().toISOString(); - const directGrant = current.grants.find( - (grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId - ); - const grantsWithoutDirect = current.grants.filter( - (grant) => !(grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId) - ); - const exceptionsWithoutDirect = current.exceptions.filter( - (exception) => !(exception.serviceId === serviceId && exception.userId === userId) - ); + const assignmentKey = accessAssignmentKey(userId, serviceId); - if (value === "unset") { - return { - ...current, - grants: grantsWithoutDirect, - exceptions: exceptionsWithoutDirect, - }; - } + if (pendingAccessAssignments[assignmentKey]) { + return; + } - if (value === "deny") { - return { - ...current, - grants: grantsWithoutDirect, - exceptions: [ - ...exceptionsWithoutDirect, - { - id: `exception_mock_${Date.now()}`, - serviceId, - userId, - type: "deny", - reason: "Создано из матрицы доступа.", - createdAt: now, - updatedAt: now, - }, - ], - }; - } - - return { - ...current, - grants: [ - ...grantsWithoutDirect, - { - id: directGrant?.id ?? `grant_mock_${Date.now()}`, - serviceId, - targetType: "user", - targetId: userId, - appRole: value, - status: "active", - createdAt: directGrant?.createdAt ?? now, - updatedAt: now, - }, - ], - exceptions: exceptionsWithoutDirect, - }; - }); + setPendingAccessAssignments((current) => ({ ...current, [assignmentKey]: value })); + setAdminUserServiceAccess({ userId, serviceId, value }) + .then((result) => { + setData(syncLauncherServiceLinks(result.data)); + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию"); + }) + .finally(() => { + setPendingAccessAssignments((current) => { + const { [assignmentKey]: _completed, ...rest } = current; + return rest; + }); + }); } function handleCreateInvite(invite: Pick) { - setData((current) => ({ - ...current, - invites: [ - { - ...invite, - id: `invite_mock_${Date.now()}`, - invitedByUserId: runtimeMe.user.id, - token: `mock-${Date.now()}`, - expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), - status: "created", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ...current.invites, - ], - })); + applyControlPlaneMutation(createAdminInvite(invite)); } function handleUpdateInvite(inviteId: string, patch: Partial) { - setData((current) => ({ - ...current, - invites: current.invites.map((invite) => - invite.id === inviteId - ? { - ...invite, - ...patch, - updatedAt: new Date().toISOString(), - } - : invite - ), - })); + applyControlPlaneMutation(updateAdminInvite(inviteId, patch)); } function handleDeleteInvite(inviteId: string) { - setData((current) => ({ - ...current, - invites: current.invites.filter((invite) => invite.id !== inviteId), - })); + applyControlPlaneMutation(deleteAdminInvite(inviteId)); } function handleRetrySync(syncId: string) { - setData((current) => ({ - ...current, - syncStatuses: current.syncStatuses.map((sync): SyncStatus => - sync.id === syncId - ? { - ...sync, - state: "pending", - error: null, - updatedAt: new Date().toISOString(), - } - : sync - ), - })); + applyControlPlaneMutation(retryAdminSync(syncId)); } function handleUpdateService(serviceId: string, patch: Partial) { - setData((current) => ({ - ...current, - services: current.services.map((service) => - service.id === serviceId - ? syncServiceLaunchLink({ - ...service, - ...patch, - updatedAt: new Date().toISOString(), - }) - : service - ), - })); + applyControlPlaneMutation(updateAdminService(serviceId, patch)); } function handleCreateClient() { - const createdAt = new Date().toISOString(); const index = data.clients.length + 1; - setData((current) => ({ - ...current, - clients: [ - ...current.clients, - { - id: `client_mock_${Date.now()}`, - type: "company", - name: `Новый клиент ${index}`, - legalName: `Новый клиент ${index}`, - status: "demo", - demoEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), - contactName: "", - contactEmail: "", - notes: "", - createdAt, - updatedAt: createdAt, - }, - ], - })); + applyControlPlaneMutation( + createAdminClient({ + type: "company", + name: `Новый клиент ${index}`, + legalName: `Новый клиент ${index}`, + status: "demo", + demoEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), + contactName: "", + contactEmail: "", + notes: "", + }) + ); } function handleUpdateClient(clientId: string, patch: Partial) { - setData((current) => ({ - ...current, - clients: current.clients.map((client) => - client.id === clientId - ? { - ...client, - ...patch, - updatedAt: new Date().toISOString(), - } - : client - ), - })); + applyControlPlaneMutation(updateAdminClient(clientId, patch)); } 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), - }; - }); + applyControlPlaneMutation(deleteAdminClient(clientId)); if (activeClientId === clientId) { setActiveClientId(nextClientId); @@ -428,166 +407,63 @@ export function LauncherApp() { } function handleUpdateUser(userId: string, patch: Partial) { - setData((current) => ({ - ...current, - users: current.users.map((user) => - user.id === userId - ? { - ...user, - ...patch, - updatedAt: new Date().toISOString(), - } - : user - ), - })); + applyControlPlaneMutation(updateAdminUserProfile(userId, patch)); + } + + async function handleUpdateOwnProfile(patch: Partial) { + const result = await updateOwnProfile(patch); + setData(syncLauncherServiceLinks(result.data)); + } + + async function handleUpdateOwnPassword(newPassword: string) { + const result = await updateOwnPassword(newPassword); + setData(syncLauncherServiceLinks(result.data)); + } + + function handleCreateUser(command: CreateUserCommand) { + createAdminUser(command) + .then((result) => { + setData(syncLauncherServiceLinks(result.data)); + + if (result.provisioning?.temporaryPassword) { + window.alert(`Пользователь создан. Временный пароль: ${result.provisioning.temporaryPassword}`); + } + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : "Не удалось создать пользователя"); + }); } function handleUpdateMembership(membershipId: string, patch: Partial) { - setData((current) => ({ - ...current, - memberships: current.memberships.map((membership) => - membership.id === membershipId - ? { - ...membership, - ...patch, - updatedAt: new Date().toISOString(), - } - : membership - ), - })); + applyControlPlaneMutation(updateAdminMembership(membershipId, patch)); } 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 - ), - }; - }); + applyControlPlaneMutation(deleteAdminMembership(membershipId)); } function handleCreateGroup(clientId: string) { - const createdAt = new Date().toISOString(); - - setData((current) => ({ - ...current, - groups: [ - ...current.groups, - { - id: `group_mock_${Date.now()}`, - clientId, - name: "Новая группа", - description: "Описание группы", - memberIds: [], - createdAt, - updatedAt: createdAt, - }, - ], - })); + applyControlPlaneMutation(createAdminGroup({ clientId, name: "Новая группа", description: "Описание группы", memberIds: [] })); } function handleUpdateGroup(groupId: string, patch: Partial) { - setData((current) => ({ - ...current, - groups: current.groups.map((group) => - group.id === groupId - ? { - ...group, - ...patch, - updatedAt: new Date().toISOString(), - } - : group - ), - })); + applyControlPlaneMutation(updateAdminGroup(groupId, patch)); } 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)), - })); + applyControlPlaneMutation(deleteAdminGroup(groupId)); } function handleReorderServices(orderedServiceIds: string[]) { - setData((current) => { - const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10])); - const now = new Date().toISOString(); - - return { - ...current, - services: current.services.map((service) => { - const nextOrder = orderById.get(service.id); - - return nextOrder - ? { - ...service, - order: nextOrder, - updatedAt: now, - } - : service; - }), - }; - }); + applyControlPlaneMutation(reorderAdminServices(orderedServiceIds)); } function handleCreateService() { - const createdAt = new Date().toISOString(); - - setData((current) => { - const nextOrder = Math.max(0, ...current.services.map((service) => service.order)) + 10; - const id = `service_mock_${Date.now()}`; - - return { - ...current, - services: [ - ...current.services, - { - id, - slug: `new-service-${current.services.length + 1}`, - title: "New Service", - subtitle: "Новый сервис", - description: "Описание сервиса для витрины.", - fullDescription: "Заполните описание, медиа и ссылку запуска в редакторе контента.", - url: "https://service.handhdc.ru/sso/launch", - launchUrl: "https://service.handhdc.ru/sso/launch", - accentColor: "#F7F8F4", - fallbackGradient: "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)", - coverMediaSource: "url", - coverMediaKind: "image", - ambientMediaSource: "url", - ambientMediaKind: "gif", - status: "hidden", - order: nextOrder, - authentikApplicationSlug: `new-service-${current.services.length + 1}`, - authentikGroupName: `service-new-${current.services.length + 1}`, - createdAt, - updatedAt: createdAt, - }, - ], - }; - }); + applyControlPlaneMutation(createAdminService()); } function handleDeleteService(serviceId: string) { - setData((current) => ({ - ...current, - services: current.services.filter((service) => service.id !== serviceId), - grants: current.grants.filter((grant) => grant.serviceId !== serviceId), - exceptions: current.exceptions.filter((exception) => exception.serviceId !== serviceId), - })); + applyControlPlaneMutation(deleteAdminService(serviceId)); setSelectedServiceId((current) => (current === serviceId ? undefined : current)); } @@ -613,6 +489,7 @@ export function LauncherApp() { onClientChange={setActiveClientId} onToggleAdmin={() => setAdminOpen((current) => !current)} onOpenShowcase={() => setAdminOpen(false)} + onOpenProfileSettings={() => setProfileSettingsOpen(true)} onLogout={() => window.location.assign(authSession.logoutUrl)} /> @@ -638,9 +515,11 @@ export function LauncherApp() { onCreateClient={handleCreateClient} onUpdateClient={handleUpdateClient} onDeleteClient={handleDeleteClient} + onCreateUser={handleCreateUser} onUpdateUser={handleUpdateUser} onUpdateMembership={handleUpdateMembership} onDeleteMembership={handleDeleteMembership} + pendingAccessAssignments={pendingAccessAssignments} onCreateGroup={handleCreateGroup} onUpdateGroup={handleUpdateGroup} onDeleteGroup={handleDeleteGroup} @@ -650,6 +529,14 @@ export function LauncherApp() { onDeleteService={handleDeleteService} /> ) : null} + {profileSettingsOpen && activeProfileUser ? ( + setProfileSettingsOpen(false)} + onSaveProfile={handleUpdateOwnProfile} + onChangePassword={handleUpdateOwnPassword} + /> + ) : null} @@ -663,6 +550,65 @@ function syncLauncherServiceLinks(data: LauncherData): LauncherData { }; } +function accessAssignmentKey(userId: string, serviceId: string) { + return `${userId}:${serviceId}`; +} + +function canUseAdminApi(session: AuthSession): boolean { + return ( + session.authenticated && + (session.isSuperAdmin || session.groups.includes("nodedc:launcher:admin") || session.groups.includes("nodedc:superadmin")) + ); +} + +function resolveAuthenticatedContext( + data: LauncherData, + session: AuthenticatedSession, + currentProfileId: string, + currentClientId: string +): { profileId: string; clientId: string } { + const sessionEmail = session.user.email?.toLowerCase(); + const sessionSub = session.user.sub; + const profile = + data.users.find( + (user) => + (sessionSub && user.authentikUserId === sessionSub) || + (sessionEmail && user.email.toLowerCase() === sessionEmail) + ) ?? + (session.isSuperAdmin ? data.users.find((user) => user.id === "user_root") : undefined) ?? + data.users.find((user) => user.id === currentProfileId) ?? + data.users[0]; + + if (!profile) { + return { profileId: currentProfileId, clientId: currentClientId }; + } + + return { + profileId: profile.id, + clientId: resolveDefaultClientId(data, profile.id, currentClientId), + }; +} + +function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string { + const user = data.users.find((item) => item.id === userId); + const isRoot = user?.id === "user_root"; + const availableClientIds = isRoot + ? data.clients.map((client) => client.id) + : data.memberships.filter((membership) => membership.userId === userId && membership.status === "active").map((membership) => membership.clientId); + + if (requestedClientId && availableClientIds.includes(requestedClientId)) { + return requestedClientId; + } + + const defaultClientId = profileOptions.find((profile) => profile.userId === userId)?.defaultClientId; + + if (defaultClientId && availableClientIds.includes(defaultClientId)) { + return defaultClientId; + } + + return availableClientIds[0] ?? data.clients[0]?.id ?? requestedClientId; +} + function AuthStateScreen({ title, description, diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts new file mode 100644 index 0000000..f0e7eea --- /dev/null +++ b/src/shared/api/adminApi.ts @@ -0,0 +1,220 @@ +import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types"; +import type { Client } from "../../entities/client/types"; +import type { Invite } from "../../entities/invite/types"; +import type { Service } from "../../entities/service/types"; +import type { SyncStatus } from "../../entities/sync/types"; +import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types"; +import type { LauncherData } from "./mockApi"; + +export type AdminAccessAssignmentValue = Exclude | "deny" | "unset"; + +export interface ControlPlaneSnapshot { + actor: { + id: string; + name: string; + email: string | null; + source: string; + }; + counts: Record; + data: LauncherData; +} + +export interface ControlPlaneMutationResult { + data: LauncherData; + provisioning?: { + authentikUserId: string; + email: string; + name: string; + groups: string[]; + created: boolean; + temporaryPassword: string | null; + } | null; +} + +export async function fetchControlPlaneSnapshot(): Promise { + return requestJson("/api/admin/control-plane"); +} + +export async function createAdminClient(payload: Partial): Promise { + return requestJson("/api/admin/clients", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateAdminClient(clientId: string, patch: Partial): Promise { + return requestJson(`/api/admin/clients/${encodeURIComponent(clientId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export async function deleteAdminClient(clientId: string): Promise { + return requestJson(`/api/admin/clients/${encodeURIComponent(clientId)}`, { method: "DELETE" }); +} + +export async function updateAdminUserProfile(userId: string, patch: Partial): Promise { + return requestJson(`/api/admin/users/${encodeURIComponent(userId)}/profile`, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export async function createAdminUser(payload: { + clientId: string; + email: string; + name?: string; + role?: ClientMembership["role"]; + groupIds?: string[]; + provisionAuth?: boolean; + generatePassword?: boolean; + password?: string; +}): Promise { + return requestJson("/api/admin/users", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function provisionAdminUserAuthentik( + userId: string, + payload: { generatePassword?: boolean; password?: string } = {} +): Promise { + return requestJson(`/api/admin/users/${encodeURIComponent(userId)}/provision-authentik`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateAdminMembership( + membershipId: string, + patch: Partial +): Promise { + return requestJson(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export async function deleteAdminMembership(membershipId: string): Promise { + return requestJson(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { method: "DELETE" }); +} + +export async function createAdminGroup(payload: Pick & Partial): Promise { + return requestJson("/api/admin/groups", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateAdminGroup(groupId: string, patch: Partial): Promise { + return requestJson(`/api/admin/groups/${encodeURIComponent(groupId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export async function deleteAdminGroup(groupId: string): Promise { + return requestJson(`/api/admin/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" }); +} + +export async function createAdminService(payload: Partial = {}): Promise { + return requestJson("/api/admin/services", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateAdminService(serviceId: string, patch: Partial): Promise { + return requestJson(`/api/admin/services/${encodeURIComponent(serviceId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export async function reorderAdminServices(orderedServiceIds: string[]): Promise { + return requestJson("/api/admin/services/reorder", { + method: "PATCH", + body: JSON.stringify({ orderedServiceIds }), + }); +} + +export async function deleteAdminService(serviceId: string): Promise { + return requestJson(`/api/admin/services/${encodeURIComponent(serviceId)}`, { method: "DELETE" }); +} + +export async function createAdminInvite( + payload: Pick +): Promise { + return requestJson("/api/admin/invites", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateAdminInvite(inviteId: string, patch: Partial): Promise { + return requestJson(`/api/admin/invites/${encodeURIComponent(inviteId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export async function deleteAdminInvite(inviteId: string): Promise { + return requestJson(`/api/admin/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE" }); +} + +export async function setAdminUserServiceAccess(payload: { + userId: string; + serviceId: string; + value: AdminAccessAssignmentValue; +}): Promise { + return requestJson("/api/admin/access/user-service", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function upsertAdminGrant(payload: Partial): Promise { + return requestJson("/api/admin/access/grants", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function upsertAdminException(payload: Partial): Promise { + return requestJson("/api/admin/access/exceptions", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function retryAdminSync(syncId: string): Promise { + return requestJson(`/api/admin/sync/${encodeURIComponent(syncId)}/retry`, { method: "POST" }); +} + +async function requestJson(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(url, { + ...init, + headers, + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return (await response.json()) as T; +} + +async function readErrorMessage(response: Response) { + try { + const payload = (await response.json()) as { error?: string }; + return payload.error ?? response.statusText; + } catch { + return response.statusText; + } +} diff --git a/src/shared/api/authApi.ts b/src/shared/api/authApi.ts index 9f146a0..0d1093f 100644 --- a/src/shared/api/authApi.ts +++ b/src/shared/api/authApi.ts @@ -3,6 +3,7 @@ export interface AuthUser { email: string; name: string; preferredUsername: string | null; + avatarUrl: string | null; groups: string[]; } diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index e3199b2..4a7399f 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -30,6 +30,7 @@ export interface AuthentikClaimsMock { sub: string; email: string; name: string; + avatarUrl?: string | null; groups: string[]; activeClientId: string; } @@ -98,40 +99,16 @@ export const initialLauncherData: LauncherData = { export const profileOptions: ProfileOption[] = [ { userId: "user_root", - label: "Root Admin", - description: "Полный каталог и все клиенты", + label: "DC Touch", + description: "NODE.DC superadmin", defaultClientId: "client_romashka", }, { - userId: "user_ivan", - label: "Client Owner", - description: "Иван, владелец Ромашки и админ демо-клиента", + userId: "user_silver_psih", + label: "Silver Psy", + description: "DCTOUCH manager", defaultClientId: "client_romashka", }, - { - userId: "user_vera", - label: "Client Admin", - description: "Вера, админ ООО Ромашка", - defaultClientId: "client_romashka", - }, - { - userId: "user_vasya", - label: "Member", - description: "Василий, обычный участник", - defaultClientId: "client_romashka", - }, - { - userId: "user_lena", - label: "Member + deny", - description: "Лена, участник с deny-исключением", - defaultClientId: "client_romashka", - }, - { - userId: "user_maria", - label: "Client Owner demo", - description: "Мария, владелец демо-клиента", - defaultClientId: "client_roga_kopyta", - }, ]; export function buildMe(data: LauncherData, userId: string, requestedClientId?: string): MeResponse { @@ -237,7 +214,7 @@ export function buildLauncherServices(data: LauncherData, userId: string, active effectiveAccess, }; }) - .filter((service) => isRoot || service.effectiveAccess.visible); + .filter((service) => isRoot || service.status !== "hidden"); } export function buildAccessMatrix(data: LauncherData, clientId: string, includeAllServices: boolean): AccessMatrix { diff --git a/src/shared/api/mockData.ts b/src/shared/api/mockData.ts index 3f63c80..a9acc14 100644 --- a/src/shared/api/mockData.ts +++ b/src/shared/api/mockData.ts @@ -12,40 +12,17 @@ export const mockClients: Client[] = [ { id: "client_romashka", type: "company", - name: "ООО Ромашка", - legalName: "ООО Ромашка", + name: "DCTOUCH", + legalName: "ООО ДИСИТАЧ", status: "active", + contractStartsAt: "2026-05-04T00:00:00.000Z", + contractEndsAt: null, + paidUntil: null, demoEndsAt: null, - contactName: "Иван Петров", - contactEmail: "ivan@romashka.ru", - notes: "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.", - createdAt: "2026-04-01T10:00:00Z", - updatedAt: now, - }, - { - id: "client_roga_kopyta", - type: "company", - name: "ООО Рога и Копыта", - legalName: "ООО Рога и Копыта", - status: "demo", - demoEndsAt: "2026-06-01T00:00:00Z", - contactName: "Мария Иванова", - contactEmail: "maria@example.ru", - notes: "Клиент на демо-доступе, подключены только базовые сервисы.", - createdAt: "2026-04-10T10:00:00Z", - updatedAt: now, - }, - { - id: "client_private_architect", - type: "person", - name: "Илья Архитектор", - legalName: null, - status: "suspended", - demoEndsAt: "2026-04-20T00:00:00Z", - contactName: "Илья Архитектор", - contactEmail: "ilya@example.ru", - notes: "Пример приостановленного частного клиента.", - createdAt: "2026-03-14T10:00:00Z", + contactName: "DC Touch", + contactEmail: "dcctouch@gmail.com", + notes: "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.", + createdAt: "2026-05-04T00:00:00.000Z", updatedAt: now, }, ]; @@ -53,92 +30,44 @@ export const mockClients: Client[] = [ export const mockUsers: LauncherUser[] = [ { id: "user_root", - authentikUserId: "ak-root", - name: "Root Admin", - email: "root@nodedc.local", + authentikUserId: null, + name: "DC Touch", + email: "dcctouch@gmail.com", + phone: null, + position: "NODE.DC Super Admin", + notes: "Главный супер-администратор NODE.DC.", + avatarUrl: null, globalStatus: "active", - createdAt: "2026-04-01T10:00:00Z", + createdAt: "2026-05-04T00:00:00.000Z", updatedAt: now, }, { - id: "user_ivan", - authentikUserId: "ak-ivan", - name: "Иван Петров", - email: "ivan@romashka.ru", + id: "user_silver_psih", + authentikUserId: null, + name: "Silver Psy", + email: "silver_psih@yahoo.com", + phone: null, + position: "Manager", + notes: "Живой пользователь из Plane. Требует Authentik invite/sync flow.", + avatarUrl: null, globalStatus: "active", - createdAt: "2026-04-01T10:00:00Z", - updatedAt: now, - }, - { - id: "user_vera", - authentikUserId: "ak-vera", - name: "Вера Соколова", - email: "vera@romashka.ru", - globalStatus: "active", - createdAt: "2026-04-02T10:00:00Z", - updatedAt: now, - }, - { - id: "user_vasya", - authentikUserId: "ak-vasya", - name: "Василий Орлов", - email: "vasya@romashka.ru", - globalStatus: "active", - createdAt: "2026-04-05T10:00:00Z", - updatedAt: now, - }, - { - id: "user_lena", - authentikUserId: "ak-lena", - name: "Лена Волкова", - email: "lena@romashka.ru", - globalStatus: "active", - createdAt: "2026-04-08T10:00:00Z", - updatedAt: now, - }, - { - id: "user_maria", - authentikUserId: "ak-maria", - name: "Мария Иванова", - email: "maria@example.ru", - globalStatus: "active", - createdAt: "2026-04-10T10:00:00Z", - updatedAt: now, - }, - { - id: "user_blocked", - authentikUserId: "ak-blocked", - name: "Олег Заблокирован", - email: "oleg@romashka.ru", - globalStatus: "blocked", - createdAt: "2026-04-12T10:00:00Z", + createdAt: "2026-05-04T00:00:00.000Z", updatedAt: now, }, ]; export const mockMemberships: ClientMembership[] = [ - membership("mem_ivan_romashka", "client_romashka", "user_ivan", "client_owner"), - membership("mem_vera_romashka", "client_romashka", "user_vera", "client_admin"), - membership("mem_vasya_romashka", "client_romashka", "user_vasya", "member"), - membership("mem_lena_romashka", "client_romashka", "user_lena", "member"), - membership("mem_blocked_romashka", "client_romashka", "user_blocked", "member", "disabled"), - membership("mem_maria_roga", "client_roga_kopyta", "user_maria", "client_owner"), - membership("mem_ivan_roga", "client_roga_kopyta", "user_ivan", "client_admin"), + membership("mem_dc_touch_dctouch", "client_romashka", "user_root", "client_owner"), + membership("mem_silver_psih_dctouch", "client_romashka", "user_silver_psih", "member"), ]; export const mockGroups: ClientGroup[] = [ - group("group_romashka_leads", "client_romashka", "Руководство", "Собственники и руководители клиента.", [ - "user_ivan", - "user_vera", + group("group_dctouch_admins", "client_romashka", "Администраторы", "Администраторы клиента и владельцы платформенного доступа.", [ + "user_root", ]), - group("group_romashka_accounting", "client_romashka", "Бухгалтерия", "1C и финансовые сценарии.", [ - "user_lena", + group("group_dctouch_managers", "client_romashka", "Менеджеры", "Рабочая группа менеджеров с доступом к операционному контуру.", [ + "user_silver_psih", ]), - group("group_romashka_ops", "client_romashka", "Операторы", "Ежедневная работа в задачах и тендерах.", [ - "user_vasya", - "user_lena", - ]), - group("group_roga_demo", "client_roga_kopyta", "Демо-команда", "Пилотный контур клиента.", ["user_maria", "user_ivan"]), ]; export const mockServices: Service[] = [ @@ -272,82 +201,43 @@ export const mockServices: Service[] = [ ]; export const mockGrants: ServiceGrant[] = [ - grant("grant_romashka_task", "service_task_manager", "client", "client_romashka", "member"), - grant("grant_romashka_nodedc_leads", "service_nodedc", "group", "group_romashka_leads", "admin"), - grant("grant_romashka_1c_accounting", "service_1c", "group", "group_romashka_accounting", "member"), - grant("grant_romashka_tender_ops", "service_tender", "group", "group_romashka_ops", "viewer"), - grant("grant_romashka_twin_vasya", "service_digital_twin", "user", "user_vasya", "viewer"), - grant("grant_roga_task", "service_task_manager", "client", "client_roga_kopyta", "member"), - grant("grant_roga_nodedc", "service_nodedc", "client", "client_roga_kopyta", "viewer"), + grant("grant_dctouch_task_admins", "service_task_manager", "group", "group_dctouch_admins", "admin"), + grant("grant_dctouch_task_managers", "service_task_manager", "group", "group_dctouch_managers", "member"), + grant("grant_dctouch_nodedc_admins", "service_nodedc", "group", "group_dctouch_admins", "admin"), ]; -export const mockExceptions: ServiceAccessException[] = [ - { - id: "exception_lena_task_deny", - serviceId: "service_task_manager", - userId: "user_lena", - type: "deny", - reason: "Индивидуально отключён Task Manager на период ревизии доступа.", - createdAt: "2026-04-28T10:00:00Z", - updatedAt: now, - }, -]; +export const mockExceptions: ServiceAccessException[] = []; -export const mockInvites: Invite[] = [ - { - id: "invite_romashka_analyst", - clientId: "client_romashka", - email: "analyst@romashka.ru", - role: "member", - invitedByUserId: "user_ivan", - token: "romashka-analyst-demo", - expiresAt: "2026-05-15T12:00:00Z", - status: "sent", - createdAt: "2026-04-30T12:00:00Z", - updatedAt: now, - }, - { - id: "invite_roga_admin", - clientId: "client_roga_kopyta", - email: "ops@example.ru", - role: "client_admin", - invitedByUserId: "user_maria", - token: "roga-admin-demo", - expiresAt: "2026-05-18T12:00:00Z", - status: "created", - createdAt: "2026-04-30T14:00:00Z", - updatedAt: now, - }, -]; +export const mockInvites: Invite[] = []; export const mockSyncStatuses: SyncStatus[] = [ - sync("sync_romashka_auth", "client_romashka", "ООО Ромашка", "client", "authentik", "synced"), - sync("sync_task_auth", "service_task_manager", "Task Manager", "service", "authentik", "synced"), + sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"), + sync("sync_dc_touch_authentik", "user_root", "dcctouch@gmail.com", "user", "authentik", "synced"), sync( - "sync_lena_task", - "exception_lena_task_deny", - "Deny: Лена / Task Manager", - "grant", - "task_manager", + "sync_silver_psih_authentik", + "user_silver_psih", + "silver_psih@yahoo.com", + "user", + "authentik", "pending", - null - ), - sync( - "sync_roga_nodedc", - "client_roga_kopyta", - "ООО Рога и Копыта", - "client", - "nodedc", - "error", - "OIDC binding ещё не создан для demo-клиента." + "Пользователь найден в Plane, но ещё не создан в Authentik через Launcher invite/sync flow." ), + sync("sync_dctouch_groups_authentik", "client_romashka:groups", "DCTOUCH groups", "group", "authentik", "pending"), + sync("sync_task_manager_authentik", "service_task_manager", "OPERATIONAL CORE", "service", "authentik", "synced"), ]; export const mockAuditEvents: AuditEvent[] = [ - audit("audit_1", "2026-05-01T08:40:00Z", "user_root", "Root Admin", "Создан сервис", "service", "Digital Modules", "success", null), - audit("audit_2", "2026-05-01T08:20:00Z", "user_ivan", "Иван Петров", "Создан invite", "invite", "analyst@romashka.ru", "success", "Срок действия до 15.05.2026"), - audit("audit_3", "2026-04-30T17:10:00Z", "user_root", "Root Admin", "Создано deny-исключение", "access", "Лена / Task Manager", "warning", "Индивидуальное правило перекрыло client grant."), - audit("audit_4", "2026-04-30T16:00:00Z", "user_root", "Root Admin", "Ошибка синхронизации", "sync", "ООО Рога и Копыта / NodeDC", "error", "Нет application binding."), + audit( + "audit_live_seed_control_plane", + "2026-05-04T00:00:00.000Z", + "system", + "NODE.DC seed", + "Применён live seed control-plane", + "control_plane", + "Launcher users and access", + "success", + "Demo-участники удалены из runtime storage. Оставлены dcctouch@gmail.com и silver_psih@yahoo.com." + ), ]; function membership( diff --git a/src/shared/api/profileApi.ts b/src/shared/api/profileApi.ts new file mode 100644 index 0000000..d146638 --- /dev/null +++ b/src/shared/api/profileApi.ts @@ -0,0 +1,57 @@ +import type { ClientMembership, LauncherUser } from "../../entities/user/types"; +import type { LauncherData } from "./mockApi"; + +export interface ProfileResponse { + user: LauncherUser; + memberships: ClientMembership[]; +} + +export interface ProfileMutationResult { + data: LauncherData; +} + +export async function fetchOwnProfile(): Promise { + return requestJson("/api/profile"); +} + +export async function updateOwnProfile(patch: Partial): Promise { + return requestJson("/api/profile", { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + +export async function updateOwnPassword(newPassword: string): Promise { + return requestJson("/api/profile/password", { + method: "POST", + body: JSON.stringify({ newPassword }), + }); +} + +async function requestJson(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(url, { + ...init, + headers, + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return (await response.json()) as T; +} + +async function readErrorMessage(response: Response) { + try { + const payload = (await response.json()) as { error?: string }; + return payload.error ?? response.statusText; + } catch { + return response.statusText; + } +} diff --git a/src/shared/nodedc-ui/ProfileMenu.tsx b/src/shared/nodedc-ui/ProfileMenu.tsx index e7e8a5c..998e49f 100644 --- a/src/shared/nodedc-ui/ProfileMenu.tsx +++ b/src/shared/nodedc-ui/ProfileMenu.tsx @@ -28,21 +28,37 @@ export function NodeDcProfileMenu({ user, coverUrl = "/storage/default.gif", tri surfaceClassName="nodedc-ui-profile-menu" trigger={({ open, toggle, setTriggerRef }) => trigger({ open, toggle, setTriggerRef })} > -
-
- - {user.name} - {user.email} + {({ close }) => ( +
+
+ + {user.name} + {user.email} +
+ +
- - -
+ )} ); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 2052563..b0abdb6 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -449,8 +449,10 @@ code { padding-left: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-content-width) + (var(--admin-panel-gap) * 2)); } -.launcher-main:has(.admin-panel-layer--content-open) .stage-video-topline, -.launcher-main:has(.admin-panel-layer--content-open) .stage-side-controls, +.launcher-main:has(.profile-settings-layer) .service-stage { + padding-right: calc(var(--launcher-page-pad) + var(--admin-nav-width) + var(--admin-panel-gap)); +} + .launcher-main:has(.admin-panel-layer--content-open) .stage-service-overlay, .launcher-main:has(.admin-panel-layer--content-open) .stage-video-controls, .launcher-main:has(.admin-panel-layer--content-open) .stage-timeline-strip { @@ -470,6 +472,7 @@ code { background: #050506; transition: padding-left 440ms cubic-bezier(0.22, 1, 0.36, 1), + padding-right 440ms cubic-bezier(0.22, 1, 0.36, 1), background 220ms ease; } @@ -536,21 +539,6 @@ code { content: ""; } -.stage-video-topline { - position: absolute; - z-index: 3; - top: 1.05rem; - left: 1.05rem; - display: inline-flex; - align-items: center; - gap: 0.6rem; - color: rgba(255, 255, 255, 0.86); - font-size: 0.95rem; - font-weight: 700; -} - -.stage-round-button, -.stage-side-controls span, .stage-video-controls button { display: grid; place-items: center; @@ -562,23 +550,30 @@ code { -webkit-backdrop-filter: blur(18px); } -.stage-round-button { - width: 2rem; - height: 2rem; -} - -.stage-side-controls { +.stage-empty-title { position: absolute; - z-index: 3; - left: 1.05rem; - top: 31%; + z-index: 4; + top: 5.76rem; + left: 5.76rem; display: grid; - gap: 0.62rem; + gap: 0.22rem; + color: rgba(255, 255, 255, 0.94); + text-transform: uppercase; + pointer-events: none; } -.stage-side-controls span { - width: 2rem; - height: 2rem; +.stage-empty-title span { + color: rgba(64, 64, 64, 0.92); + font-size: clamp(1.82rem, 2.3vw, 2.52rem); + font-weight: 350; + line-height: 1; +} + +.stage-empty-title strong { + color: rgba(255, 255, 255, 0.94); + font-size: clamp(1.82rem, 2.3vw, 2.52rem); + font-weight: 350; + line-height: 1; } .stage-service-overlay { @@ -702,13 +697,6 @@ code { line-height: 0.98; } -.stage-description-card__copy p { - margin: 0; - color: rgba(255, 255, 255, 0.72); - font-size: 0.86rem; - line-height: 1.48; -} - .stage-rich-description { display: grid; gap: 0.75rem; @@ -748,14 +736,14 @@ code { } .stage-description-card__chips, -.stage-description-card__actions { +.stage-description-card__footer { display: flex; flex-wrap: wrap; gap: 0.5rem; } .stage-description-card__chips .status-badge, -.stage-description-card__actions .button { +.stage-description-card__footer .button { min-height: 2.78rem; border-radius: var(--launcher-radius-circle); padding: 0 1.22rem; @@ -763,31 +751,29 @@ code { font-weight: 800; } -.stage-description-card__actions { - align-self: flex-end; - justify-content: flex-end; - margin-top: auto; -} - -.stage-description-card__reason { +.stage-description-card__description { display: grid; - gap: 0.3rem; + min-height: 3.85rem; + max-height: 13rem; + overflow-y: auto; padding: 0.78rem; border-radius: 1rem; background: rgba(255, 255, 255, 0.1); + scrollbar-width: none; } -.stage-description-card__reason span { - color: rgba(255, 255, 255, 0.48); - font-size: 0.72rem; - font-weight: 800; - text-transform: uppercase; +.stage-description-card__description::-webkit-scrollbar { + display: none; } -.stage-description-card__reason strong { - color: rgba(255, 255, 255, 0.76); - font-size: 0.86rem; - line-height: 1.38; +.stage-description-card__footer { + align-items: center; + justify-content: space-between; + margin-top: auto; +} + +.stage-description-card__chips { + align-items: center; } .stage-video-controls { @@ -971,8 +957,8 @@ code { } .service-tile--active .service-tile__arrow { - background: rgb(var(--nodedc-card-active-rgb)); - color: rgb(var(--nodedc-on-accent-rgb)); + background: rgba(247, 248, 244, 0.94); + color: rgba(8, 8, 10, 0.96); } .service-tile__media { @@ -1045,8 +1031,8 @@ code { height: 2.3rem; place-items: center; border-radius: var(--launcher-radius-circle); - background: rgba(247, 248, 244, 0.94); - color: rgba(8, 8, 10, 0.96); + background: rgba(64, 64, 64, 0.62); + color: rgba(255, 255, 255, 0.88); transform: translateY(-50%); } @@ -1246,6 +1232,164 @@ code { animation: adminPanelSlide 460ms cubic-bezier(0.22, 1, 0.36, 1) both; } +.profile-settings-layer { + position: absolute; + z-index: 9; + top: 0; + right: var(--launcher-page-pad); + bottom: calc(var(--launcher-rail-height) + var(--launcher-rail-bottom) + var(--launcher-stage-rail-gap)); + width: var(--admin-nav-width); + pointer-events: none; +} + +.profile-settings-panel { + display: grid; + height: 100%; + grid-template-rows: auto minmax(0, 1fr) auto auto; + gap: 1rem; + pointer-events: auto; + border: 0; + border-radius: var(--launcher-radius-card); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.014)), + rgba(10, 10, 13, 0.9); + box-shadow: 0 34px 110px rgba(0, 0, 0, 0.52); + backdrop-filter: blur(28px); + -webkit-backdrop-filter: blur(28px); + padding: var(--admin-nav-pad); + animation: profilePanelSlide 420ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.profile-settings-panel__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.profile-settings-panel__head h2 { + margin: 0.15rem 0 0; + font-size: 1.35rem; +} + +.profile-settings-panel__body { + display: grid; + align-content: start; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.15rem; +} + +.profile-settings-avatar-card { + display: grid; + justify-items: center; + gap: 0.85rem; + border-radius: 1.25rem; + background: + radial-gradient(circle at 50% 0%, rgba(181, 255, 90, 0.16), transparent 42%), + rgba(255, 255, 255, 0.045); + padding: 1.25rem 1rem; +} + +.profile-settings-avatar-card__image { + display: grid; + width: 5.25rem; + height: 5.25rem; + place-items: center; + overflow: hidden; + border-radius: var(--launcher-radius-circle); + background: + radial-gradient(circle at 72% 20%, rgba(255, 255, 255, 0.72), transparent 23%), + linear-gradient(135deg, rgb(166, 194, 109), rgb(142, 123, 139)); + color: rgba(8, 8, 10, 0.96); + object-fit: cover; + font-size: 1rem; + font-weight: 850; +} + +.profile-settings-upload { + display: inline-flex; + min-height: 2.35rem; + align-items: center; + justify-content: center; + gap: 0.45rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); + cursor: pointer; + padding: 0 0.85rem; + font-size: 0.78rem; + font-weight: 800; +} + +.profile-settings-upload:hover { + background: rgba(255, 255, 255, 0.13); +} + +.profile-settings-upload input { + display: none; +} + +.profile-settings-field { + display: grid; + gap: 0.4rem; +} + +.profile-settings-field span { + color: var(--text-secondary); + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.profile-settings-field input { + min-height: 2.75rem; + border: 0; + border-radius: 0.95rem; + outline: 0; + background: rgba(255, 255, 255, 0.07); + color: var(--text-primary); + padding: 0 0.85rem; + font: inherit; +} + +.profile-settings-field input:focus { + background: rgba(255, 255, 255, 0.1); + box-shadow: 0 0 0 1px rgba(181, 255, 90, 0.34); +} + +.profile-settings-divider { + height: 1px; + background: rgba(255, 255, 255, 0.08); + margin: 0.25rem 0; +} + +.profile-settings-message { + margin: 0; + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.4; +} + +.profile-settings-panel__foot { + display: grid; + gap: 0.65rem; +} + +@keyframes profilePanelSlide { + from { + opacity: 0; + transform: translateX(1.25rem); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + .admin-panel-nav__head { display: flex; align-items: start; @@ -1266,7 +1410,7 @@ code { width: var(--admin-control-ring); height: var(--admin-control-ring); flex: 0 0 auto; - border: 0; + border: 1px solid rgba(255, 255, 255, 0.22); outline: none; background: transparent !important; background-image: none !important; @@ -1275,6 +1419,7 @@ code { } .admin-panel-close:hover { + border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.07) !important; color: var(--text-primary); } @@ -1665,6 +1810,11 @@ code { filter: grayscale(1); } +.admin-content-close { + background: transparent !important; + color: rgba(255, 255, 255, 0.8); +} + .admin-section-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -2609,8 +2759,13 @@ code { box-shadow: none; } -.access-cell:hover, -.access-cell[aria-expanded="true"] { +.access-cell--pending { + cursor: wait; + opacity: 0.72; +} + +.access-cell:not(.access-cell--pending):hover, +.access-cell:not(.access-cell--pending)[aria-expanded="true"] { filter: brightness(1.12); } @@ -3114,6 +3269,11 @@ code { width: min(62rem, calc(100% - 5rem)); gap: 0.8rem; } + + .stage-empty-title { + top: 3.84rem; + left: 3.84rem; + } } @media (max-width: 760px) { @@ -3179,7 +3339,11 @@ code { transform: translate(-50%, -50%); } - .stage-side-controls, + .stage-empty-title { + top: 2.05rem; + left: 2.05rem; + } + .stage-timeline-strip { display: none; } diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 35cdd22..e365ef0 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -89,6 +89,16 @@ export interface SetUserServiceAccessCommand { value: AccessAssignmentValue; } +export interface CreateUserCommand { + clientId: string; + email: string; + name?: string; + role: ClientMembershipRole; + groupIds: string[]; + provisionAuth: boolean; + generatePassword: boolean; +} + const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ { id: "overview", label: "Обзор", icon: }, { id: "clients", label: "Клиенты", icon: }, @@ -124,9 +134,11 @@ export function AdminOverlay({ onCreateClient, onUpdateClient, onDeleteClient, + onCreateUser, onUpdateUser, onUpdateMembership, onDeleteMembership, + pendingAccessAssignments, onCreateGroup, onUpdateGroup, onDeleteGroup, @@ -147,9 +159,11 @@ export function AdminOverlay({ onCreateClient: () => void; onUpdateClient: (clientId: string, patch: Partial) => void; onDeleteClient: (clientId: string) => void; + onCreateUser: (command: CreateUserCommand) => void; onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; onDeleteMembership: (membershipId: string) => void; + pendingAccessAssignments: Record; onCreateGroup: (clientId: string) => void; onUpdateGroup: (groupId: string, patch: Partial) => void; onDeleteGroup: (groupId: string) => void; @@ -264,7 +278,7 @@ export function AdminOverlay({ {activeSection ? (
- + setActiveSection(null)} />
{activeSection === "overview" ? : null} {activeSection === "clients" && isRoot ? ( @@ -275,6 +289,7 @@ export function AdminOverlay({ data={data} clientId={scopedClientId} isRoot={isRoot} + onCreateUser={onCreateUser} onUpdateUser={onUpdateUser} onUpdateMembership={onUpdateMembership} onDeleteMembership={onDeleteMembership} @@ -305,6 +320,7 @@ export function AdminOverlay({ selectedCell={selectedAccessCell} onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })} onSetUserServiceAccess={onSetUserServiceAccess} + pendingAccessAssignments={pendingAccessAssignments} /> ) : null} {activeSection === "invites" ? ( @@ -327,7 +343,7 @@ export function AdminOverlay({ ); } -function AdminHeader() { +function AdminHeader({ onCloseContent }: { onCloseContent: () => void }) { return (
@@ -337,6 +353,9 @@ function AdminHeader() { + + +
); @@ -496,6 +515,7 @@ function UsersSection({ data, clientId, isRoot, + onCreateUser, onUpdateUser, onUpdateMembership, onDeleteMembership, @@ -503,26 +523,93 @@ function UsersSection({ data: LauncherData; clientId: string; isRoot: boolean; + onCreateUser: (command: CreateUserCommand) => void; onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; onDeleteMembership: (membershipId: string) => void; }) { const [editingMembershipId, setEditingMembershipId] = useState(null); + const [newUserEmail, setNewUserEmail] = useState(""); + const [newUserName, setNewUserName] = useState(""); + const [newUserRole, setNewUserRole] = useState("member"); + const [newUserGroupId, setNewUserGroupId] = useState("none"); const rows = isRoot ? data.memberships.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) })) : data.memberships .filter((membership) => membership.clientId === clientId) .map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) })); const editingRow = rows.find((row) => row.membership.id === editingMembershipId) ?? null; + const clientGroups = data.groups.filter((group) => group.clientId === clientId); + const groupOptions: Array> = [ + { value: "none", label: "Без группы" }, + ...clientGroups.map((group) => ({ value: group.id, label: group.name })), + ]; + + function handleCreateUser() { + const email = newUserEmail.trim(); + + if (!email) { + return; + } + + onCreateUser({ + clientId, + email, + name: newUserName.trim() || undefined, + role: newUserRole, + groupIds: newUserGroupId === "none" ? [] : [newUserGroupId], + provisionAuth: true, + generatePassword: true, + }); + setNewUserEmail(""); + setNewUserName(""); + setNewUserRole("member"); + setNewUserGroupId("none"); + } return ( <> + +
+
+

Launcher → Authentik

+

Создать участника

+
+ + + +
+
+ setNewUserEmail(event.target.value)} placeholder="email@company.ru" /> + setNewUserName(event.target.value)} placeholder="Имя пользователя" /> + setNewUserRole(role)} + /> + setNewUserGroupId(groupId)} + /> +
+
+

Участники

- - -
@@ -1823,12 +1910,14 @@ function AccessSection({ selectedCell, onSelectCell, onSetUserServiceAccess, + pendingAccessAssignments, }: { data: LauncherData; matrix: ReturnType; selectedCell: AccessMatrixCell | null; onSelectCell: (cell: AccessMatrixCell) => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; + pendingAccessAssignments: Record; }) { const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null; @@ -1893,6 +1982,7 @@ function AccessSection({ onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value })} /> @@ -1926,15 +2016,18 @@ function AccessSection({ function AccessCellControl({ cell, active, + pendingValue, onSelectCell, onSetAccess, }: { cell: AccessMatrixCell; active: boolean; + pendingValue?: AccessAssignmentValue; onSelectCell: (cell: AccessMatrixCell) => void; onSetAccess: (value: AccessAssignmentValue) => void; }) { - const assignmentValue = accessAssignmentValue(cell); + const isPending = pendingValue !== undefined; + const assignmentValue = pendingValue ?? accessAssignmentValue(cell); return ( onSetAccess(value)} trigger={({ open, toggle, setTriggerRef }) => ( )} /> @@ -2308,6 +2404,14 @@ function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue { return "unset"; } +function accessAssignmentLabel(value: AccessAssignmentValue): string { + return accessAssignmentOptions.find((option) => option.value === value)?.label ?? value; +} + +function accessCellKey(userId: string, serviceId: string): string { + return `${userId}:${serviceId}`; +} + function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string { if (!source) return "—"; const labels = { diff --git a/src/widgets/profile-settings-panel/ProfileSettingsPanel.tsx b/src/widgets/profile-settings-panel/ProfileSettingsPanel.tsx new file mode 100644 index 0000000..3e7e025 --- /dev/null +++ b/src/widgets/profile-settings-panel/ProfileSettingsPanel.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from "react"; +import { KeyRound, Save, Upload, X } from "lucide-react"; +import type { LauncherUser } from "../../entities/user/types"; +import { uploadStorageFile } from "../../shared/api/storageApi"; +import { initials } from "../../shared/lib/format"; +import { Button, IconButton } from "../../shared/ui/Button"; + +export function ProfileSettingsPanel({ + user, + onClose, + onSaveProfile, + onChangePassword, +}: { + user: LauncherUser; + onClose: () => void; + onSaveProfile: (patch: Partial) => Promise; + onChangePassword: (newPassword: string) => Promise; +}) { + const [draft, setDraft] = useState(user); + const [newPassword, setNewPassword] = useState(""); + const [uploading, setUploading] = useState(false); + const [savingProfile, setSavingProfile] = useState(false); + const [savingPassword, setSavingPassword] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => setDraft(user), [user]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + function update(key: K, value: LauncherUser[K]) { + setDraft((current) => ({ ...current, [key]: value })); + } + + async function handleAvatarUpload(file: File | undefined) { + if (!file) return; + + setUploading(true); + setMessage(null); + + try { + const result = await uploadStorageFile(file); + update("avatarUrl", result.url); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар"); + } finally { + setUploading(false); + } + } + + async function handleSaveProfile() { + setSavingProfile(true); + setMessage(null); + + try { + await onSaveProfile({ + name: draft.name, + email: draft.email, + phone: draft.phone ?? null, + position: draft.position ?? null, + avatarUrl: draft.avatarUrl ?? null, + }); + setMessage("Профиль сохранён"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль"); + } finally { + setSavingProfile(false); + } + } + + async function handleSavePassword() { + if (newPassword.length < 8) { + setMessage("Пароль должен быть не короче 8 символов"); + return; + } + + setSavingPassword(true); + setMessage(null); + + try { + await onChangePassword(newPassword); + setNewPassword(""); + setMessage("Пароль обновлён"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Не удалось обновить пароль"); + } finally { + setSavingPassword(false); + } + } + + return ( + + ); +} diff --git a/src/widgets/service-stage/ServiceStage.tsx b/src/widgets/service-stage/ServiceStage.tsx index 0fd7b27..1b29bba 100644 --- a/src/widgets/service-stage/ServiceStage.tsx +++ b/src/widgets/service-stage/ServiceStage.tsx @@ -17,6 +17,8 @@ import type { LauncherServiceView } from "../../entities/service/types"; import { Button } from "../../shared/ui/Button"; import { ServiceStatusBadge, StatusBadge } from "../../shared/ui/StatusBadge"; +const stageActionAccentRgb = [247, 248, 244] as const; + export function ServiceStage({ service, hasServices, @@ -50,7 +52,7 @@ export function ServiceStage({ ? service.status === "maintenance" ? "Сервис временно недоступен" : service.userAccess === "denied" - ? "Доступ не выдан" + ? "Нет доступа" : service.effectiveAccess.openEnabled ? null : "Открытие заблокировано" @@ -63,20 +65,6 @@ export function ServiceStage({ -
- - {service?.title ?? "Витрина NODE.DC"} -
- - - {service ? (
@@ -104,26 +92,24 @@ export function ServiceStage({

{service.title}

+
+ +
-
- - -
- -
- Почему видно - {service.effectiveAccess.reason} -
- -
+
+
+ + +
-
- ) : null} + ) : ( +
+ NODE.DC + Витрина модулей +
+ )}