diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 24a5da6..b348d71 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -23,12 +23,14 @@ { "slug": "nodedc", "name": "NODE DC", - "isPrimary": true + "isPrimary": true, + "managedBy": "launcher" }, { "slug": "dcabramov", "name": "DCABRAMOV", - "isPrimary": false + "isPrimary": false, + "managedBy": "launcher" } ] } @@ -128,6 +130,45 @@ "globalStatus": "active", "createdAt": "2026-05-06T01:06:48.113Z", "updatedAt": "2026-05-06T01:28:06.887Z" + }, + { + "id": "user_ayo_ayo_ae_gmail_com", + "authentikUserId": "529588a1-48fa-44f7-a2a7-dc24f9b6626b", + "name": "Anna Ayo", + "email": "ayo.ayo.ae@gmail.com", + "phone": null, + "position": null, + "notes": "Создан через публичную регистрацию по инвайту клиента Открытый контур.", + "avatarUrl": "/storage/uploads/1778326544251-ddfabfce-31e1669e-28da-428a-9e2a-d51a781f041f.jpg", + "globalStatus": "active", + "createdAt": "2026-05-09T11:33:58.892Z", + "updatedAt": "2026-05-09T12:34:39.358Z" + }, + { + "id": "user_alah_gmail_com", + "authentikUserId": "1a3e1273-8d77-4747-947a-295f8ac89418", + "name": "ALAH", + "email": "alah@gmail.com", + "phone": null, + "position": null, + "notes": "Создан через публичную регистрацию по инвайту клиента Открытый контур.", + "avatarUrl": null, + "globalStatus": "active", + "createdAt": "2026-05-09T17:26:58.823Z", + "updatedAt": "2026-05-09T18:58:28.494Z" + }, + { + "id": "user_pupa_mail_ru", + "authentikUserId": "a2a1b489-f492-45a0-a5bd-04f6c1ede80d", + "name": "PUPA", + "email": "pupa@mail.ru", + "phone": null, + "position": null, + "notes": "Создан через публичную регистрацию по инвайту клиента Открытый контур.", + "avatarUrl": null, + "globalStatus": "active", + "createdAt": "2026-05-09T19:37:43.521Z", + "updatedAt": "2026-05-09T19:37:43.533Z" } ], "memberships": [ @@ -193,6 +234,41 @@ "status": "active", "createdAt": "2026-05-06T01:06:48.113Z", "updatedAt": "2026-05-06T01:06:48.113Z" + }, + { + "id": "mem_client_public_pool_ayo_ayo_ae_gmail_com", + "clientId": "client_public_pool", + "userId": "user_ayo_ayo_ae_gmail_com", + "role": "member", + "status": "active", + "createdAt": "2026-05-09T11:33:58.892Z", + "updatedAt": "2026-05-09T11:33:58.892Z" + }, + { + "id": "mem_client_public_pool_alah_gmail_com", + "clientId": "client_public_pool", + "userId": "user_alah_gmail_com", + "role": "member", + "status": "active", + "invitedByUserId": "user_ayo_ayo_ae_gmail_com", + "inviteId": "invite_ayoyoyo_alah_gmail_com_2", + "source": "tasker_workspace_invite", + "sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com_2", + "createdAt": "2026-05-09T17:26:58.823Z", + "updatedAt": "2026-05-09T18:58:27.834Z" + }, + { + "id": "mem_client_public_pool_pupa_mail_ru", + "clientId": "client_public_pool", + "userId": "user_pupa_mail_ru", + "role": "member", + "status": "active", + "invitedByUserId": "user_ayo_ayo_ae_gmail_com", + "inviteId": "invite_ayoyoyo_pupa_mail_ru", + "source": "tasker_workspace_invite", + "sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_pupa_mail_ru", + "createdAt": "2026-05-09T19:37:43.521Z", + "updatedAt": "2026-05-09T19:37:43.521Z" } ], "groups": [ @@ -439,6 +515,36 @@ "status": "active", "createdAt": "2026-05-05T16:04:52.709Z", "updatedAt": "2026-05-08T10:14:37.303Z" + }, + { + "id": "grant_task_manager_user_ayo_ayo_ae_gmail_com", + "serviceId": "service_task_manager", + "targetType": "user", + "targetId": "user_ayo_ayo_ae_gmail_com", + "appRole": "admin", + "status": "active", + "createdAt": "2026-05-09T12:34:38.766Z", + "updatedAt": "2026-05-09T12:34:38.766Z" + }, + { + "id": "grant_task_manager_user_alah_gmail_com", + "serviceId": "service_task_manager", + "targetType": "user", + "targetId": "user_alah_gmail_com", + "appRole": "member", + "status": "active", + "createdAt": "2026-05-09T18:58:27.834Z", + "updatedAt": "2026-05-09T18:58:27.834Z" + }, + { + "id": "grant_task_manager_user_pupa_mail_ru", + "serviceId": "service_task_manager", + "targetType": "user", + "targetId": "user_pupa_mail_ru", + "appRole": "member", + "status": "active", + "createdAt": "2026-05-09T19:37:43.521Z", + "updatedAt": "2026-05-09T19:37:43.521Z" } ], "exceptions": [], @@ -502,6 +608,90 @@ "status": "accepted", "createdAt": "2026-05-06T01:04:54.007Z", "updatedAt": "2026-05-06T01:06:48.113Z" + }, + { + "id": "invite_ayo_ayo_ae_gmail_com", + "clientId": "client_public_pool", + "email": "ayo.ayo.ae@gmail.com", + "role": "member", + "invitedByUserId": "user_root", + "token": "2555c4e6-6a84-429c-8f21-bb41a5e51c28", + "expiresAt": "2026-05-16T11:32:40.371Z", + "status": "accepted", + "createdAt": "2026-05-09T11:32:40.370Z", + "updatedAt": "2026-05-09T11:33:58.892Z" + }, + { + "id": "invite_ayoyoyo_alah_mail_ru", + "clientId": "client_public_pool", + "email": "alah@mail.ru", + "role": "member", + "invitedByUserId": "user_ayo_ayo_ae_gmail_com", + "source": "tasker_workspace_invite", + "sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_mail_ru_2", + "sourceTaskerInviteId": "bcb82953-f277-4f0c-a61a-1b0791a8a381", + "sourceWorkspaceSlug": "ayoyoyo", + "sourceWorkspaceName": "AYOYOYO", + "sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=bcb82953-f277-4f0c-a61a-1b0791a8a381&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzM2NjcxLjIzNTc3fQ.wyKAbnfPd2NasrAEbmg8KaYo-nOGFKHgC0nERaekuys", + "token": "e0726f74-82c0-49d8-bd08-e6df097bde44", + "expiresAt": "2026-05-16T17:08:10.886Z", + "status": "revoked", + "createdAt": "2026-05-09T17:08:10.886Z", + "updatedAt": "2026-05-09T17:25:17.778Z" + }, + { + "id": "invite_ayoyoyo_alah_gmail_com", + "clientId": "client_public_pool", + "email": "alah@gmail.com", + "role": "member", + "invitedByUserId": "user_ayo_ayo_ae_gmail_com", + "source": "tasker_workspace_invite", + "sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com", + "sourceTaskerInviteId": "359913f5-80e6-4772-82f9-19e653cf1147", + "sourceWorkspaceSlug": "ayoyoyo", + "sourceWorkspaceName": "AYOYOYO", + "sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=359913f5-80e6-4772-82f9-19e653cf1147&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNDc1NTQuMzg3MTV9.ECLpdg1RAaCll_GfNZEx1OTI3sf3bfuRxmJShpEZruM", + "token": "410184c9-926b-4e6a-8649-3c0178c7d248", + "expiresAt": "2026-05-16T17:26:04.673Z", + "status": "accepted", + "createdAt": "2026-05-09T17:26:04.673Z", + "updatedAt": "2026-05-09T17:26:58.823Z" + }, + { + "id": "invite_ayoyoyo_alah_gmail_com_2", + "clientId": "client_public_pool", + "email": "alah@gmail.com", + "role": "member", + "invitedByUserId": "user_ayo_ayo_ae_gmail_com", + "source": "tasker_workspace_invite", + "sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com_2", + "sourceTaskerInviteId": "c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c", + "sourceWorkspaceSlug": "ayoyoyo", + "sourceWorkspaceName": "AYOYOYO", + "sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNTMwNTIuMzYxODc5fQ.NtsQ5-48bXpOCgU7Z4u4QxLHZZvgiWVXtJ6e_yUrpfg", + "token": "c8ca208c-7a8b-4d32-8559-81bf6fd360d9", + "expiresAt": "2026-05-16T18:58:00.118Z", + "status": "accepted", + "createdAt": "2026-05-09T18:58:00.118Z", + "updatedAt": "2026-05-09T18:58:27.834Z" + }, + { + "id": "invite_ayoyoyo_pupa_mail_ru", + "clientId": "client_public_pool", + "email": "pupa@mail.ru", + "role": "member", + "invitedByUserId": "user_ayo_ayo_ae_gmail_com", + "source": "tasker_workspace_invite", + "sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_pupa_mail_ru", + "sourceTaskerInviteId": "9b470198-5685-4ebf-b330-75b357363e97", + "sourceWorkspaceSlug": "ayoyoyo", + "sourceWorkspaceName": "AYOYOYO", + "sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=9b470198-5685-4ebf-b330-75b357363e97&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6InB1cGFAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzU1Mzc5LjI4MDg0MX0.q9fLuUBLXNYK9XZk0y02zjc55uixPfKTMiS92oOfEoU", + "token": "dee0814c-917a-456c-be5d-f8da468136c9", + "expiresAt": "2026-05-16T19:36:41.118Z", + "status": "accepted", + "createdAt": "2026-05-09T19:36:41.118Z", + "updatedAt": "2026-05-09T19:37:43.521Z" } ], "syncStatuses": [ @@ -746,9 +936,97 @@ "lastSyncAt": null, "error": null, "updatedAt": "2026-05-06T01:28:06.516Z" + }, + { + "id": "sync_invite_invite_ayo_ayo_ae_gmail_com", + "objectId": "invite_ayo_ayo_ae_gmail_com", + "objectName": "ayo.ayo.ae@gmail.com", + "objectType": "invite", + "target": "authentik", + "state": "pending", + "lastSyncAt": null, + "error": null, + "updatedAt": "2026-05-09T11:32:51.152Z" + }, + { + "id": "sync_user_user_ayo_ayo_ae_gmail_com", + "objectId": "user_ayo_ayo_ae_gmail_com", + "objectName": "ayo.ayo.ae@gmail.com", + "objectType": "user", + "target": "authentik", + "state": "synced", + "lastSyncAt": "2026-05-09T12:34:39.358Z", + "error": null, + "updatedAt": "2026-05-09T12:34:39.358Z" + }, + { + "id": "sync_grant_service_task_manager_user_ayo_ayo_ae_gmail_com", + "objectId": "service_task_manager:user_ayo_ayo_ae_gmail_com", + "objectName": "task-manager:ayo.ayo.ae@gmail.com", + "objectType": "grant", + "target": "authentik", + "state": "pending", + "lastSyncAt": null, + "error": null, + "updatedAt": "2026-05-09T12:34:38.766Z" + }, + { + "id": "sync_grant_service_task_manager_user_alah_gmail_com", + "objectId": "service_task_manager:user_alah_gmail_com", + "objectName": "task-manager:alah@gmail.com", + "objectType": "grant", + "target": "authentik", + "state": "pending", + "lastSyncAt": null, + "error": null, + "updatedAt": "2026-05-09T18:58:27.834Z" + }, + { + "id": "sync_user_user_alah_gmail_com", + "objectId": "user_alah_gmail_com", + "objectName": "alah@gmail.com", + "objectType": "user", + "target": "authentik", + "state": "synced", + "lastSyncAt": "2026-05-09T18:58:28.494Z", + "error": null, + "updatedAt": "2026-05-09T18:58:28.494Z" + }, + { + "id": "sync_grant_service_task_manager_user_pupa_mail_ru", + "objectId": "service_task_manager:user_pupa_mail_ru", + "objectName": "task-manager:pupa@mail.ru", + "objectType": "grant", + "target": "authentik", + "state": "pending", + "lastSyncAt": null, + "error": null, + "updatedAt": "2026-05-09T19:37:43.521Z" + }, + { + "id": "sync_user_user_pupa_mail_ru", + "objectId": "user_pupa_mail_ru", + "objectName": "pupa@mail.ru", + "objectType": "user", + "target": "authentik", + "state": "synced", + "lastSyncAt": "2026-05-09T19:37:43.533Z", + "error": null, + "updatedAt": "2026-05-09T19:37:43.533Z" } ], "auditEvents": [ + { + "id": "audit_1778336274475_tasker_invite_cancelled", + "createdAt": "2026-05-09T14:17:54.470Z", + "actorUserId": null, + "actorName": "Operational Core", + "action": "Отозвана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@mail.ru", + "result": "warning", + "details": "Отозвано в Operational Core" + }, { "id": "audit_live_seed_control_plane", "at": "2026-05-04T12:55:13.842Z", @@ -2920,6 +3198,462 @@ "clientId": "client_2", "result": "warning", "details": null + }, + { + "id": "audit_ayo_ayo_ae_gmail_com", + "at": "2026-05-09T11:01:08.503Z", + "actorUserId": "public", + "actorName": "DC", + "action": "Создана публичная заявка", + "objectType": "access_request", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": "DC" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_2", + "at": "2026-05-09T11:32:40.371Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Подтверждена публичная заявка", + "objectType": "access_request", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": "Invite: invite_ayo_ayo_ae_gmail_com; target: Открытый контур; role: member" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_3", + "at": "2026-05-09T11:32:51.152Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён инвайт", + "objectType": "invite", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": "client_public_pool", + "result": "success", + "details": null + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_4", + "at": "2026-05-09T11:33:58.892Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Регистрация по инвайту", + "objectType": "invite", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": "client_public_pool", + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_5", + "at": "2026-05-09T11:33:58.934Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_6", + "at": "2026-05-09T11:35:48.963Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Обновлён профиль пользователя", + "objectType": "user", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": null + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_7", + "at": "2026-05-09T11:35:49.157Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_task_manager", + "at": "2026-05-09T12:23:47.333Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "ayo.ayo.ae@gmail.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: admin" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_8", + "at": "2026-05-09T12:23:50.206Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, nodedc:taskmanager:admin" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_task_manager_2", + "at": "2026-05-09T12:24:41.929Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "ayo.ayo.ae@gmail.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: deny" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_9", + "at": "2026-05-09T12:24:42.117Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_task_manager_3", + "at": "2026-05-09T12:34:38.766Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлён доступ пользователя к сервису", + "objectType": "grant", + "objectName": "ayo.ayo.ae@gmail.com / task-manager", + "clientId": null, + "result": "success", + "details": "Value: admin" + }, + { + "id": "audit_ayo_ayo_ae_gmail_com_10", + "at": "2026-05-09T12:34:39.358Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "ayo.ayo.ae@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, nodedc:taskmanager:admin" + }, + { + "id": "audit_ayoyoyo_alah_mail_ru", + "at": "2026-05-09T13:43:17.849Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Создана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@mail.ru", + "clientId": null, + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_ayoyoyo_alah_mail_ru_2", + "at": "2026-05-09T13:43:47.185Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Подтверждена заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@mail.ru", + "clientId": null, + "result": "success", + "details": "http://task.local.nodedc/workspace-invitations/?invitation_id=af8c6a1c-9ac2-44c8-9023-f27bc93d76d8&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzM0MTk3Ljc4NjAyMX0.iup2nsWz2r0LCbOv8vH6INI0fo7dWdLfN2Jtxz76E-I" + }, + { + "id": "audit_ayoyoyo_alah_mail_ru_3", + "at": "2026-05-09T14:24:31.257Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Создана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@mail.ru", + "clientId": null, + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_ayoyoyo_alah_mail_ru_4", + "at": "2026-05-09T14:24:41.276Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Подтверждена заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@mail.ru", + "clientId": null, + "result": "success", + "details": "http://task.local.nodedc/workspace-invitations/?invitation_id=bcb82953-f277-4f0c-a61a-1b0791a8a381&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzM2NjcxLjIzNTc3fQ.wyKAbnfPd2NasrAEbmg8KaYo-nOGFKHgC0nERaekuys" + }, + { + "id": "audit_alah_mail_ru", + "at": "2026-05-09T17:08:10.887Z", + "actorUserId": "system", + "actorName": "NODE.DC Root", + "action": "Создан platform-инвайт для workspace-инвайта", + "objectType": "invite", + "objectName": "alah@mail.ru", + "clientId": "client_public_pool", + "result": "success", + "details": "ayoyoyo; inviter: ayo.ayo.ae@gmail.com" + }, + { + "id": "audit_ayoyoyo_alah_mail_ru_5", + "at": "2026-05-09T17:25:17.781Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Отозвана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@mail.ru", + "clientId": null, + "result": "warning", + "details": "Отозвано в Operational Core" + }, + { + "id": "audit_ayoyoyo_alah_gmail_com", + "at": "2026-05-09T17:25:54.406Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Создана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@gmail.com", + "clientId": null, + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_alah_gmail_com", + "at": "2026-05-09T17:26:04.674Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Создан platform-инвайт для workspace-инвайта", + "objectType": "invite", + "objectName": "alah@gmail.com", + "clientId": "client_public_pool", + "result": "success", + "details": "ayoyoyo; inviter: ayo.ayo.ae@gmail.com" + }, + { + "id": "audit_ayoyoyo_alah_gmail_com_2", + "at": "2026-05-09T17:26:04.726Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Подтверждена заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@gmail.com", + "clientId": null, + "result": "success", + "details": "http://task.local.nodedc/workspace-invitations/?invitation_id=359913f5-80e6-4772-82f9-19e653cf1147&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNDc1NTQuMzg3MTV9.ECLpdg1RAaCll_GfNZEx1OTI3sf3bfuRxmJShpEZruM" + }, + { + "id": "audit_alah_gmail_com_2", + "at": "2026-05-09T17:26:58.823Z", + "actorUserId": "user_alah_gmail_com", + "actorName": "ALAH", + "action": "Регистрация по инвайту", + "objectType": "invite", + "objectName": "alah@gmail.com", + "clientId": "client_public_pool", + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_alah_gmail_com_3", + "at": "2026-05-09T17:26:58.829Z", + "actorUserId": "user_alah_gmail_com", + "actorName": "ALAH", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "alah@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" + }, + { + "id": "audit_ayoyoyo_alah_gmail_com_3", + "at": "2026-05-09T18:01:41.568Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Отозвана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@gmail.com", + "clientId": null, + "result": "warning", + "details": "Отозвано в Operational Core" + }, + { + "id": "audit_alah_gmail_com_task_manager", + "at": "2026-05-09T18:22:48.238Z", + "actorUserId": "user_alah_gmail_com", + "actorName": "ALAH", + "action": "Снят доступ Operational Core по workspace-инвайту", + "objectType": "grant", + "objectName": "alah@gmail.com / task-manager", + "clientId": null, + "result": "warning", + "details": "Workspace invite: ayoyoyo; cancelled at: 2026-05-09T18:22:48.238Z" + }, + { + "id": "audit_ayoyoyo_alah_gmail_com_4", + "at": "2026-05-09T18:22:48.238Z", + "actorUserId": "system", + "actorName": "NODE.DC cleanup", + "action": "Отозвана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@gmail.com", + "clientId": null, + "result": "warning", + "details": "Пользователь удалён из workspace Operational Core." + }, + { + "id": "audit_alah_gmail_com_4", + "at": "2026-05-09T18:26:10.793Z", + "actorUserId": "system", + "actorName": "NODE.DC sync cleanup", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "alah@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user" + }, + { + "id": "audit_ayoyoyo_alah_gmail_com_5", + "at": "2026-05-09T18:57:32.381Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Создана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@gmail.com", + "clientId": null, + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_alah_gmail_com_5", + "at": "2026-05-09T18:58:00.118Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Создан platform-инвайт для workspace-инвайта", + "objectType": "invite", + "objectName": "alah@gmail.com", + "clientId": "client_public_pool", + "result": "success", + "details": "ayoyoyo; inviter: ayo.ayo.ae@gmail.com" + }, + { + "id": "audit_ayoyoyo_alah_gmail_com_6", + "at": "2026-05-09T18:58:00.166Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Подтверждена заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:alah@gmail.com", + "clientId": null, + "result": "success", + "details": "http://task.local.nodedc/workspace-invitations/?invitation_id=c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNTMwNTIuMzYxODc5fQ.NtsQ5-48bXpOCgU7Z4u4QxLHZZvgiWVXtJ6e_yUrpfg" + }, + { + "id": "audit_alah_gmail_com_6", + "at": "2026-05-09T18:58:27.834Z", + "actorUserId": "user_alah_gmail_com", + "actorName": "ALAH", + "action": "Инвайт принят", + "objectType": "invite", + "objectName": "alah@gmail.com", + "clientId": "client_public_pool", + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_alah_gmail_com_7", + "at": "2026-05-09T18:58:28.494Z", + "actorUserId": "user_alah_gmail_com", + "actorName": "ALAH", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "alah@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" + }, + { + "id": "audit_ayoyoyo_pupa_mail_ru", + "at": "2026-05-09T19:36:19.366Z", + "actorUserId": "user_ayo_ayo_ae_gmail_com", + "actorName": "Anna Ayo", + "action": "Создана заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:pupa@mail.ru", + "clientId": null, + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_pupa_mail_ru", + "at": "2026-05-09T19:36:41.118Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Создан platform-инвайт для workspace-инвайта", + "objectType": "invite", + "objectName": "pupa@mail.ru", + "clientId": "client_public_pool", + "result": "success", + "details": "ayoyoyo; inviter: ayo.ayo.ae@gmail.com" + }, + { + "id": "audit_ayoyoyo_pupa_mail_ru_2", + "at": "2026-05-09T19:36:41.170Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Подтверждена заявка workspace-инвайта", + "objectType": "tasker_invite_request", + "objectName": "ayoyoyo:pupa@mail.ru", + "clientId": null, + "result": "success", + "details": "http://task.local.nodedc/workspace-invitations/?invitation_id=9b470198-5685-4ebf-b330-75b357363e97&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6InB1cGFAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzU1Mzc5LjI4MDg0MX0.q9fLuUBLXNYK9XZk0y02zjc55uixPfKTMiS92oOfEoU" + }, + { + "id": "audit_pupa_mail_ru_2", + "at": "2026-05-09T19:37:43.521Z", + "actorUserId": "user_pupa_mail_ru", + "actorName": "PUPA", + "action": "Регистрация по инвайту", + "objectType": "invite", + "objectName": "pupa@mail.ru", + "clientId": "client_public_pool", + "result": "success", + "details": "Role: member" + }, + { + "id": "audit_pupa_mail_ru_3", + "at": "2026-05-09T19:37:43.533Z", + "actorUserId": "user_pupa_mail_ru", + "actorName": "PUPA", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "pupa@mail.ru", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" } ], "settings": { @@ -3049,5 +3783,137 @@ "planeRole": 15, "updatedAt": "2026-05-08T11:52:50.104Z" } + ], + "accessRequests": [ + { + "id": "access_request_ayo_ayo_ae_gmail_com", + "email": "ayo.ayo.ae@gmail.com", + "firstName": "Anna", + "lastName": "Ayo", + "middleName": "Ayo", + "phone": "+7 (925) 420-88-84", + "company": "DC", + "status": "approved", + "targetClientId": "client_public_pool", + "role": "member", + "approvedInviteId": "invite_ayo_ayo_ae_gmail_com", + "reviewedByUserId": "user_root", + "reviewedAt": "2026-05-09T11:32:40.370Z", + "comment": null, + "createdAt": "2026-05-09T11:01:08.481Z", + "updatedAt": "2026-05-09T11:32:40.370Z" + } + ], + "taskerInviteRequests": [ + { + "id": "tasker_invite_request_ayoyoyo_alah_mail_ru", + "taskerInviteId": "af8c6a1c-9ac2-44c8-9023-f27bc93d76d8", + "workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2", + "workspaceSlug": "ayoyoyo", + "workspaceName": "AYOYOYO", + "inviteeEmail": "alah@mail.ru", + "role": "member", + "inviterUserId": "user_ayo_ayo_ae_gmail_com", + "inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39", + "inviterEmail": "ayo.ayo.ae@gmail.com", + "inviterName": "Anna Ayo", + "status": "cancelled", + "taskerInviteLink": null, + "platformInviteId": null, + "platformInviteToken": null, + "reviewedByUserId": "user_root", + "reviewedAt": "2026-05-09T13:43:47.185Z", + "comment": "Отозвано в Operational Core", + "createdAt": "2026-05-09T13:43:17.845Z", + "updatedAt": "2026-05-09T14:17:54.470Z" + }, + { + "id": "tasker_invite_request_ayoyoyo_alah_mail_ru_2", + "taskerInviteId": "bcb82953-f277-4f0c-a61a-1b0791a8a381", + "workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2", + "workspaceSlug": "ayoyoyo", + "workspaceName": "AYOYOYO", + "inviteeEmail": "alah@mail.ru", + "role": "member", + "inviterUserId": "user_ayo_ayo_ae_gmail_com", + "inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39", + "inviterEmail": "ayo.ayo.ae@gmail.com", + "inviterName": "Anna Ayo", + "status": "cancelled", + "taskerInviteLink": null, + "platformInviteId": "invite_ayoyoyo_alah_mail_ru", + "platformInviteToken": "e0726f74-82c0-49d8-bd08-e6df097bde44", + "reviewedByUserId": "user_root", + "reviewedAt": "2026-05-09T14:24:41.276Z", + "comment": "Отозвано в Operational Core", + "createdAt": "2026-05-09T14:24:31.256Z", + "updatedAt": "2026-05-09T17:25:17.778Z" + }, + { + "id": "tasker_invite_request_ayoyoyo_alah_gmail_com", + "taskerInviteId": "359913f5-80e6-4772-82f9-19e653cf1147", + "workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2", + "workspaceSlug": "ayoyoyo", + "workspaceName": "AYOYOYO", + "inviteeEmail": "alah@gmail.com", + "role": "member", + "inviterUserId": "user_ayo_ayo_ae_gmail_com", + "inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39", + "inviterEmail": "ayo.ayo.ae@gmail.com", + "inviterName": "Anna Ayo", + "status": "cancelled", + "taskerInviteLink": null, + "platformInviteId": "invite_ayoyoyo_alah_gmail_com", + "platformInviteToken": "410184c9-926b-4e6a-8649-3c0178c7d248", + "reviewedByUserId": "user_root", + "reviewedAt": "2026-05-09T17:26:04.726Z", + "comment": "Пользователь удалён из workspace Operational Core.", + "createdAt": "2026-05-09T17:25:54.405Z", + "updatedAt": "2026-05-09T18:22:48.238Z" + }, + { + "id": "tasker_invite_request_ayoyoyo_alah_gmail_com_2", + "taskerInviteId": "c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c", + "workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2", + "workspaceSlug": "ayoyoyo", + "workspaceName": "AYOYOYO", + "inviteeEmail": "alah@gmail.com", + "role": "member", + "inviterUserId": "user_ayo_ayo_ae_gmail_com", + "inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39", + "inviterEmail": "ayo.ayo.ae@gmail.com", + "inviterName": "Anna Ayo", + "status": "approved", + "taskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNTMwNTIuMzYxODc5fQ.NtsQ5-48bXpOCgU7Z4u4QxLHZZvgiWVXtJ6e_yUrpfg", + "platformInviteId": "invite_ayoyoyo_alah_gmail_com_2", + "platformInviteToken": "c8ca208c-7a8b-4d32-8559-81bf6fd360d9", + "reviewedByUserId": "user_root", + "reviewedAt": "2026-05-09T18:58:00.166Z", + "comment": null, + "createdAt": "2026-05-09T18:57:32.379Z", + "updatedAt": "2026-05-09T18:58:00.166Z" + }, + { + "id": "tasker_invite_request_ayoyoyo_pupa_mail_ru", + "taskerInviteId": "9b470198-5685-4ebf-b330-75b357363e97", + "workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2", + "workspaceSlug": "ayoyoyo", + "workspaceName": "AYOYOYO", + "inviteeEmail": "pupa@mail.ru", + "role": "member", + "inviterUserId": "user_ayo_ayo_ae_gmail_com", + "inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39", + "inviterEmail": "ayo.ayo.ae@gmail.com", + "inviterName": "Anna Ayo", + "status": "approved", + "taskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=9b470198-5685-4ebf-b330-75b357363e97&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6InB1cGFAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzU1Mzc5LjI4MDg0MX0.q9fLuUBLXNYK9XZk0y02zjc55uixPfKTMiS92oOfEoU", + "platformInviteId": "invite_ayoyoyo_pupa_mail_ru", + "platformInviteToken": "dee0814c-917a-456c-be5d-f8da468136c9", + "reviewedByUserId": "user_root", + "reviewedAt": "2026-05-09T19:36:41.169Z", + "comment": null, + "createdAt": "2026-05-09T19:36:19.353Z", + "updatedAt": "2026-05-09T19:36:41.169Z" + } ] } diff --git a/public/storage/uploads/1778326544251-ddfabfce-31e1669e-28da-428a-9e2a-d51a781f041f.jpg b/public/storage/uploads/1778326544251-ddfabfce-31e1669e-28da-428a-9e2a-d51a781f041f.jpg new file mode 100644 index 0000000..e816b37 Binary files /dev/null and b/public/storage/uploads/1778326544251-ddfabfce-31e1669e-28da-428a-9e2a-d51a781f041f.jpg differ diff --git a/server/authentik-sync.mjs b/server/authentik-sync.mjs index 9a766bc..5001536 100644 --- a/server/authentik-sync.mjs +++ b/server/authentik-sync.mjs @@ -7,6 +7,11 @@ const platformGroups = { taskManagerAdmin: "nodedc:taskmanager:admin", taskManagerUser: "nodedc:taskmanager:user", }; +const publicPoolClientId = "client_public_pool"; +const publicPoolClient = { + id: publicPoolClientId, + status: "active", +}; export function createAuthentikSyncClient({ baseUrl, token }) { const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, ""); @@ -172,7 +177,7 @@ export function resolveRequiredGroups(data, user) { return [...groupNames]; } - for (const client of data.clients) { + for (const client of getUserRuntimeClients(data, user.id)) { const membership = getRuntimeMembership(data, user.id, client.id); if (membership.status !== "active") { @@ -203,6 +208,19 @@ export function resolveRequiredGroups(data, user) { return [...groupNames]; } +function getUserRuntimeClients(data, userId) { + const clients = [...data.clients]; + const hasPublicPoolMembership = data.memberships.some( + (membership) => membership.userId === userId && membership.clientId === publicPoolClientId + ); + + if (hasPublicPoolMembership) { + clients.push(publicPoolClient); + } + + return clients; +} + function generatePasswordValue() { return `NDC-${randomBytes(15).toString("base64url")}`; } diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 82f9f91..2023b94 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -12,6 +12,8 @@ const collectionKeys = [ "grants", "exceptions", "invites", + "accessRequests", + "taskerInviteRequests", "syncStatuses", "auditEvents", "taskManagerMemberships", @@ -28,6 +30,27 @@ const grantStatuses = new Set(["active", "disabled"]); const exceptionTypes = new Set(["deny", "allow"]); const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]); const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]); +const accessRequestStatuses = new Set(["new", "approved", "rejected"]); +const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); +const taskManagerInviteRoles = new Set(["guest", "member", "admin"]); +const publicPoolClientId = "client_public_pool"; +const publicPoolClient = { + id: publicPoolClientId, + type: "person", + name: "Открытый контур", + legalName: "Public access pool", + status: "active", + contractStartsAt: null, + contractEndsAt: null, + paidUntil: null, + demoEndsAt: null, + contactName: "NODE.DC", + contactEmail: null, + avatarUrl: null, + notes: "Системный контур для публичных заявок, публичных инвайтов и self-service пользователей.", + createdAt: "2026-05-09T00:00:00.000Z", + updatedAt: "2026-05-09T00:00:00.000Z", +}; const defaultSettings = { brand: { logoLinkUrl: "/", @@ -386,7 +409,7 @@ export function createControlPlaneStore({ projectRoot }) { const actor = resolveActor(data, identity); const now = isoNow(); const clientId = requireString(payload?.clientId, "clientId"); - const client = findById(data.clients, clientId, "client"); + const client = findClientById(data, clientId); const email = requireString(payload?.email, "email").toLowerCase(); const existingUser = data.users.find((item) => item.email.toLowerCase() === email); const user = @@ -429,6 +452,10 @@ export function createControlPlaneStore({ projectRoot }) { userId: user.id, role: pickEnum(payload?.role, membershipRoles, "member"), status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"), + invitedByUserId: actor.id, + inviteId: null, + source: "launcher", + sourceTaskerInviteRequestId: null, createdAt: now, updatedAt: now, }; @@ -519,7 +546,7 @@ export function createControlPlaneStore({ projectRoot }) { const actor = resolveActor(data, identity); const now = isoNow(); const clientId = requireString(payload?.clientId, "clientId"); - const client = findById(data.clients, clientId, "client"); + const client = findClientById(data, clientId); 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()); @@ -529,6 +556,11 @@ export function createControlPlaneStore({ projectRoot }) { email, role, invitedByUserId: actor.id, + source: "launcher", + sourceTaskerInviteRequestId: null, + sourceTaskerInviteId: null, + sourceWorkspaceSlug: null, + sourceWorkspaceName: null, token: randomUUID(), expiresAt, status: "created", @@ -551,6 +583,407 @@ export function createControlPlaneStore({ projectRoot }) { return { invite, data }; } + async function createAccessRequest(payload) { + const data = readData(); + const now = isoNow(); + const requestPayload = sanitizeAccessRequestPayload(payload); + const email = requestPayload.email.toLowerCase(); + const existingRequest = data.accessRequests.find( + (candidate) => candidate.email.toLowerCase() === email && candidate.status === "new" + ); + + if (existingRequest) { + Object.assign(existingRequest, { + ...requestPayload, + email, + updatedAt: now, + }); + addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, { + action: "Обновлена публичная заявка", + objectType: "access_request", + objectName: email, + clientId: null, + result: "success", + details: requestPayload.company, + }); + + await writeData(data); + return { accessRequest: existingRequest, data }; + } + + const accessRequest = { + id: uniqueId(data.accessRequests, "access_request", email), + ...requestPayload, + email, + status: "new", + targetClientId: publicPoolClientId, + role: "member", + approvedInviteId: null, + reviewedByUserId: null, + reviewedAt: null, + comment: null, + createdAt: now, + updatedAt: now, + }; + + data.accessRequests.push(accessRequest); + addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, { + action: "Создана публичная заявка", + objectType: "access_request", + objectName: email, + clientId: null, + result: "success", + details: requestPayload.company, + }); + + await writeData(data); + return { accessRequest, data }; + } + + async function updateAccessRequest(accessRequestId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const accessRequest = findAccessRequestById(data, accessRequestId); + + if (payload?.targetClientId !== undefined) { + accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId); + } + + accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role); + accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null); + accessRequest.updatedAt = isoNow(); + + addAuditEvent(data, actor, { + action: "Обновлена публичная заявка", + objectType: "access_request", + objectName: accessRequest.email, + clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId, + result: "success", + details: `Target: ${accessRequest.targetClientId}; role: ${accessRequest.role}`, + }); + + await writeData(data); + return { accessRequest, data }; + } + + async function approveAccessRequest(accessRequestId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const accessRequest = findAccessRequestById(data, accessRequestId); + + if (accessRequest.status === "rejected") { + throw new Error("Отклонённую заявку нельзя подтвердить без повторного запроса"); + } + + if (payload?.targetClientId !== undefined) { + accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId); + } else { + accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, accessRequest.targetClientId, publicPoolClientId); + } + + accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role); + accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null); + + if (accessRequest.status === "approved" && accessRequest.approvedInviteId) { + const existingInvite = findById(data.invites, accessRequest.approvedInviteId, "invite"); + return { accessRequest, invite: existingInvite, data }; + } + + const client = findClientById(data, accessRequest.targetClientId); + const invite = { + id: uniqueId(data.invites, "invite", accessRequest.email), + clientId: client.id, + email: accessRequest.email, + role: accessRequest.role, + invitedByUserId: actor.id, + source: "access_request", + sourceTaskerInviteRequestId: null, + sourceTaskerInviteId: null, + sourceWorkspaceSlug: null, + sourceWorkspaceName: null, + token: randomUUID(), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + status: "created", + createdAt: now, + updatedAt: now, + }; + + data.invites.push(invite); + accessRequest.status = "approved"; + accessRequest.approvedInviteId = invite.id; + accessRequest.reviewedByUserId = actor.id; + accessRequest.reviewedAt = now; + accessRequest.updatedAt = now; + + addAuditEvent(data, actor, { + action: "Подтверждена публичная заявка", + objectType: "access_request", + objectName: accessRequest.email, + clientId: client.id === publicPoolClientId ? null : client.id, + result: "success", + details: `Invite: ${invite.id}; target: ${client.name}; role: ${invite.role}`, + }); + markPendingSync(data, invite, "invite", invite.email); + + await writeData(data); + return { accessRequest, invite, data }; + } + + async function rejectAccessRequest(accessRequestId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const accessRequest = findAccessRequestById(data, accessRequestId); + const now = isoNow(); + + if (accessRequest.status === "approved") { + throw new Error("Подтверждённую заявку нельзя отклонить"); + } + + accessRequest.status = "rejected"; + accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null); + accessRequest.reviewedByUserId = actor.id; + accessRequest.reviewedAt = now; + accessRequest.updatedAt = now; + + addAuditEvent(data, actor, { + action: "Отклонена публичная заявка", + objectType: "access_request", + objectName: accessRequest.email, + clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId, + result: "warning", + details: accessRequest.comment, + }); + + await writeData(data); + return { accessRequest, data }; + } + + async function createTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) { + const data = readData(); + const now = isoNow(); + const actor = resolveActor(data, identity); + const taskerInviteId = requireString(payload?.taskerInviteId, "taskerInviteId"); + const workspaceSlug = requireString(payload?.workspaceSlug, "workspaceSlug"); + const workspaceName = optionalString(payload?.workspaceName, workspaceSlug); + const inviteeEmail = requireString(payload?.inviteeEmail, "inviteeEmail").toLowerCase(); + const role = normalizeTaskManagerInviteRole(payload?.role); + const existingRequest = data.taskerInviteRequests.find( + (request) => + request.taskerInviteId === taskerInviteId || + (request.status === "new" && request.workspaceSlug === workspaceSlug && request.inviteeEmail.toLowerCase() === inviteeEmail) + ); + const request = + existingRequest ?? + { + id: uniqueId(data.taskerInviteRequests, "tasker_invite_request", `${workspaceSlug}-${inviteeEmail}`), + taskerInviteId, + createdAt: now, + }; + + Object.assign(request, { + taskerInviteId, + workspaceId: nullableStringWithFallback(payload?.workspaceId, request.workspaceId ?? null), + workspaceSlug, + workspaceName, + inviteeEmail, + role, + inviterUserId: nullableStringWithFallback(payload?.inviterUserId, request.inviterUserId ?? null), + inviterPlaneUserId: nullableStringWithFallback(payload?.inviterPlaneUserId, request.inviterPlaneUserId ?? null), + inviterEmail: requireString(payload?.inviterEmail, "inviterEmail").toLowerCase(), + inviterName: optionalString(payload?.inviterName, payload?.inviterEmail ?? "Operational Core user"), + status: existingRequest?.status && existingRequest.status !== "rejected" ? existingRequest.status : "new", + taskerInviteLink: existingRequest?.taskerInviteLink ?? null, + reviewedByUserId: existingRequest?.reviewedByUserId ?? null, + reviewedAt: existingRequest?.reviewedAt ?? null, + comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null), + updatedAt: now, + }); + + if (!existingRequest) { + data.taskerInviteRequests.push(request); + } + + addAuditEvent(data, actor, { + action: existingRequest ? "Обновлена заявка workspace-инвайта" : "Создана заявка workspace-инвайта", + objectType: "tasker_invite_request", + objectName: `${workspaceSlug}:${inviteeEmail}`, + result: "success", + details: `Role: ${role}`, + }); + + await writeData(data); + return { taskerInviteRequest: request, data }; + } + + async function approveTaskerInviteRequest(taskerInviteRequestId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const request = findTaskerInviteRequestById(data, taskerInviteRequestId); + const now = isoNow(); + + if (request.status === "rejected") { + throw new Error("Отклонённую заявку workspace-инвайта нельзя подтвердить"); + } + + request.status = "approved"; + request.taskerInviteLink = nullableStringWithFallback(payload?.taskerInviteLink, request.taskerInviteLink ?? null); + request.platformInviteId = nullableStringWithFallback(payload?.platformInviteId, request.platformInviteId ?? null); + request.platformInviteToken = nullableStringWithFallback(payload?.platformInviteToken, request.platformInviteToken ?? null); + request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null); + request.reviewedByUserId = actor.id; + request.reviewedAt = now; + request.updatedAt = now; + + if (request.platformInviteId) { + const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId); + if (invite) { + invite.sourceTaskerInviteLink = request.taskerInviteLink ?? invite.sourceTaskerInviteLink ?? null; + invite.updatedAt = now; + } + } + + addAuditEvent(data, actor, { + action: "Подтверждена заявка workspace-инвайта", + objectType: "tasker_invite_request", + objectName: `${request.workspaceSlug}:${request.inviteeEmail}`, + result: "success", + details: request.taskerInviteLink ?? null, + }); + + await writeData(data); + return { taskerInviteRequest: request, data }; + } + + async function ensureTaskerInvitePlatformInvite(taskerInviteRequestId, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const request = findTaskerInviteRequestById(data, taskerInviteRequestId); + const now = isoNow(); + const client = findClientById(data, publicPoolClientId); + let invite = request.platformInviteId ? data.invites.find((candidate) => candidate.id === request.platformInviteId) : null; + + if (!invite) { + invite = data.invites.find( + (candidate) => + candidate.source === "tasker_workspace_invite" && + candidate.sourceTaskerInviteRequestId === request.id && + candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase() + ); + } + + if (!invite) { + invite = { + id: uniqueId(data.invites, "invite", `${request.workspaceSlug}-${request.inviteeEmail}`), + clientId: client.id, + email: request.inviteeEmail, + role: "member", + invitedByUserId: request.inviterUserId || actor.id, + source: "tasker_workspace_invite", + sourceTaskerInviteRequestId: request.id, + sourceTaskerInviteId: request.taskerInviteId, + sourceWorkspaceSlug: request.workspaceSlug, + sourceWorkspaceName: request.workspaceName, + sourceTaskerInviteLink: request.taskerInviteLink ?? null, + token: randomUUID(), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + status: "created", + createdAt: now, + updatedAt: now, + }; + data.invites.push(invite); + addAuditEvent(data, actor, { + action: "Создан platform-инвайт для workspace-инвайта", + objectType: "invite", + objectName: invite.email, + clientId: client.id, + result: "success", + details: `${request.workspaceSlug}; inviter: ${request.inviterEmail}`, + }); + } else { + invite.clientId = client.id; + invite.role = "member"; + invite.invitedByUserId = request.inviterUserId || invite.invitedByUserId || actor.id; + invite.source = "tasker_workspace_invite"; + invite.sourceTaskerInviteRequestId = request.id; + invite.sourceTaskerInviteId = request.taskerInviteId; + invite.sourceWorkspaceSlug = request.workspaceSlug; + invite.sourceWorkspaceName = request.workspaceName; + invite.status = invite.status === "revoked" || invite.status === "expired" ? "created" : invite.status; + invite.updatedAt = now; + } + + request.platformInviteId = invite.id; + request.platformInviteToken = invite.token; + request.updatedAt = now; + + await writeData(data); + return { taskerInviteRequest: request, invite, data }; + } + + async function rejectTaskerInviteRequest(taskerInviteRequestId, payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const request = findTaskerInviteRequestById(data, taskerInviteRequestId); + const now = isoNow(); + + if (request.status === "approved") { + throw new Error("Подтверждённую заявку workspace-инвайта нельзя отклонить"); + } + + request.status = "rejected"; + request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null); + request.reviewedByUserId = actor.id; + request.reviewedAt = now; + request.updatedAt = now; + + addAuditEvent(data, actor, { + action: "Отклонена заявка workspace-инвайта", + objectType: "tasker_invite_request", + objectName: `${request.workspaceSlug}:${request.inviteeEmail}`, + result: "warning", + details: request.comment ?? null, + }); + + await writeData(data); + return { taskerInviteRequest: request, data }; + } + + async function cancelTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) { + const data = readData(); + const actor = resolveActor(data, identity); + const request = findTaskerInviteRequestForCancellation(data, payload); + + if (!request) { + return { taskerInviteRequest: null, affectedUserIds: [], data }; + } + + request.status = "cancelled"; + request.taskerInviteLink = null; + request.comment = nullableStringWithFallback(payload?.comment, "Отозвано в Operational Core"); + request.updatedAt = isoNow(); + const affectedUserIds = revokeTaskerInviteServiceAccessIfOrphaned(data, request, request.updatedAt); + + if (request.platformInviteId) { + const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId); + if (invite && invite.status !== "accepted") { + invite.status = "revoked"; + invite.updatedAt = request.updatedAt; + } + } + + addAuditEvent(data, actor, { + action: "Отозвана заявка workspace-инвайта", + objectType: "tasker_invite_request", + objectName: `${request.workspaceSlug}:${request.inviteeEmail}`, + result: "warning", + details: request.comment, + }); + + await writeData(data); + return { taskerInviteRequest: request, affectedUserIds, data }; + } + async function updateInvite(inviteId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); @@ -597,11 +1030,17 @@ export function createControlPlaneStore({ projectRoot }) { function getInviteByToken(token) { const data = readData(); const invite = findInviteByToken(data, token); - const client = findById(data.clients, invite.clientId, "client"); + const client = findClientById(data, invite.clientId); + const existingUser = data.users.find((user) => user.email.toLowerCase() === invite.email.toLowerCase()) ?? null; return { invite: toPublicInvite(invite), client: toPublicClient(client), + redirectUrl: resolvePublicInviteRedirectUrl(invite), + account: { + exists: Boolean(existingUser?.authentikUserId), + email: invite.email, + }, }; } @@ -623,7 +1062,7 @@ export function createControlPlaneStore({ projectRoot }) { async function acceptInvite(token, identity) { const data = readData(); const invite = findInviteByToken(data, token); - const client = findById(data.clients, invite.clientId, "client"); + const client = findClientById(data, invite.clientId); const email = requireInviteIdentityEmail(identity); const now = isoNow(); @@ -641,6 +1080,7 @@ export function createControlPlaneStore({ projectRoot }) { await writeData(data); throw new Error("Срок действия инвайта истёк"); } + validateTaskerInviteSourceCanBeAccepted(data, invite); const actor = resolveActor(data, identity); let user = data.users.find((item) => item.email.toLowerCase() === email); @@ -673,6 +1113,10 @@ export function createControlPlaneStore({ projectRoot }) { if (membership) { membership.role = invite.role; membership.status = "active"; + membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null; + membership.inviteId = invite.id; + membership.source = invite.source ?? membership.source ?? "launcher"; + membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null; membership.updatedAt = now; } else { membership = { @@ -681,12 +1125,17 @@ export function createControlPlaneStore({ projectRoot }) { userId: user.id, role: invite.role, status: "active", + invitedByUserId: invite.invitedByUserId ?? null, + inviteId: invite.id, + source: invite.source ?? "launcher", + sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null, createdAt: now, updatedAt: now, }; data.memberships.push(membership); } + ensureTaskerInviteServiceAccess(data, invite, user, now); invite.status = "accepted"; invite.updatedAt = now; @@ -1144,7 +1593,12 @@ export function createControlPlaneStore({ projectRoot }) { } return { + approveAccessRequest, + approveTaskerInviteRequest, buildAuthentikSyncPlan, + cancelTaskerInviteRequest, + createAccessRequest, + createTaskerInviteRequest, createClient, createGroup, createInvite, @@ -1155,10 +1609,13 @@ export function createControlPlaneStore({ projectRoot }) { deleteInvite, deleteMembership, deleteService, + rejectAccessRequest, + rejectTaskerInviteRequest, acceptInvite, commitInviteRegistration, getInviteByToken, getSnapshot, + ensureTaskerInvitePlatformInvite, prepareInviteRegistration, readData, replaceData, @@ -1170,6 +1627,7 @@ export function createControlPlaneStore({ projectRoot }) { removeTaskManagerProjectMembership, removeTaskManagerWorkspaceMembership, setUserServiceAccess, + updateAccessRequest, updateClient, updateGroup, updateInvite, @@ -1197,9 +1655,77 @@ function normalizeData(payload) { ...client, integrations: normalizeClientIntegrations(client.integrations), })); + data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean); + data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean); return data; } +function normalizeAccessRequest(payload) { + if (typeof payload !== "object" || payload === null) return null; + const now = isoNow(); + const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""; + const firstName = optionalString(payload.firstName, ""); + const lastName = optionalString(payload.lastName, ""); + const middleName = optionalString(payload.middleName, ""); + const phone = optionalString(payload.phone, ""); + const company = optionalString(payload.company, ""); + + if (!email || !firstName || !lastName || !middleName || !phone || !company) return null; + + return { + id: optionalString(payload.id, `access_request_${slugify(email)}`), + email, + firstName, + lastName, + middleName, + phone, + company, + status: pickEnum(payload.status, accessRequestStatuses, "new"), + targetClientId: typeof payload.targetClientId === "string" && payload.targetClientId.trim() ? payload.targetClientId.trim() : publicPoolClientId, + role: pickEnum(payload.role, membershipRoles, "member"), + approvedInviteId: nullableStringWithFallback(payload.approvedInviteId, null), + reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null), + reviewedAt: nullableStringWithFallback(payload.reviewedAt, null), + comment: nullableStringWithFallback(payload.comment, null), + createdAt: optionalString(payload.createdAt, now), + updatedAt: optionalString(payload.updatedAt, now), + }; +} + +function normalizeTaskerInviteRequest(payload) { + if (typeof payload !== "object" || payload === null) return null; + const now = isoNow(); + const taskerInviteId = typeof payload.taskerInviteId === "string" ? payload.taskerInviteId.trim() : ""; + const workspaceSlug = typeof payload.workspaceSlug === "string" ? payload.workspaceSlug.trim() : ""; + const inviteeEmail = typeof payload.inviteeEmail === "string" ? payload.inviteeEmail.trim().toLowerCase() : ""; + const inviterEmail = typeof payload.inviterEmail === "string" ? payload.inviterEmail.trim().toLowerCase() : ""; + + if (!taskerInviteId || !workspaceSlug || !inviteeEmail || !inviterEmail) return null; + + return { + id: optionalString(payload.id, `tasker_invite_request_${slugify(`${workspaceSlug}-${inviteeEmail}`)}`), + taskerInviteId, + workspaceId: nullableStringWithFallback(payload.workspaceId, null), + workspaceSlug, + workspaceName: optionalString(payload.workspaceName, workspaceSlug), + inviteeEmail, + role: normalizeTaskManagerInviteRole(payload.role), + inviterUserId: nullableStringWithFallback(payload.inviterUserId, null), + inviterPlaneUserId: nullableStringWithFallback(payload.inviterPlaneUserId, null), + inviterEmail, + inviterName: optionalString(payload.inviterName, inviterEmail), + status: pickEnum(payload.status, taskerInviteRequestStatuses, "new"), + taskerInviteLink: nullableStringWithFallback(payload.taskerInviteLink, null), + platformInviteId: nullableStringWithFallback(payload.platformInviteId, null), + platformInviteToken: nullableStringWithFallback(payload.platformInviteToken, null), + reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null), + reviewedAt: nullableStringWithFallback(payload.reviewedAt, null), + comment: nullableStringWithFallback(payload.comment, null), + createdAt: optionalString(payload.createdAt, now), + updatedAt: optionalString(payload.updatedAt, now), + }; +} + function normalizeSettings(payload) { const settings = typeof payload === "object" && payload !== null ? payload : {}; const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {}; @@ -1430,7 +1956,7 @@ async function writeJsonAtomically(filePath, payload) { function assertGrantTargetExists(data, targetType, targetId) { if (targetType === "client") { - findById(data.clients, targetId, "client"); + findClientById(data, targetId); } else if (targetType === "group") { findById(data.groups, targetId, "group"); } else { @@ -1467,12 +1993,24 @@ function isInviteExpired(invite) { function toPublicInvite(invite) { return { id: invite.id, + email: invite.email, role: invite.role, expiresAt: invite.expiresAt, status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status, + source: invite.source ?? "launcher", + sourceWorkspaceName: invite.sourceWorkspaceName ?? null, + sourceWorkspaceSlug: invite.sourceWorkspaceSlug ?? null, }; } +function resolvePublicInviteRedirectUrl(invite) { + if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) { + return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`; + } + + return "/"; +} + function toPublicClient(client) { return { id: client.id, @@ -1481,15 +2019,139 @@ function toPublicClient(client) { }; } +function ensureTaskerInviteServiceAccess(data, invite, user, now) { + if (invite.source !== "tasker_workspace_invite") { + return null; + } + + const service = data.services.find((candidate) => candidate.slug === "task-manager"); + if (!service) { + return null; + } + + const taskerInviteRequest = invite.sourceTaskerInviteRequestId + ? data.taskerInviteRequests.find((request) => request.id === invite.sourceTaskerInviteRequestId) + : null; + const requestedAppRole = taskerInviteRequest?.role === "guest" ? "viewer" : "member"; + const existingGrant = data.grants.find( + (grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id + ); + const existingException = data.exceptions.find((exception) => exception.serviceId === service.id && exception.userId === user.id); + + if (existingException?.type === "deny") { + return null; + } + + if (existingGrant) { + existingGrant.status = "active"; + existingGrant.appRole = + existingGrant.appRole === "admin" || existingGrant.appRole === "owner" ? existingGrant.appRole : requestedAppRole; + existingGrant.updatedAt = now; + markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`); + return existingGrant; + } + + const grant = { + id: uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`), + serviceId: service.id, + targetType: "user", + targetId: user.id, + appRole: requestedAppRole, + status: "active", + createdAt: now, + updatedAt: now, + }; + data.grants.push(grant); + markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`); + return grant; +} + +function findTaskerInviteRequestForCancellation(data, payload) { + const requestId = nullableString(payload?.requestId); + const taskerInviteId = nullableString(payload?.taskerInviteId); + const workspaceSlug = nullableString(payload?.workspaceSlug); + const inviteeEmail = nullableString(payload?.inviteeEmail)?.toLowerCase() ?? null; + + if (requestId || taskerInviteId) { + const request = data.taskerInviteRequests.find( + (candidate) => (requestId && candidate.id === requestId) || (taskerInviteId && candidate.taskerInviteId === taskerInviteId) + ); + if (request) return request; + } + + if (!workspaceSlug || !inviteeEmail) { + return null; + } + + return data.taskerInviteRequests + .filter( + (candidate) => + candidate.workspaceSlug === workspaceSlug && + candidate.inviteeEmail.toLowerCase() === inviteeEmail + ) + .sort((left, right) => { + if (left.status !== "cancelled" && right.status === "cancelled") return -1; + if (left.status === "cancelled" && right.status !== "cancelled") return 1; + return Date.parse(right.updatedAt ?? right.createdAt ?? 0) - Date.parse(left.updatedAt ?? left.createdAt ?? 0); + })[0] ?? null; +} + +function revokeTaskerInviteServiceAccessIfOrphaned(data, request, now) { + const user = data.users.find((candidate) => candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase()); + const service = data.services.find((candidate) => candidate.slug === "task-manager"); + + if (!user || !service) { + return []; + } + + const hasAnotherAcceptedWorkspaceInvite = data.taskerInviteRequests.some((candidate) => { + if (candidate.id === request.id) return false; + if (candidate.status !== "approved") return false; + if (candidate.inviteeEmail.toLowerCase() !== request.inviteeEmail.toLowerCase()) return false; + + const platformInvite = candidate.platformInviteId + ? data.invites.find((invite) => invite.id === candidate.platformInviteId) + : null; + return platformInvite?.status === "accepted"; + }); + + if (hasAnotherAcceptedWorkspaceInvite) { + return []; + } + + const directGrant = data.grants.find( + (grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id && grant.status === "active" + ); + + if (!directGrant || directGrant.appRole === "admin" || directGrant.appRole === "owner") { + return []; + } + + data.grants = data.grants.filter((grant) => grant.id !== directGrant.id); + markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`); + markPendingSync(data, user, "user", user.email); + + addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "tasker" }, { + action: "Снят доступ Operational Core по workspace-инвайту", + objectType: "grant", + objectName: `${user.email} / ${service.slug}`, + result: "warning", + details: `Workspace invite: ${request.workspaceSlug}; cancelled at: ${now}`, + }); + + return [user.id]; +} + function applyInviteRegistration(data, token, payload, { commit, provisioning = null } = {}) { const invite = findInviteByToken(data, token); - const client = findById(data.clients, invite.clientId, "client"); + const client = findClientById(data, invite.clientId); const now = isoNow(); const requestedEmail = normalizeInviteRegistrationEmail(payload?.email); const email = invite.email.toLowerCase(); const name = optionalString(payload?.name, requestedEmail.split("@")[0]); validateInviteCanBeRegistered(invite); + validateTaskerInviteSourceCanBeAccepted(data, invite); if (!requestedEmail || requestedEmail !== email) { throw new Error("Для этой почты нет активного инвайта"); @@ -1528,6 +2190,10 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning = if (membership) { membership.role = invite.role; membership.status = "active"; + membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null; + membership.inviteId = invite.id; + membership.source = invite.source ?? membership.source ?? "launcher"; + membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null; membership.updatedAt = now; } else { membership = { @@ -1536,12 +2202,17 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning = userId: user.id, role: invite.role, status: "active", + invitedByUserId: invite.invitedByUserId ?? null, + inviteId: invite.id, + source: invite.source ?? "launcher", + sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null, createdAt: now, updatedAt: now, }; data.memberships.push(membership); } + ensureTaskerInviteServiceAccess(data, invite, user, now); invite.status = "accepted"; invite.updatedAt = now; markPendingSync(data, user, "user", email); @@ -1578,6 +2249,20 @@ function validateInviteCanBeRegistered(invite) { } } +function validateTaskerInviteSourceCanBeAccepted(data, invite) { + if (invite.source !== "tasker_workspace_invite") { + return; + } + + const request = invite.sourceTaskerInviteRequestId + ? data.taskerInviteRequests.find((candidate) => candidate.id === invite.sourceTaskerInviteRequestId) + : null; + + if (!request || request.status !== "approved") { + throw new Error("Workspace-инвайт больше не активен"); + } +} + function findById(items, id, label) { const item = items.find((candidate) => candidate.id === id); @@ -1588,6 +2273,60 @@ function findById(items, id, label) { return item; } +function findClientById(data, clientId) { + if (clientId === publicPoolClientId) { + return publicPoolClient; + } + + return findById(data.clients, clientId, "client"); +} + +function findAccessRequestById(data, accessRequestId) { + return findById(data.accessRequests, accessRequestId, "access_request"); +} + +function findTaskerInviteRequestById(data, taskerInviteRequestId) { + return findById(data.taskerInviteRequests, taskerInviteRequestId, "tasker_invite_request"); +} + +function resolveAccessRequestTargetClientId(data, value, fallback = publicPoolClientId) { + const clientId = optionalString(value, fallback || publicPoolClientId); + findClientById(data, clientId); + return clientId; +} + +function sanitizeAccessRequestPayload(payload) { + const email = requireString(payload?.email, "email").toLowerCase(); + + if (!isValidEmail(email)) { + throw new Error("Введите корректную электронную почту"); + } + + return { + email, + firstName: requireString(payload?.firstName, "firstName").slice(0, 80), + lastName: requireString(payload?.lastName, "lastName").slice(0, 80), + middleName: requireString(payload?.middleName, "middleName").slice(0, 80), + phone: requireString(payload?.phone, "phone").slice(0, 80), + company: requireString(payload?.company, "company").slice(0, 160), + }; +} + +function normalizeTaskManagerInviteRole(value) { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : value; + + if (normalized === "viewer") return "guest"; + if (normalized === "owner") return "admin"; + if (normalized === 5) return "guest"; + if (normalized === 15) return "member"; + if (normalized === 20) return "admin"; + return taskManagerInviteRoles.has(normalized) ? normalized : "member"; +} + +function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + function requireString(value, fieldName) { if (typeof value !== "string" || !value.trim()) { throw new Error(`Field ${fieldName} is required`); diff --git a/server/dev-server.mjs b/server/dev-server.mjs index f2a6b8c..7959d77 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -20,6 +20,7 @@ const oidcStateCookieName = "nodedc_oidc_state"; const maxOidcStateCookieEntries = 8; const sessionCookieName = "nodedc_session"; const noStoreCacheControl = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; +const publicPoolClientId = "client_public_pool"; loadEnvFiles([ process.env.NODEDC_PLATFORM_ENV, @@ -67,6 +68,16 @@ app.get("/api/public/brand", (_req, res) => { res.json(buildPublicBrandResponse(snapshot.data.settings)); }); +app.post("/api/access-requests", asyncRoute(async (req, res) => { + try { + const result = await controlPlaneStore.createAccessRequest(req.body); + publishControlPlaneEvent("access-request.created"); + res.status(201).json({ accessRequest: result.accessRequest }); + } catch (error) { + sendAccessRequestApiError(res, error); + } +})); + app.get("/auth/login", asyncRoute(async (req, res) => { ensureOidcConfigured(); @@ -395,6 +406,84 @@ app.post("/api/internal/access/check", (req, res) => { }); }); +app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) => { + if (!isInternalRequestAuthorized(req)) { + res.status(config.internalAccessToken ? 401 : 503).json({ + ok: false, + error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured", + }); + return; + } + + const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC tasker invite request" }); + const inviterPayload = typeof req.body?.inviter === "object" && req.body.inviter !== null ? req.body.inviter : req.body; + const inviter = findInternalAccessUser(snapshot.data, { + subject: inviterPayload.subject, + email: inviterPayload.email, + userId: inviterPayload.userId, + }); + + if (!inviter) { + res.status(404).json({ ok: false, error: "inviter_not_found" }); + return; + } + + const groups = resolveRequiredGroups(snapshot.data, inviter); + const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager"); + const workspacePolicy = resolveTaskManagerWorkspacePolicy(snapshot.data, groups, Boolean(app?.hasAccess), inviter); + + if (!app?.hasAccess || workspacePolicy.inviteApproval !== "nodedc") { + res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy }); + return; + } + + const result = await controlPlaneStore.createTaskerInviteRequest({ + taskerInviteId: req.body?.taskerInviteId, + workspaceId: req.body?.workspace?.id ?? req.body?.workspaceId, + workspaceSlug: req.body?.workspace?.slug ?? req.body?.workspaceSlug, + workspaceName: req.body?.workspace?.name ?? req.body?.workspaceName, + inviteeEmail: req.body?.invitee?.email ?? req.body?.inviteeEmail, + role: req.body?.invitee?.role ?? req.body?.role, + inviterUserId: inviter.id, + inviterPlaneUserId: inviterPayload.planeUserId, + inviterEmail: inviter.email, + inviterName: inviter.name, + }, inviter); + + publishControlPlaneEvent("tasker.invite-request.created", [inviter.id]); + res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest }); +})); + +app.post("/api/internal/tasker/invite-requests/cancel", asyncRoute(async (req, res) => { + if (!isInternalRequestAuthorized(req)) { + res.status(config.internalAccessToken ? 401 : 503).json({ + ok: false, + error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured", + }); + return; + } + + const result = await controlPlaneStore.cancelTaskerInviteRequest(req.body, { + name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core", + email: req.body?.cancelledBy?.email, + source: "tasker", + }); + const syncResult = await syncUsersToAuthentik(result.data, result.affectedUserIds ?? [], { + name: req.body?.cancelledBy?.name ?? req.body?.cancelledBy?.email ?? "Operational Core", + email: req.body?.cancelledBy?.email, + source: "tasker", + }); + + if (result.taskerInviteRequest) { + publishControlPlaneEvent("tasker.invite-request.cancelled", [ + result.taskerInviteRequest.inviterUserId, + ...syncResult.userIds, + ]); + } + + res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest }); +})); + 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); @@ -478,13 +567,14 @@ app.post("/api/invites/:token/register", asyncRoute(async (req, res) => { ); publishControlPlaneEvent("invite.registered", [result.user.id]); + const redirectUrl = resolveInviteRedirectUrl(result.invite); res.json({ ...result, user: storeResult.user, data: storeResult.data, provisioning: toProvisioningResponse(provisionedUser), - loginUrl: buildLoginRedirectUrl("/", { forceLogin: true, includeReturnTo: true }), - redirectUrl: "/", + loginUrl: buildLoginRedirectUrl(redirectUrl, { forceLogin: true, includeReturnTo: true }), + redirectUrl, authenticated: true, }); })); @@ -502,9 +592,44 @@ app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, re const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user); publishControlPlaneEvent("invite.accepted", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json({ ...result, data: syncResult.data, redirectUrl: resolveInviteRedirectUrl(result.invite) }); })); +app.get("/tasker-workspace-invite/:taskerInviteRequestId", (req, res) => { + const session = getCurrentSession(req); + + if (!session) { + res.redirect(buildLoginRedirectUrl(req.originalUrl, { forceLogin: true })); + return; + } + + const runtimeContext = getRuntimeSessionContext(session); + const request = controlPlaneStore + .getSnapshot({ name: "NODE.DC tasker invite redirect" }) + .data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId); + + if (!request || request.status !== "approved") { + res.status(404).send("Workspace-инвайт не найден или ещё не подтверждён NODE.DC."); + return; + } + + if (session.user.email?.toLowerCase() !== request.inviteeEmail.toLowerCase()) { + res.status(403).send("Этот workspace-инвайт выписан на другую почту."); + return; + } + + const handoffToken = createServiceHandoff("task-manager", runtimeContext.user); + const taskBaseUrl = getTaskBaseUrl(); + const targetUrl = new URL("/auth/nodedc/handoff/", taskBaseUrl); + targetUrl.searchParams.set("token", handoffToken); + targetUrl.searchParams.set( + "next_path", + `/auth/nodedc/workspace-invite/accept/${encodeURIComponent(request.id)}/` + ); + + res.redirect(targetUrl.toString()); +}); + app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => { res.json(scopeAdminSnapshot(req)); }); @@ -974,6 +1099,94 @@ app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(asyn res.json(scopeAdminMutationResult(req, result)); })); +app.patch("/api/admin/access-requests/:accessRequestId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { + try { + const result = await controlPlaneStore.updateAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.access-request.updated"); + res.json(scopeAdminMutationResult(req, result)); + } catch (error) { + sendAccessRequestApiError(res, error); + } +})); + +app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { + try { + const result = await controlPlaneStore.approveAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.access-request.approved"); + res.json(scopeAdminMutationResult(req, result)); + } catch (error) { + sendAccessRequestApiError(res, error); + } +})); + +app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { + try { + const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.access-request.rejected"); + res.json(scopeAdminMutationResult(req, result)); + } catch (error) { + sendAccessRequestApiError(res, error); + } +})); + +app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId); + + if (!taskerInviteRequest) { + res.status(404).json({ error: "tasker_invite_request_not_found" }); + return; + } + + const platformInviteResult = await controlPlaneStore.ensureTaskerInvitePlatformInvite( + req.params.taskerInviteRequestId, + req.nodedcSession.user + ); + const platformInviteLink = buildPlatformInviteUrl(platformInviteResult.invite); + const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/approve/", { + body: { + taskerInviteId: taskerInviteRequest.taskerInviteId, + requestId: taskerInviteRequest.id, + platformInviteLink, + }, + }); + const result = await controlPlaneStore.approveTaskerInviteRequest( + req.params.taskerInviteRequestId, + { + taskerInviteLink: taskerResult.invite?.taskerInviteLink ?? taskerResult.invite?.tasker_invite_link ?? taskerResult.invite?.inviteLink ?? null, + platformInviteId: platformInviteResult.invite.id, + platformInviteToken: platformInviteResult.invite.token, + comment: req.body?.comment, + }, + req.nodedcSession.user + ); + + publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]); + res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult })); +})); + +app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const taskerInviteRequest = snapshot.data.taskerInviteRequests.find((request) => request.id === req.params.taskerInviteRequestId); + + if (!taskerInviteRequest) { + res.status(404).json({ error: "tasker_invite_request_not_found" }); + return; + } + + const taskerResult = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-invite-requests/reject/", { + body: { + taskerInviteId: taskerInviteRequest.taskerInviteId, + requestId: taskerInviteRequest.id, + comment: req.body?.comment, + }, + }); + const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user); + + publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]); + res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult })); +})); + app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => { if (!assertAdminCanManageClient(req, res, req.body?.clientId)) { return; @@ -1381,6 +1594,20 @@ function sendInviteApiError(res, error) { res.status(status).json({ error: message }); } +function sendAccessRequestApiError(res, error) { + const message = error instanceof Error ? error.message : "Заявка недоступна"; + const status = + message.includes("Unknown access_request") || message.includes("не найден") + ? 404 + : message.includes("нельзя") + ? 409 + : message.includes("required") || message.includes("Введите") + ? 400 + : 400; + + res.status(status).json({ error: message }); +} + function sanitizeSelfProfilePatch(payload) { return { name: payload?.name, @@ -1760,13 +1987,19 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin"); const workspaces = resolveTaskManagerWorkspaceAssignments(data, user); const hasLauncherManagedWorkspace = workspaces.some((workspace) => workspace.managedBy === "launcher"); + const isPublicPoolUser = data.memberships.some( + (membership) => membership.userId === user?.id && membership.clientId === publicPoolClientId && membership.status === "active" + ); const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker"; + const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "tasker"; if (!hasTaskManagerAccess) { return { mode, managedBy: defaultManagedBy, defaultManagedBy, + inviteApproval: "disabled", + defaultInviteApproval, workspaces, canCreateWorkspace: false, reason: "Нет доступа к Operational Core.", @@ -1778,6 +2011,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u mode, managedBy: defaultManagedBy, defaultManagedBy, + inviteApproval: "disabled", + defaultInviteApproval, workspaces, canCreateWorkspace: false, reason: "Создание рабочих пространств отключено на уровне платформы.", @@ -1789,6 +2024,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u mode, managedBy: "launcher", defaultManagedBy: "launcher", + inviteApproval: "launcher", + defaultInviteApproval: "launcher", workspaces, canCreateWorkspace: false, reason: "Рабочие пространства этого пользователя управляются через Launcher.", @@ -1800,6 +2037,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u mode, managedBy: defaultManagedBy, defaultManagedBy, + inviteApproval: defaultInviteApproval, + defaultInviteApproval, workspaces, canCreateWorkspace: false, reason: "Создание рабочих пространств доступно только администраторам Operational Core.", @@ -1810,6 +2049,8 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u mode, managedBy: "tasker", defaultManagedBy: "tasker", + inviteApproval: defaultInviteApproval, + defaultInviteApproval, workspaces, canCreateWorkspace: true, reason: "Создание рабочих пространств разрешено платформенной policy.", @@ -2441,6 +2682,8 @@ function scopeControlPlaneData(data, scope) { memberships, groups: data.groups.filter((group) => clientIds.has(group.clientId)), invites: data.invites.filter((invite) => clientIds.has(invite.clientId)), + accessRequests: [], + taskerInviteRequests: [], grants: data.grants.filter((grant) => { if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "group") return groupIds.has(grant.targetId); @@ -2550,6 +2793,18 @@ function buildLoginRedirectUrl(returnTo, { forceLogin = false, includeReturnTo = return loginUrl.toString(); } +function buildPlatformInviteUrl(invite) { + return new URL(`/invite/${encodeURIComponent(invite.token)}`, config.appBaseUrl).toString(); +} + +function resolveInviteRedirectUrl(invite) { + if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) { + return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`; + } + + return "/"; +} + function buildOidcLogoutUrl(discovery, returnTo = "/", idToken = null) { const issuerUrl = new URL(discovery.issuer || config.issuer); const logoutUrl = new URL("/if/flow/default-invalidation-flow/", issuerUrl.origin); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index b20398c..77db55d 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -1,10 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { ClientGroup, ClientMembership, LauncherUser } from "../entities/user/types"; import { + approveAdminAccessRequest, + approveAdminTaskerInviteRequest, createAdminClient, createAdminGroup, createAdminInvite, @@ -21,10 +23,13 @@ import { fetchControlPlaneSnapshot, reorderAdminServices, retryAdminSync, + rejectAdminAccessRequest, + rejectAdminTaskerInviteRequest, removeAdminTaskManagerProjectMembership, removeAdminTaskManagerWorkspaceMembership, setAdminUserServiceAccess, updateAdminClient, + updateAdminAccessRequest, updateAdminGroup, updateAdminInvite, updateAdminMembership, @@ -35,6 +40,7 @@ import { type TaskManagerWorkspaceMemberRole, type TaskManagerWorkspaceSummary, } from "../shared/api/adminApi"; +import { createAccessRequest, type CreateAccessRequestResponse } from "../shared/api/accessRequestApi"; import { buildLauncherServices, buildMe, @@ -53,6 +59,7 @@ import { } from "../shared/api/authApi"; import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi"; +import type { CreateAccessRequestCommand } from "../entities/access-request/types"; import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { @@ -80,6 +87,7 @@ type InviteFlowState = export function LauncherApp() { const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []); + const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []); const [data, setData] = useState(() => syncLauncherServiceLinks(initialLauncherData)); const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId); const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId); @@ -95,6 +103,15 @@ export function LauncherApp() { const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false); const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState(null); const [inviteFlow, setInviteFlow] = useState(() => (inviteToken ? { status: "loading" } : null)); + const runtimeDataRef = useRef(data); + const runtimeProfileIdRef = useRef(activeProfileId); + const runtimeClientIdRef = useRef(activeClientId); + + useEffect(() => { + runtimeDataRef.current = data; + runtimeProfileIdRef.current = activeProfileId; + runtimeClientIdRef.current = activeClientId; + }, [activeClientId, activeProfileId, data]); const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0]; @@ -218,10 +235,10 @@ export function LauncherApp() { useEffect(() => { if (!authSession || authSession.authenticated) return; - if (inviteToken) return; + if (inviteToken || isAccessRequestRoute) return; redirectToLogin(authSession.loginUrl); - }, [authSession, inviteToken]); + }, [authSession, inviteToken, isAccessRequestRoute]); useEffect(() => { if (!inviteToken) return; @@ -266,6 +283,7 @@ export function LauncherApp() { if (!isMounted) return; if (!session.authenticated) { + if (inviteToken || isAccessRequestRoute) return; redirectToLogin(session.loginUrl); return; } @@ -273,7 +291,7 @@ export function LauncherApp() { setAuthSession(session); }) .catch(() => { - if (isMounted) { + if (isMounted && !inviteToken && !isAccessRequestRoute) { redirectToLogin("/auth/login"); } }); @@ -285,7 +303,7 @@ export function LauncherApp() { isMounted = false; window.removeEventListener("pageshow", validateRestoredSession); }; - }, []); + }, [inviteToken, isAccessRequestRoute]); useEffect(() => { if (!authSession?.authenticated) return; @@ -341,49 +359,60 @@ export function LauncherApp() { void refreshTaskManagerWorkspaces(); }, [adminOpen, canOpenAdminApi]); + const refreshRuntimeState = useCallback(async () => { + try { + const nextSession = await fetchAuthSession(); + + setAuthSession(nextSession); + + if (!nextSession.authenticated) { + setAuthApps([]); + return; + } + + const currentData = runtimeDataRef.current; + const nextContext = resolveAuthenticatedContext( + currentData, + nextSession, + runtimeProfileIdRef.current, + runtimeClientIdRef.current + ); + const nextMe = buildMe(currentData, nextContext.profileId, nextContext.clientId); + const [persistedData, apps] = await Promise.all([ + nextSession.isSuperAdmin || nextMe.permissions.canOpenAdmin + ? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data) + : loadPersistedLauncherData(), + fetchAvailableApps(), + ]); + + if (persistedData) { + setData(syncLauncherServiceLinks(persistedData)); + } + + setAuthApps(apps); + } catch (error: unknown) { + console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher"); + } + }, []); + useEffect(() => { if (!authSession?.authenticated) return; let isMounted = true; - const refreshRuntimeState = async () => { - try { - const nextSession = await fetchAuthSession(); - - if (!isMounted) return; - - setAuthSession(nextSession); - - if (!nextSession.authenticated) { - setAuthApps([]); - return; - } - - const nextContext = resolveAuthenticatedContext(data, nextSession, activeProfileId, activeClientId); - const nextMe = buildMe(data, nextContext.profileId, nextContext.clientId); - const [persistedData, apps] = await Promise.all([ - nextMe.permissions.canOpenAdmin - ? 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 refreshMountedRuntimeState = async () => { + await refreshRuntimeState(); + if (!isMounted) return; }; const eventSource = new EventSource("/api/events"); + eventSource.addEventListener("nodedc-ready", () => { + void refreshMountedRuntimeState(); + }); + eventSource.addEventListener("nodedc-runtime", () => { - void refreshRuntimeState(); + void refreshMountedRuntimeState(); }); eventSource.onerror = () => { @@ -394,7 +423,25 @@ export function LauncherApp() { isMounted = false; eventSource.close(); }; - }, [authSession?.authenticated]); + }, [authSession?.authenticated, refreshRuntimeState]); + + useEffect(() => { + if (!authSession?.authenticated) return; + + const refreshVisibleRuntimeState = () => { + if (document.visibilityState === "visible") { + void refreshRuntimeState(); + } + }; + + window.addEventListener("focus", refreshVisibleRuntimeState); + document.addEventListener("visibilitychange", refreshVisibleRuntimeState); + + return () => { + window.removeEventListener("focus", refreshVisibleRuntimeState); + document.removeEventListener("visibilitychange", refreshVisibleRuntimeState); + }; + }, [authSession?.authenticated, refreshRuntimeState]); function handleProfileChange(userId: string) { const profile = profileOptions.find((option) => option.userId === userId); @@ -561,6 +608,10 @@ export function LauncherApp() { try { const result = await acceptInvite(inviteToken); setData(syncLauncherServiceLinks(result.data)); + if (result.redirectUrl && result.redirectUrl !== "/") { + window.location.assign(result.redirectUrl); + return; + } setInviteFlow({ status: "accepted", payload: inviteFlow.payload }); } catch (error) { setInviteFlow({ @@ -601,6 +652,32 @@ export function LauncherApp() { applyControlPlaneMutation(deleteAdminInvite(inviteId)); } + function handleUpdateAccessRequest(accessRequestId: string, patch: Parameters[1]) { + applyControlPlaneMutation(updateAdminAccessRequest(accessRequestId, patch)); + } + + function handleApproveAccessRequest(accessRequestId: string, patch: Parameters[1]) { + applyControlPlaneMutation(approveAdminAccessRequest(accessRequestId, patch)); + } + + function handleRejectAccessRequest(accessRequestId: string, patch: Parameters[1]) { + applyControlPlaneMutation(rejectAdminAccessRequest(accessRequestId, patch)); + } + + function handleApproveTaskerInviteRequest( + taskerInviteRequestId: string, + patch: Parameters[1] + ) { + applyControlPlaneMutation(approveAdminTaskerInviteRequest(taskerInviteRequestId, patch)); + } + + function handleRejectTaskerInviteRequest( + taskerInviteRequestId: string, + patch: Parameters[1] + ) { + applyControlPlaneMutation(rejectAdminTaskerInviteRequest(taskerInviteRequestId, patch)); + } + function handleRetrySync(syncId: string) { applyControlPlaneMutation(retryAdminSync(syncId)); } @@ -706,11 +783,20 @@ export function LauncherApp() { setSelectedServiceId((current) => (current === serviceId ? undefined : current)); } + if (isAccessRequestRoute) { + return ( + redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)} + /> + ); + } + if (inviteToken) { return ( void handleAcceptInvite()} onRegister={(command) => void handleRegisterInvite(command)} onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)} @@ -774,6 +860,11 @@ export function LauncherApp() { onCreateInvite={handleCreateInvite} onUpdateInvite={handleUpdateInvite} onDeleteInvite={handleDeleteInvite} + onUpdateAccessRequest={handleUpdateAccessRequest} + onApproveAccessRequest={handleApproveAccessRequest} + onRejectAccessRequest={handleRejectAccessRequest} + onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest} + onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest} onRetrySync={handleRetrySync} onCreateClient={handleCreateClient} onUpdateClient={handleUpdateClient} @@ -828,6 +919,156 @@ function accessAssignmentKey(userId: string, serviceId: string) { return `${userId}:${serviceId}`; } +function AccessRequestScreen({ + onSubmit, + onLogin, +}: { + onSubmit: (command: CreateAccessRequestCommand) => Promise; + onLogin: () => void; +}) { + const [values, setValues] = useState({ + email: "", + firstName: "", + lastName: "", + middleName: "", + phone: "", + company: "", + }); + const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle"); + const [message, setMessage] = useState(null); + const isSubmitted = status === "submitted"; + const normalizedEmail = values.email.trim().toLowerCase(); + const canSubmit = Boolean( + normalizedEmail.includes("@") && + values.firstName.trim() && + values.lastName.trim() && + values.middleName.trim() && + values.phone.trim() && + values.company.trim() && + status !== "submitting" + ); + + const updateField = (field: keyof CreateAccessRequestCommand, value: string) => { + setValues((current) => ({ ...current, [field]: value })); + }; + + return ( +
+ +
+
+
+

NODE.DC.

+

{isSubmitted ? "Вы запросили доступ." : "Работайте во всех измерениях."}

+
+ + {!isSubmitted ? ( +

+ Заполните обязательные поля. Заявка попадёт в очередь NODE.DC, после approve администратор передаст ссылку инвайта. +

+ ) : null} + {message ?

{message}

: null} + + {isSubmitted ? ( +
+ +
+ ) : ( +
{ + event.preventDefault(); + if (!canSubmit) return; + + setStatus("submitting"); + setMessage(null); + onSubmit({ + email: normalizedEmail, + firstName: values.firstName.trim(), + lastName: values.lastName.trim(), + middleName: values.middleName.trim(), + phone: values.phone.trim(), + company: values.company.trim(), + }) + .then(() => { + setStatus("submitted"); + setMessage("Заявка отправлена администратору. Администратор проверит данные. Дождитесь результатов."); + }) + .catch((error) => { + setStatus("error"); + setMessage(error instanceof Error ? error.message : "Не удалось отправить заявку."); + }); + }} + > + +
+ + +
+ + + + + +
+ )} +
+
+
+ ); +} + function resolveAuthenticatedContext( data: LauncherData, session: AuthenticatedSession, @@ -878,7 +1119,7 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli function InviteFlowScreen({ state, - isAuthenticated, + authenticatedEmail, onAccept, onRegister, onLogin, @@ -886,7 +1127,7 @@ function InviteFlowScreen({ onGoHome, }: { state: InviteFlowState; - isAuthenticated: boolean; + authenticatedEmail: string | null; onAccept: () => void; onRegister: (command: RegisterInviteCommand) => void; onLogin: () => void; @@ -899,12 +1140,33 @@ function InviteFlowScreen({ const [passwordConfirm, setPasswordConfirm] = useState(""); const payload = "payload" in state ? state.payload : undefined; const inviteStatus = payload?.invite.status; + const inviteEmail = payload?.account.email ?? payload?.invite.email ?? ""; + const normalizedInviteEmail = inviteEmail.toLowerCase(); + const existingAccount = Boolean(payload?.account.exists); + const isAuthenticated = Boolean(authenticatedEmail); + const isAuthenticatedAsInvitee = Boolean( + authenticatedEmail && + normalizedInviteEmail && + authenticatedEmail.toLowerCase() === normalizedInviteEmail + ); + const isAuthenticatedAsDifferentUser = Boolean( + authenticatedEmail && + normalizedInviteEmail && + authenticatedEmail.toLowerCase() !== normalizedInviteEmail + ); const isAccepting = state.status === "accepting"; const isRegistering = state.status === "registering"; + const inviteTargetUrl = payload?.redirectUrl; + const canOpenInviteTarget = Boolean( + payload?.invite.source === "tasker_workspace_invite" && + inviteTargetUrl && + inviteTargetUrl !== "/" && + (state.status === "accepted" || inviteStatus === "accepted") + ); const requiresAccountSwitch = state.status === "error" && state.message.includes("другую почту"); const canAccept = Boolean( state.status === "ready" && - isAuthenticated && + isAuthenticatedAsInvitee && inviteStatus !== "accepted" && inviteStatus !== "expired" && inviteStatus !== "revoked" @@ -913,6 +1175,7 @@ function InviteFlowScreen({ const canShowRegistrationForm = Boolean( payload && !isAuthenticated && + !existingAccount && !isTerminalInvite && (state.status === "ready" || state.status === "registering" || state.status === "error") ); @@ -927,12 +1190,25 @@ function InviteFlowScreen({ password === passwordConfirm ); const details = payload - ? [ - `Рабочая область: ${payload.client.name}`, - `Роль: ${membershipRoleLabel(payload.invite.role)}`, - ] + ? payload.invite.source === "tasker_workspace_invite" + ? [ + `Контур: ${payload.client.name}`, + `Workspace: ${payload.invite.sourceWorkspaceName ?? payload.invite.sourceWorkspaceSlug ?? "Operational Core"}`, + `Роль: ${membershipRoleLabel(payload.invite.role)}`, + ] + : [ + `Рабочая область: ${payload.client.name}`, + `Роль: ${membershipRoleLabel(payload.invite.role)}`, + ] : ["Проверяем приглашение и платформенную сессию"]; - const statusMessage = resolveInviteStatusMessage(state, isAuthenticated, inviteStatus); + const statusMessage = resolveInviteStatusMessage(state, { + existingAccount, + inviteEmail, + inviteStatus, + isAuthenticated, + isAuthenticatedAsInvitee, + isAuthenticatedAsDifferentUser, + }); return (
@@ -1004,7 +1280,11 @@ function InviteFlowScreen({ Уже есть аккаунт - ) : requiresAccountSwitch ? ( + ) : existingAccount && !isAuthenticated && !isTerminalInvite ? ( + + ) : (existingAccount && isAuthenticatedAsDifferentUser && !isTerminalInvite) || requiresAccountSwitch ? ( @@ -1013,8 +1293,18 @@ function InviteFlowScreen({ Войти в NODE.DC ) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? ( - ) : ( ); } return ( - {isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)} - {isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)} + {displayTitle} + {displaySource} )} /> @@ -2884,24 +3032,50 @@ function InvitesSection({ data, clientId, actorUserId, + isPublicPoolContext, onCreateInvite, onUpdateInvite, onDeleteInvite, + onUpdateAccessRequest, + onApproveAccessRequest, + onRejectAccessRequest, + onApproveTaskerInviteRequest, + onRejectTaskerInviteRequest, }: { data: LauncherData; clientId: string; actorUserId: string; + isPublicPoolContext: boolean; onCreateInvite: (invite: Pick) => void; onUpdateInvite: (inviteId: string, patch: Partial) => void; onDeleteInvite: (inviteId: string) => void; + onUpdateAccessRequest: ( + accessRequestId: string, + patch: Partial> + ) => void; + onApproveAccessRequest: ( + accessRequestId: string, + patch: Partial> + ) => void; + onRejectAccessRequest: (accessRequestId: string, patch: Partial>) => void; + onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; + onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; }) { const [email, setEmail] = useState(""); const [role, setRole] = useState("member"); const [deleteInviteId, setDeleteInviteId] = useState(null); const [copiedInviteId, setCopiedInviteId] = useState(null); + const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming"); const invites = data.invites.filter((invite) => invite.clientId === clientId); + const pendingIncomingRequests = + data.accessRequests.filter((request) => request.status === "new").length + + data.taskerInviteRequests.filter((request) => request.status === "new").length; const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null; const actor = getUser(data, actorUserId); + const clientOptions: Array> = [ + { value: PUBLIC_POOL_CLIENT_ID, label: PUBLIC_POOL_CONTEXT_LABEL, description: PUBLIC_POOL_CONTEXT_DESCRIPTION }, + ...data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined })), + ]; function handleCreateInvite() { if (!email.trim()) return; @@ -2933,6 +3107,44 @@ function InvitesSection({ return (
+ {isPublicPoolContext ? ( + +
+ + +
+

+ Входящие — заявки из формы “Запросить доступ”. Исходящие — инвайты, которые root-admin выпускает руками без заявки. +

+
+ ) : null} + + {isPublicPoolContext && publicInviteTab === "incoming" ? ( + + ) : ( + <>
@@ -3064,6 +3276,386 @@ function InvitesSection({ setDeleteInviteId(null); }} /> + + )} +
+ ); +} + +function AccessRequestsPanel({ + data, + clientOptions, + copiedInviteId, + onCopyInvite, + onUpdateAccessRequest, + onApproveAccessRequest, + onRejectAccessRequest, + onApproveTaskerInviteRequest, + onRejectTaskerInviteRequest, +}: { + data: LauncherData; + clientOptions: Array>; + copiedInviteId: string | null; + onCopyInvite: (invite: Invite) => Promise; + onUpdateAccessRequest: ( + accessRequestId: string, + patch: Partial> + ) => void; + onApproveAccessRequest: ( + accessRequestId: string, + patch: Partial> + ) => void; + onRejectAccessRequest: (accessRequestId: string, patch: Partial>) => void; + onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; + onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; +}) { + const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const taskerInviteRequests = data.taskerInviteRequests + .filter((request) => request.status !== "cancelled") + .slice() + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + return ( + <> + +
+
+

Входящие запросы доступа

+

+ Перед approve выберите целевой контур: оставить пользователя в открытом пуле или направить его в enterprise-клиента. +

+
+
+ + {accessRequests.length === 0 ? ( +
+ Входящих заявок пока нет + Кнопка “Запросить доступ” на login будет отправлять пользователей в эту очередь. +
+ ) : ( +
+ + + + + + + + + + + + + + {accessRequests.map((accessRequest) => { + const approvedInvite = accessRequest.approvedInviteId + ? data.invites.find((invite) => invite.id === accessRequest.approvedInviteId) ?? null + : null; + const isTerminal = accessRequest.status !== "new"; + const isCopied = Boolean(approvedInvite && copiedInviteId === approvedInvite.id); + + return ( + + + + + + + + + + + ); + })} + +
ЗаявительКонтактыКомпанияНазначениеРольСтатусИнвайт +
+
+ {formatAccessRequestName(accessRequest)} + {formatDateTime(accessRequest.createdAt)} +
+
+
+ {accessRequest.email} + {accessRequest.phone} +
+
{accessRequest.company} + onUpdateAccessRequest(accessRequest.id, { targetClientId })} + /> + + onUpdateAccessRequest(accessRequest.id, { role })} + /> + + + + {approvedInvite ? ( +
+ {buildInviteUrl(approvedInvite.token)} + {isCopied ? Скопировано : null} + void onCopyInvite(approvedInvite)} + > + + +
+ ) : ( + + )} +
+ {accessRequest.status === "new" ? ( +
+ + +
+ ) : ( + + )} +
+
+ )} +
+ + + ); +} + +function TaskerInviteRequestsPanel({ + requests, + onApproveTaskerInviteRequest, + onRejectTaskerInviteRequest, +}: { + requests: TaskerInviteRequest[]; + onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; + onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; +}) { + const [copiedRequestId, setCopiedRequestId] = useState(null); + + async function handleCopyTaskerInvite(request: TaskerInviteRequest) { + if (!request.platformInviteToken) return; + + try { + await copyToClipboard(buildInviteUrl(request.platformInviteToken)); + } catch { + return; + } + + setCopiedRequestId(request.id); + window.setTimeout(() => { + setCopiedRequestId((currentRequestId) => (currentRequestId === request.id ? null : currentRequestId)); + }, 1800); + } + + return ( + +
+
+

Запросы workspace-инвайтов

+

+ Self-service админы Operational Core создают эти заявки из настроек workspace. После approve ссылка становится доступна + пригласившему пользователю. +

+
+
+ + {requests.length === 0 ? ( +
+ Workspace-инвайтов пока нет + Когда self-service admin добавит участника в Operational Core, заявка появится здесь. +
+ ) : ( +
+ + + + + + + + + + + + + {requests.map((request) => { + const isCopied = copiedRequestId === request.id; + + return ( + + + + + + + + + + ); + })} + +
WorkspaceПриглашённыйИнициаторРольСтатусСсылка +
+
+ {request.workspaceName} + {request.workspaceSlug} +
+
+
+ {request.inviteeEmail} + {formatDateTime(request.createdAt)} +
+
+
+ {request.inviterName} + {request.inviterEmail} +
+
{taskerInviteRoleLabel(request.role)} + + + {request.platformInviteToken ? ( +
+ {buildInviteUrl(request.platformInviteToken)} + {isCopied ? Скопировано : null} + void handleCopyTaskerInvite(request)} + > + + +
+ ) : ( + + )} +
+ {request.status === "new" ? ( +
+ + +
+ ) : ( + + )} +
+
+ )} +
+ ); +} + +function formatAccessRequestName(accessRequest: AccessRequest) { + return [accessRequest.lastName, accessRequest.firstName, accessRequest.middleName].filter(Boolean).join(" "); +} + +function getMembershipInviterMeta(data: LauncherData, membership: ClientMembership) { + const inviter = membership.invitedByUserId ? getUser(data, membership.invitedByUserId) : null; + const taskerRequest = membership.sourceTaskerInviteRequestId + ? data.taskerInviteRequests.find((request) => request.id === membership.sourceTaskerInviteRequestId) + : null; + + if (membership.source === "tasker_workspace_invite") { + const title = inviter?.name ?? taskerRequest?.inviterName ?? "Operational Core"; + const subtitle = inviter?.email ?? taskerRequest?.inviterEmail ?? "Self-service invite"; + const workspaceLabel = taskerRequest?.workspaceName || taskerRequest?.workspaceSlug; + + return { + title, + subtitle, + sourceLabel: workspaceLabel ? `Operational Core · ${workspaceLabel}` : "Operational Core", + showInAccessMatrix: true, + }; + } + + if (membership.source === "access_request") { + return { + title: "Публичная заявка", + subtitle: inviter ? `approve: ${inviter.name}` : "NODE.DC", + sourceLabel: "Заявка доступа", + showInAccessMatrix: true, + }; + } + + if (membership.inviteId) { + return { + title: inviter?.name ?? "NODE.DC", + subtitle: inviter?.email ?? "Launcher invite", + sourceLabel: "Launcher invite", + showInAccessMatrix: true, + }; + } + + return { + title: inviter?.name ?? "NODE.DC", + subtitle: inviter?.email ?? "Ручное добавление", + sourceLabel: "Ручное добавление", + showInAccessMatrix: false, + }; +} + +function MembershipInviterCell({ data, membership }: { data: LauncherData; membership: ClientMembership }) { + const inviterMeta = getMembershipInviterMeta(data, membership); + + return ( +
+ {inviterMeta.title} + {inviterMeta.subtitle} + {inviterMeta.sourceLabel}
); } @@ -3324,6 +3916,16 @@ function roleLabel(role: string): string { return labels[role] ?? role; } +function taskerInviteRoleLabel(role: TaskerInviteRequest["role"]): string { + const labels: Record = { + guest: "Guest", + member: "Member", + admin: "Admin", + }; + + return labels[role] ?? role; +} + function sectionTitle(section: AdminSection): string { const labels: Record = { overview: "Обзор", @@ -3363,6 +3965,32 @@ function accessAssignmentRoleLabel(role?: ServiceAppRole | null): string { return accessAssignmentLabel(role === "owner" ? "admin" : role); } +function publicOperationalCoreCellTitle(cell: AccessMatrixCell): string { + if (!cell.effectiveAccess.allowed) return "—"; + if (cell.effectiveAccess.appRole === "admin" || cell.effectiveAccess.appRole === "owner") return "Service Admin"; + if (cell.effectiveAccess.appRole === "viewer") return "Workspace Guest"; + return "Workspace Member"; +} + +function publicOperationalCoreCellSubtitle(cell: AccessMatrixCell): string { + if (!cell.effectiveAccess.allowed) return "Не назначен"; + if (cell.effectiveAccess.appRole === "admin" || cell.effectiveAccess.appRole === "owner") return "Self-service"; + return "Workspace invite"; +} + +function publicAccessAssignmentLabel(value: AccessAssignmentValue): string { + if (value === "admin") return "Service Admin"; + if (value === "viewer") return "Workspace Guest"; + if (value === "member") return "Workspace Member"; + if (value === "deny") return "—"; + return accessAssignmentLabel(value); +} + +function publicOperationalCoreSelectValue(value: AccessAssignmentValue): AccessAssignmentValue { + if (value === "viewer" || value === "member" || value === "admin" || value === "deny") return value; + return "unset"; +} + function accessCellKey(userId: string, serviceId: string): string { return `${userId}:${serviceId}`; } diff --git a/src/widgets/top-bar/TopBar.tsx b/src/widgets/top-bar/TopBar.tsx index c0ad4a5..c8b60b3 100644 --- a/src/widgets/top-bar/TopBar.tsx +++ b/src/widgets/top-bar/TopBar.tsx @@ -1,5 +1,6 @@ import { Inbox } from "lucide-react"; import type { Client } from "../../entities/client/types"; +import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants"; import type { MeResponse, ProfileOption } from "../../shared/api/mockApi"; import { initials } from "../../shared/lib/format"; import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui"; @@ -34,7 +35,11 @@ export function TopBar({ brandLinkUrl?: string; }) { const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId)); - const availableClients = clients.filter((client) => availableClientIds.has(client.id)); + const clientsWithPublicPool = [ + ...clients, + availableClientIds.has(PUBLIC_POOL_CLIENT.id) && !clients.some((client) => isPublicPoolClientId(client.id)) ? PUBLIC_POOL_CLIENT : null, + ].filter((client): client is Client => Boolean(client)); + const availableClients = clientsWithPublicPool.filter((client) => availableClientIds.has(client.id)); const activeClient = availableClients.find((client) => client.id === activeClientId); const clientOptions = availableClients.map((client) => ({ value: client.id,