ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: открытый контур и self-service инвайты
This commit is contained in:
parent
01e0988031
commit
a579e71b9b
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
|
|
@ -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")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<LauncherData>(() => 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<string | null>(null);
|
||||
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (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,17 +359,10 @@ export function LauncherApp() {
|
|||
void refreshTaskManagerWorkspaces();
|
||||
}, [adminOpen, canOpenAdminApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const refreshRuntimeState = async () => {
|
||||
const refreshRuntimeState = useCallback(async () => {
|
||||
try {
|
||||
const nextSession = await fetchAuthSession();
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setAuthSession(nextSession);
|
||||
|
||||
if (!nextSession.authenticated) {
|
||||
|
|
@ -359,17 +370,21 @@ export function LauncherApp() {
|
|||
return;
|
||||
}
|
||||
|
||||
const nextContext = resolveAuthenticatedContext(data, nextSession, activeProfileId, activeClientId);
|
||||
const nextMe = buildMe(data, nextContext.profileId, nextContext.clientId);
|
||||
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([
|
||||
nextMe.permissions.canOpenAdmin
|
||||
nextSession.isSuperAdmin || nextMe.permissions.canOpenAdmin
|
||||
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
||||
: loadPersistedLauncherData(),
|
||||
fetchAvailableApps(),
|
||||
]);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (persistedData) {
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
|
|
@ -378,12 +393,26 @@ export function LauncherApp() {
|
|||
} catch (error: unknown) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
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<typeof updateAdminAccessRequest>[1]) {
|
||||
applyControlPlaneMutation(updateAdminAccessRequest(accessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleApproveAccessRequest(accessRequestId: string, patch: Parameters<typeof approveAdminAccessRequest>[1]) {
|
||||
applyControlPlaneMutation(approveAdminAccessRequest(accessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleRejectAccessRequest(accessRequestId: string, patch: Parameters<typeof rejectAdminAccessRequest>[1]) {
|
||||
applyControlPlaneMutation(rejectAdminAccessRequest(accessRequestId, patch));
|
||||
}
|
||||
|
||||
function handleApproveTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
patch: Parameters<typeof approveAdminTaskerInviteRequest>[1]
|
||||
) {
|
||||
applyControlPlaneMutation(approveAdminTaskerInviteRequest(taskerInviteRequestId, patch));
|
||||
}
|
||||
|
||||
function handleRejectTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
patch: Parameters<typeof rejectAdminTaskerInviteRequest>[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 (
|
||||
<AccessRequestScreen
|
||||
onSubmit={createAccessRequest}
|
||||
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (inviteToken) {
|
||||
return (
|
||||
<InviteFlowScreen
|
||||
state={inviteFlow ?? { status: "loading" }}
|
||||
isAuthenticated={Boolean(authSession?.authenticated)}
|
||||
authenticatedEmail={authSession?.authenticated ? authSession.user.email : null}
|
||||
onAccept={() => 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<CreateAccessRequestResponse>;
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<CreateAccessRequestCommand>({
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
middleName: "",
|
||||
phone: "",
|
||||
company: "",
|
||||
});
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "submitted" | "error">("idle");
|
||||
const [message, setMessage] = useState<string | null>(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 (
|
||||
<div className="launcher-app nodedc-auth-page">
|
||||
<NodeDcAuthBrandHeader />
|
||||
<main className="nodedc-auth-page__main">
|
||||
<section className="nodedc-auth-card nodedc-access-request-card" aria-live="polite">
|
||||
<div className="nodedc-auth-card__copy">
|
||||
<h1>NODE.DC.</h1>
|
||||
<p>{isSubmitted ? "Вы запросили доступ." : "Работайте во всех измерениях."}</p>
|
||||
</div>
|
||||
|
||||
{!isSubmitted ? (
|
||||
<p className="nodedc-auth-card__status">
|
||||
Заполните обязательные поля. Заявка попадёт в очередь NODE.DC, после approve администратор передаст ссылку инвайта.
|
||||
</p>
|
||||
) : null}
|
||||
{message ? <p className="nodedc-auth-card__status">{message}</p> : null}
|
||||
|
||||
{isSubmitted ? (
|
||||
<div className="nodedc-auth-card__form">
|
||||
<button className="button button--primary" type="button" onClick={onLogin}>
|
||||
Войти в NODE.DC
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="nodedc-auth-card__form"
|
||||
onSubmit={(event) => {
|
||||
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 : "Не удалось отправить заявку.");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Эл. почта</span>
|
||||
<input
|
||||
value={values.email}
|
||||
type="email"
|
||||
placeholder="email@company.ru"
|
||||
autoComplete="email"
|
||||
onChange={(event) => updateField("email", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="nodedc-auth-card__field-grid">
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Фамилия</span>
|
||||
<input
|
||||
value={values.lastName}
|
||||
placeholder="Иванов"
|
||||
autoComplete="family-name"
|
||||
onChange={(event) => updateField("lastName", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Имя</span>
|
||||
<input
|
||||
value={values.firstName}
|
||||
placeholder="Иван"
|
||||
autoComplete="given-name"
|
||||
onChange={(event) => updateField("firstName", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Отчество</span>
|
||||
<input value={values.middleName} placeholder="Иванович" onChange={(event) => updateField("middleName", event.target.value)} />
|
||||
</label>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Телефон</span>
|
||||
<input
|
||||
value={values.phone}
|
||||
type="tel"
|
||||
placeholder="+7 999 000-00-00"
|
||||
autoComplete="tel"
|
||||
onChange={(event) => updateField("phone", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="nodedc-auth-card__field">
|
||||
<span>Компания</span>
|
||||
<input
|
||||
value={values.company}
|
||||
placeholder="Название компании"
|
||||
autoComplete="organization"
|
||||
onChange={(event) => updateField("company", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="button button--primary" type="submit" disabled={!canSubmit}>
|
||||
{status === "submitting" ? "Отправляем заявку" : "Запросить доступ"}
|
||||
</button>
|
||||
<button className="button button--secondary" type="button" onClick={onLogin}>
|
||||
Уже есть аккаунт
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.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 (
|
||||
<div className="launcher-app nodedc-auth-page">
|
||||
|
|
@ -1004,7 +1280,11 @@ function InviteFlowScreen({
|
|||
Уже есть аккаунт
|
||||
</button>
|
||||
</form>
|
||||
) : requiresAccountSwitch ? (
|
||||
) : existingAccount && !isAuthenticated && !isTerminalInvite ? (
|
||||
<button className="button button--primary" type="button" onClick={onLogin}>
|
||||
Войти и принять приглашение
|
||||
</button>
|
||||
) : (existingAccount && isAuthenticatedAsDifferentUser && !isTerminalInvite) || requiresAccountSwitch ? (
|
||||
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
|
||||
Сменить аккаунт
|
||||
</button>
|
||||
|
|
@ -1013,8 +1293,18 @@ function InviteFlowScreen({
|
|||
Войти в NODE.DC
|
||||
</button>
|
||||
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
|
||||
<button className="button button--primary" type="button" onClick={onGoHome}>
|
||||
Перейти в витрину
|
||||
<button
|
||||
className="button button--primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (canOpenInviteTarget && inviteTargetUrl) {
|
||||
window.location.assign(inviteTargetUrl);
|
||||
return;
|
||||
}
|
||||
onGoHome();
|
||||
}}
|
||||
>
|
||||
{canOpenInviteTarget ? "Перейти в workspace" : "Перейти в витрину"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
|
||||
|
|
@ -1037,7 +1327,26 @@ function NodeDcAuthBrandHeader() {
|
|||
);
|
||||
}
|
||||
|
||||
function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boolean, inviteStatus?: Invite["status"]) {
|
||||
function resolveInviteStatusMessage(
|
||||
state: InviteFlowState,
|
||||
context: {
|
||||
existingAccount: boolean;
|
||||
inviteEmail: string;
|
||||
inviteStatus?: Invite["status"];
|
||||
isAuthenticated: boolean;
|
||||
isAuthenticatedAsInvitee: boolean;
|
||||
isAuthenticatedAsDifferentUser: boolean;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
existingAccount,
|
||||
inviteEmail,
|
||||
inviteStatus,
|
||||
isAuthenticated,
|
||||
isAuthenticatedAsInvitee,
|
||||
isAuthenticatedAsDifferentUser,
|
||||
} = context;
|
||||
|
||||
if (state.status === "loading") return "Проверяем приглашение.";
|
||||
if (state.status === "accepting") return "Подключаем доступ к рабочей области.";
|
||||
if (state.status === "registering") return "Создаём аккаунт и подключаем доступ.";
|
||||
|
|
@ -1045,6 +1354,9 @@ function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boo
|
|||
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
|
||||
if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
|
||||
if (inviteStatus === "revoked") return "Приглашение отозвано.";
|
||||
if (existingAccount && !isAuthenticated) return `Аккаунт ${inviteEmail} уже есть в NODE.DC. Войдите под этой почтой, чтобы принять приглашение.`;
|
||||
if (existingAccount && isAuthenticatedAsDifferentUser) return `Сейчас открыт другой аккаунт. Смените пользователя и войдите под ${inviteEmail}.`;
|
||||
if (existingAccount && isAuthenticatedAsInvitee) return "Аккаунт найден. Подтвердите подключение к workspace.";
|
||||
if (!isAuthenticated) return "Введите почту, имя и пароль для регистрации по приглашению.";
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1101,6 +1413,10 @@ function parseInviteToken(pathname: string) {
|
|||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function isAccessRequestPath(pathname: string) {
|
||||
return /^\/(?:request-access|access-request)\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
function membershipRoleLabel(role: ClientMembership["role"]) {
|
||||
return {
|
||||
client_owner: "Владелец клиента",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import type { ClientMembershipRole } from "../user/types";
|
||||
|
||||
export type AccessRequestStatus = "new" | "approved" | "rejected";
|
||||
|
||||
export interface AccessRequest {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
status: AccessRequestStatus;
|
||||
targetClientId: string;
|
||||
role: ClientMembershipRole;
|
||||
approvedInviteId?: string | null;
|
||||
reviewedByUserId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
comment?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateAccessRequestCommand {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ export interface Invite {
|
|||
email: string;
|
||||
role: ClientMembershipRole;
|
||||
invitedByUserId: string;
|
||||
source?: "launcher" | "access_request" | "tasker_workspace_invite";
|
||||
sourceTaskerInviteRequestId?: string | null;
|
||||
sourceTaskerInviteId?: string | null;
|
||||
sourceWorkspaceSlug?: string | null;
|
||||
sourceWorkspaceName?: string | null;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
status: InviteStatus;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import type { Client } from "../client/types";
|
||||
|
||||
export const PUBLIC_POOL_CLIENT_ID = "client_public_pool";
|
||||
export const PUBLIC_POOL_CONTEXT_LABEL = "Открытый контур";
|
||||
export const PUBLIC_POOL_CONTEXT_DESCRIPTION = "Public access pool";
|
||||
|
||||
export const PUBLIC_POOL_CLIENT: Client = {
|
||||
id: PUBLIC_POOL_CLIENT_ID,
|
||||
type: "person",
|
||||
name: PUBLIC_POOL_CONTEXT_LABEL,
|
||||
legalName: PUBLIC_POOL_CONTEXT_DESCRIPTION,
|
||||
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",
|
||||
};
|
||||
|
||||
export function isPublicPoolClientId(clientId: string | null | undefined): boolean {
|
||||
return clientId === PUBLIC_POOL_CLIENT_ID;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
export type TaskerInviteRequestStatus = "new" | "approved" | "rejected" | "cancelled";
|
||||
export type TaskerInviteRequestRole = "guest" | "member" | "admin";
|
||||
|
||||
export interface TaskerInviteRequest {
|
||||
id: string;
|
||||
taskerInviteId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceSlug: string;
|
||||
workspaceName: string;
|
||||
inviteeEmail: string;
|
||||
role: TaskerInviteRequestRole;
|
||||
inviterUserId?: string | null;
|
||||
inviterPlaneUserId?: string | null;
|
||||
inviterEmail: string;
|
||||
inviterName: string;
|
||||
status: TaskerInviteRequestStatus;
|
||||
taskerInviteLink?: string | null;
|
||||
platformInviteId?: string | null;
|
||||
platformInviteToken?: string | null;
|
||||
reviewedByUserId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
comment?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -30,6 +30,10 @@ export interface ClientMembership {
|
|||
userId: string;
|
||||
role: ClientMembershipRole;
|
||||
status: ClientMembershipStatus;
|
||||
invitedByUserId?: string | null;
|
||||
inviteId?: string | null;
|
||||
source?: "launcher" | "access_request" | "tasker_workspace_invite" | null;
|
||||
sourceTaskerInviteRequestId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import type { AccessRequest, CreateAccessRequestCommand } from "../../entities/access-request/types";
|
||||
|
||||
export interface CreateAccessRequestResponse {
|
||||
accessRequest: AccessRequest;
|
||||
}
|
||||
|
||||
export async function createAccessRequest(command: CreateAccessRequestCommand): Promise<CreateAccessRequestResponse> {
|
||||
return requestJson<CreateAccessRequestResponse>("/api/access-requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
return payload.error ?? response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import type { AccessRequest } from "../../entities/access-request/types";
|
||||
import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types";
|
||||
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||
import type { LauncherData, LauncherSettings } from "./mockApi";
|
||||
|
||||
|
|
@ -31,6 +33,31 @@ export interface ControlPlaneMutationResult {
|
|||
} | null;
|
||||
}
|
||||
|
||||
export interface AccessRequestMutationResult extends ControlPlaneMutationResult {
|
||||
accessRequest: AccessRequest;
|
||||
}
|
||||
|
||||
export interface AccessRequestApproveResult extends AccessRequestMutationResult {
|
||||
invite: Invite;
|
||||
}
|
||||
|
||||
export interface TaskerInviteRequestMutationResult extends ControlPlaneMutationResult {
|
||||
taskerInviteRequest: TaskerInviteRequest;
|
||||
tasker?: {
|
||||
ok: boolean;
|
||||
invite?: {
|
||||
id: string;
|
||||
email: string;
|
||||
status: string;
|
||||
inviteLink?: string | null;
|
||||
invite_link?: string | null;
|
||||
taskerInviteLink?: string | null;
|
||||
tasker_invite_link?: string | null;
|
||||
platformInviteLink?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskManagerWorkspaceSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
|
@ -304,6 +331,62 @@ export async function deleteAdminInvite(inviteId: string): Promise<ControlPlaneM
|
|||
return requestJson<ControlPlaneMutationResult>(`/api/admin/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateAdminAccessRequest(
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
): Promise<AccessRequestMutationResult> {
|
||||
return requestJson<AccessRequestMutationResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveAdminAccessRequest(
|
||||
accessRequestId: string,
|
||||
payload: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">> = {}
|
||||
): Promise<AccessRequestApproveResult> {
|
||||
return requestJson<AccessRequestApproveResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectAdminAccessRequest(
|
||||
accessRequestId: string,
|
||||
payload: Partial<Pick<AccessRequest, "comment">> = {}
|
||||
): Promise<AccessRequestMutationResult> {
|
||||
return requestJson<AccessRequestMutationResult>(`/api/admin/access-requests/${encodeURIComponent(accessRequestId)}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveAdminTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
payload: Partial<Pick<TaskerInviteRequest, "comment">> = {}
|
||||
): Promise<TaskerInviteRequestMutationResult> {
|
||||
return requestJson<TaskerInviteRequestMutationResult>(
|
||||
`/api/admin/tasker-invite-requests/${encodeURIComponent(taskerInviteRequestId)}/approve`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function rejectAdminTaskerInviteRequest(
|
||||
taskerInviteRequestId: string,
|
||||
payload: Partial<Pick<TaskerInviteRequest, "comment">> = {}
|
||||
): Promise<TaskerInviteRequestMutationResult> {
|
||||
return requestJson<TaskerInviteRequestMutationResult>(
|
||||
`/api/admin/tasker-invite-requests/${encodeURIComponent(taskerInviteRequestId)}/reject`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function setAdminUserServiceAccess(payload: {
|
||||
userId: string;
|
||||
serviceId: string;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import type { Invite } from "../../entities/invite/types";
|
|||
import type { LauncherData } from "./mockApi";
|
||||
|
||||
export interface PublicInviteResponse {
|
||||
invite: Pick<Invite, "id" | "role" | "expiresAt" | "status">;
|
||||
invite: Pick<Invite, "id" | "email" | "role" | "expiresAt" | "status" | "source" | "sourceWorkspaceName" | "sourceWorkspaceSlug">;
|
||||
client: Pick<Client, "id" | "name" | "status">;
|
||||
redirectUrl?: string;
|
||||
account: {
|
||||
exists: boolean;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AcceptInviteResponse {
|
||||
|
|
@ -14,6 +19,7 @@ export interface AcceptInviteResponse {
|
|||
user: LauncherUser;
|
||||
membership: ClientMembership;
|
||||
data: LauncherData;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
export interface RegisterInviteCommand {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
|
||||
import type { AccessRequest } from "../../entities/access-request/types";
|
||||
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
||||
import { getServiceLaunchLink } from "../../entities/service/links";
|
||||
import type { LauncherServiceView, Service } from "../../entities/service/types";
|
||||
import type { SyncStatus } from "../../entities/sync/types";
|
||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||
import type {
|
||||
ClientGroup,
|
||||
ClientMembership,
|
||||
|
|
@ -15,6 +18,8 @@ import type {
|
|||
import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions";
|
||||
import {
|
||||
mockAuditEvents,
|
||||
mockAccessRequests,
|
||||
mockTaskerInviteRequests,
|
||||
mockClients,
|
||||
mockExceptions,
|
||||
mockGrants,
|
||||
|
|
@ -58,6 +63,8 @@ export interface LauncherData {
|
|||
grants: ServiceGrant[];
|
||||
exceptions: ServiceAccessException[];
|
||||
invites: Invite[];
|
||||
accessRequests: AccessRequest[];
|
||||
taskerInviteRequests: TaskerInviteRequest[];
|
||||
syncStatuses: SyncStatus[];
|
||||
auditEvents: typeof mockAuditEvents;
|
||||
taskManagerMemberships: TaskManagerMembershipAssignment[];
|
||||
|
|
@ -144,6 +151,8 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({
|
|||
grants: mockGrants,
|
||||
exceptions: mockExceptions,
|
||||
invites: mockInvites,
|
||||
accessRequests: mockAccessRequests,
|
||||
taskerInviteRequests: mockTaskerInviteRequests,
|
||||
syncStatuses: mockSyncStatuses,
|
||||
auditEvents: mockAuditEvents,
|
||||
settings: defaultLauncherSettings,
|
||||
|
|
@ -189,6 +198,8 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
|||
grants: Array.isArray(payload.grants) ? payload.grants : mockGrants,
|
||||
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
||||
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
||||
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
||||
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
||||
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
|
||||
|
|
@ -354,6 +365,10 @@ export function buildAccessMatrix(data: LauncherData, clientId: string, includeA
|
|||
}
|
||||
|
||||
export function getClient(data: LauncherData, clientId: string): Client {
|
||||
if (isPublicPoolClientId(clientId)) {
|
||||
return PUBLIC_POOL_CLIENT;
|
||||
}
|
||||
|
||||
const client = data.clients.find((item) => item.id === clientId);
|
||||
if (!client) throw new Error(`Unknown client: ${clientId}`);
|
||||
return client;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { AuditEvent } from "../../entities/audit/types";
|
||||
import type { AccessRequest } from "../../entities/access-request/types";
|
||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||
import type { Client } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { Service } from "../../entities/service/types";
|
||||
|
|
@ -211,6 +213,9 @@ export const mockExceptions: ServiceAccessException[] = [];
|
|||
|
||||
export const mockInvites: Invite[] = [];
|
||||
|
||||
export const mockAccessRequests: AccessRequest[] = [];
|
||||
export const mockTaskerInviteRequests: TaskerInviteRequest[] = [];
|
||||
|
||||
export const mockSyncStatuses: SyncStatus[] = [
|
||||
sync("sync_dctouch_client_authentik", "client_romashka", "DCTOUCH", "client", "authentik", "synced"),
|
||||
sync("sync_dc_touch_authentik", "user_root", "dcctouch@gmail.com", "user", "authentik", "synced"),
|
||||
|
|
|
|||
|
|
@ -174,6 +174,10 @@ code {
|
|||
-webkit-backdrop-filter: blur(40px);
|
||||
}
|
||||
|
||||
.nodedc-access-request-card {
|
||||
width: min(100%, 36rem);
|
||||
}
|
||||
|
||||
.nodedc-auth-card__copy {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
|
@ -224,6 +228,12 @@ code {
|
|||
gap: 1.05rem;
|
||||
}
|
||||
|
||||
.nodedc-auth-card__field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.05rem;
|
||||
}
|
||||
|
||||
.nodedc-auth-card__field {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
|
|
@ -2171,7 +2181,7 @@ code {
|
|||
}
|
||||
|
||||
.admin-data-table--users {
|
||||
min-width: 66rem;
|
||||
min-width: 78rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
|
|
@ -2201,7 +2211,7 @@ code {
|
|||
|
||||
.admin-data-table--users th:nth-child(3),
|
||||
.admin-data-table--users td:nth-child(3) {
|
||||
width: 12rem;
|
||||
width: 13.5rem;
|
||||
}
|
||||
|
||||
.admin-data-table--users th:nth-child(4),
|
||||
|
|
@ -2211,14 +2221,44 @@ code {
|
|||
|
||||
.admin-data-table--users th:nth-child(5),
|
||||
.admin-data-table--users td:nth-child(5) {
|
||||
width: 18rem;
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.admin-data-table--users th:nth-child(6),
|
||||
.admin-data-table--users td:nth-child(6) {
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
.admin-data-table--users th:nth-child(7),
|
||||
.admin-data-table--users td:nth-child(7) {
|
||||
width: 10.2rem;
|
||||
}
|
||||
|
||||
.membership-inviter-cell {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
max-width: 13.5rem;
|
||||
}
|
||||
|
||||
.membership-inviter-cell span,
|
||||
.membership-inviter-cell small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.membership-inviter-cell span {
|
||||
color: var(--text-primary);
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.membership-inviter-cell small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.71rem;
|
||||
}
|
||||
|
||||
.admin-static-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -3338,6 +3378,12 @@ code {
|
|||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.access-user-cell__inviter {
|
||||
color: var(--accent-lime);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.access-main-stack {
|
||||
display: grid;
|
||||
width: 10.8rem;
|
||||
|
|
@ -3616,6 +3662,41 @@ code {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-tabs-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-tab-button {
|
||||
min-height: 2.4rem;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.95rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 820;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-tab-button:hover,
|
||||
.admin-tab-button:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-tab-button--active {
|
||||
background: rgba(247, 248, 244, 0.96);
|
||||
color: rgb(var(--nodedc-on-accent-rgb));
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
|
|
@ -3747,6 +3828,160 @@ code {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests {
|
||||
width: max-content;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.access-request-table-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
margin: 0 -0.25rem;
|
||||
padding: 0 0.25rem 0.35rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests th,
|
||||
.admin-data-table--access-requests td {
|
||||
width: 1%;
|
||||
padding-inline: 0.78rem;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests th:nth-child(7),
|
||||
.admin-data-table--access-requests td:nth-child(7) {
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests th:nth-child(8),
|
||||
.admin-data-table--access-requests td:nth-child(8) {
|
||||
min-width: 4.75rem;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests .admin-table-select-wrap {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests .admin-table-select-trigger {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: 13rem;
|
||||
padding-inline: 0.82rem 0.68rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests td:nth-child(4) .admin-table-select-trigger {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests td:nth-child(5) .admin-table-select-trigger {
|
||||
min-width: 8.4rem;
|
||||
max-width: 9.5rem;
|
||||
}
|
||||
|
||||
.access-request-applicant {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
width: max-content;
|
||||
min-width: 8.5rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.access-request-applicant strong,
|
||||
.access-request-applicant small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.access-request-applicant small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.access-request-contact {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
width: max-content;
|
||||
min-width: 10.5rem;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.access-request-contact span,
|
||||
.access-request-contact small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.access-request-contact small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-data-table--access-requests .invite-link-cell {
|
||||
width: min(24rem, 42vw);
|
||||
}
|
||||
|
||||
.access-request-decision-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.45rem;
|
||||
padding: 0.24rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.access-request-decision-button {
|
||||
display: grid;
|
||||
width: 1.95rem;
|
||||
min-width: 1.95rem;
|
||||
height: 1.95rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background 160ms ease,
|
||||
color 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.access-request-decision-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.access-request-decision-button:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.access-request-decision-button--accept {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.access-request-decision-button--accept:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.access-request-decision-button--decline {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.access-request-decision-button--decline:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.admin-helper-note {
|
||||
max-width: 38rem;
|
||||
margin: 0.22rem 0 0;
|
||||
|
|
@ -4168,6 +4403,10 @@ code {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.nodedc-auth-card__field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-expanded-toolbar-shell {
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy }
|
|||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
ClipboardList,
|
||||
Copy,
|
||||
DatabaseZap,
|
||||
|
|
@ -39,12 +40,15 @@ import {
|
|||
Video,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { AccessRequest, AccessRequestStatus } from "../../entities/access-request/types";
|
||||
import type { ServiceAppRole } from "../../entities/access/types";
|
||||
import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types";
|
||||
import type { Invite, InviteStatus } from "../../entities/invite/types";
|
||||
import { PUBLIC_POOL_CLIENT_ID, PUBLIC_POOL_CONTEXT_DESCRIPTION, PUBLIC_POOL_CONTEXT_LABEL, isPublicPoolClientId } from "../../entities/public-pool/constants";
|
||||
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
|
||||
import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
|
||||
import type { SyncState, SyncStatus } from "../../entities/sync/types";
|
||||
import type { TaskerInviteRequest } from "../../entities/tasker-invite-request/types";
|
||||
import type {
|
||||
ClientGroup,
|
||||
ClientMembership,
|
||||
|
|
@ -143,6 +147,16 @@ const clientSections: Array<{ id: AdminSection; label: string; icon: React.React
|
|||
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
|
||||
];
|
||||
|
||||
const publicPoolSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
||||
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
||||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||||
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
||||
{ id: "misc", label: "Разное", icon: <SlidersHorizontal size={16} /> },
|
||||
];
|
||||
|
||||
export function AdminOverlay({
|
||||
data,
|
||||
me,
|
||||
|
|
@ -152,6 +166,11 @@ export function AdminOverlay({
|
|||
onCreateInvite,
|
||||
onUpdateInvite,
|
||||
onDeleteInvite,
|
||||
onUpdateAccessRequest,
|
||||
onApproveAccessRequest,
|
||||
onRejectAccessRequest,
|
||||
onApproveTaskerInviteRequest,
|
||||
onRejectTaskerInviteRequest,
|
||||
onRetrySync,
|
||||
onCreateClient,
|
||||
onUpdateClient,
|
||||
|
|
@ -186,6 +205,17 @@ export function AdminOverlay({
|
|||
onCreateInvite: (invite: Pick<Invite, "clientId" | "email" | "role">) => void;
|
||||
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
|
||||
onDeleteInvite: (inviteId: string) => void;
|
||||
onUpdateAccessRequest: (
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
) => void;
|
||||
onApproveAccessRequest: (
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
) => void;
|
||||
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
||||
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onRetrySync: (syncId: string) => void;
|
||||
onCreateClient: () => void;
|
||||
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
||||
|
|
@ -213,16 +243,18 @@ export function AdminOverlay({
|
|||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||||
}) {
|
||||
const isRoot = me.launcherRole === "root_admin";
|
||||
const sections = isRoot ? rootSections : clientSections;
|
||||
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
|
||||
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
|
||||
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
|
||||
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
||||
|
||||
const fallbackClientId = data.clients[0]?.id ?? activeClientId;
|
||||
const selectedClientExists = data.clients.some((client) => client.id === selectedClientId);
|
||||
const selectedContextIsPublicPool = isRoot && isPublicPoolClientId(selectedClientId);
|
||||
const selectedClientExists = selectedContextIsPublicPool || data.clients.some((client) => client.id === selectedClientId);
|
||||
const scopedClientId = isRoot ? (selectedClientExists ? selectedClientId : fallbackClientId) : activeClientId;
|
||||
const isPublicPoolContext = isRoot && isPublicPoolClientId(scopedClientId);
|
||||
const currentClient = getClient(data, scopedClientId);
|
||||
const sections = isPublicPoolContext ? publicPoolSections : isRoot ? rootSections : clientSections;
|
||||
const accessMatrix = useMemo(() => buildAccessMatrix(data, scopedClientId, isRoot), [data, scopedClientId, isRoot]);
|
||||
const selectedAccessCell =
|
||||
accessMatrix.cells.find((cell) => cell.userId === selectedCell?.userId && cell.serviceId === selectedCell?.serviceId) ??
|
||||
|
|
@ -236,6 +268,12 @@ export function AdminOverlay({
|
|||
}
|
||||
}, [data.clients, isRoot, selectedClientExists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection && !sections.some((section) => section.id === activeSection)) {
|
||||
setActiveSection(sections[0]?.id ?? null);
|
||||
}
|
||||
}, [activeSection, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
|
|
@ -271,8 +309,11 @@ export function AdminOverlay({
|
|||
{isRoot ? (
|
||||
<NodeDcSelect
|
||||
value={selectedClientId}
|
||||
options={data.clients.map((client) => ({ value: client.id, label: client.name, description: client.legalName ?? undefined }))}
|
||||
label="Выбрать клиента"
|
||||
options={[
|
||||
{ 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 })),
|
||||
]}
|
||||
label="Выбрать контур администрирования"
|
||||
searchable
|
||||
minMenuWidth={292}
|
||||
onChange={(clientId) => {
|
||||
|
|
@ -289,7 +330,7 @@ export function AdminOverlay({
|
|||
onClick={toggle}
|
||||
>
|
||||
<span className="admin-panel-client-select__icon">
|
||||
<Building2 size={16} />
|
||||
{selectedContextIsPublicPool ? <Globe2 size={16} /> : <Building2 size={16} />}
|
||||
</span>
|
||||
<span className="admin-panel-client-select__name">{selectedOption?.label ?? currentClient.name}</span>
|
||||
<span className="admin-panel-client-select__chevron" aria-hidden="true" />
|
||||
|
|
@ -335,7 +376,9 @@ export function AdminOverlay({
|
|||
onCloseContent={() => setActiveSection(null)}
|
||||
/>
|
||||
<div className="admin-panel-content__body">
|
||||
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
|
||||
{activeSection === "overview" ? (
|
||||
<OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} isPublicPoolContext={isPublicPoolContext} />
|
||||
) : null}
|
||||
{activeSection === "clients" && isRoot ? (
|
||||
<ClientsSection
|
||||
data={data}
|
||||
|
|
@ -352,7 +395,6 @@ export function AdminOverlay({
|
|||
<UsersSection
|
||||
data={data}
|
||||
clientId={scopedClientId}
|
||||
isRoot={isRoot}
|
||||
onCreateUser={onCreateUser}
|
||||
onUpdateUser={onUpdateUser}
|
||||
/>
|
||||
|
|
@ -369,6 +411,7 @@ export function AdminOverlay({
|
|||
{activeSection === "services" && isRoot ? (
|
||||
<ServicesSection
|
||||
data={data}
|
||||
isPublicPoolContext={isPublicPoolContext}
|
||||
onUpdateService={onUpdateService}
|
||||
onReorderServices={onReorderServices}
|
||||
onCreateService={onCreateService}
|
||||
|
|
@ -397,9 +440,15 @@ export function AdminOverlay({
|
|||
data={data}
|
||||
clientId={scopedClientId}
|
||||
actorUserId={me.user.id}
|
||||
isPublicPoolContext={isPublicPoolContext}
|
||||
onCreateInvite={onCreateInvite}
|
||||
onUpdateInvite={onUpdateInvite}
|
||||
onDeleteInvite={onDeleteInvite}
|
||||
onUpdateAccessRequest={onUpdateAccessRequest}
|
||||
onApproveAccessRequest={onApproveAccessRequest}
|
||||
onRejectAccessRequest={onRejectAccessRequest}
|
||||
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
||||
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
||||
|
|
@ -442,10 +491,51 @@ function AdminHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function OverviewSection({ data, clientId, isRoot }: { data: LauncherData; clientId: string; isRoot: boolean }) {
|
||||
function OverviewSection({
|
||||
data,
|
||||
clientId,
|
||||
isRoot,
|
||||
isPublicPoolContext,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
clientId: string;
|
||||
isRoot: boolean;
|
||||
isPublicPoolContext: boolean;
|
||||
}) {
|
||||
const clientUsers = getClientUsers(data, clientId);
|
||||
const clientServiceCount = data.grants.filter((grant) => grant.targetType === "client" && grant.targetId === clientId).length;
|
||||
const syncErrors = data.syncStatuses.filter((sync) => sync.state === "error" && (isRoot || sync.objectId === clientId)).length;
|
||||
const newAccessRequests = data.accessRequests.filter((request) => request.status === "new").length;
|
||||
const approvedAccessRequests = data.accessRequests.filter((request) => request.status === "approved").length;
|
||||
const publicInvites = data.invites.filter((invite) => invite.clientId === PUBLIC_POOL_CLIENT_ID).length;
|
||||
|
||||
if (isPublicPoolContext) {
|
||||
return (
|
||||
<section className="admin-section-grid">
|
||||
<MetricCard label="Входящих заявок" value={newAccessRequests} hint="Ожидают approve" danger={newAccessRequests > 0} />
|
||||
<MetricCard label="Public участников" value={clientUsers.length} hint="Открытый контур" />
|
||||
<MetricCard label="Public инвайтов" value={publicInvites} hint="Исходящие ссылки" />
|
||||
<MetricCard label="Подтверждено" value={approvedAccessRequests} hint="Заявки public pool" />
|
||||
|
||||
<GlassSurface className="admin-wide-card">
|
||||
<h3>Public access pool</h3>
|
||||
<p className="admin-helper-note">
|
||||
Это системный контур для свободных запросов доступа. Заявки можно оставить в открытом контуре или вручную назначить
|
||||
в enterprise-клиента перед выпуском инвайта.
|
||||
</p>
|
||||
<div className="activity-list">
|
||||
{data.auditEvents.slice(0, 5).map((event) => (
|
||||
<div key={event.id} className="activity-row">
|
||||
<span>{formatDateTime(event.at)}</span>
|
||||
<strong>{event.action}</strong>
|
||||
<em>{event.objectName}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-section-grid">
|
||||
|
|
@ -607,13 +697,11 @@ function ClientsSection({
|
|||
function UsersSection({
|
||||
data,
|
||||
clientId,
|
||||
isRoot,
|
||||
onCreateUser,
|
||||
onUpdateUser,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
clientId: string;
|
||||
isRoot: boolean;
|
||||
onCreateUser: (command: CreateUserCommand) => void;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
}) {
|
||||
|
|
@ -621,9 +709,7 @@ function UsersSection({
|
|||
const [newUserName, setNewUserName] = useState("");
|
||||
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
|
||||
const [newUserGroupId, setNewUserGroupId] = useState<string>("none");
|
||||
const rows = isRoot
|
||||
? data.memberships.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }))
|
||||
: data.memberships
|
||||
const rows = data.memberships
|
||||
.filter((membership) => membership.clientId === clientId)
|
||||
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
|
||||
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
|
||||
|
|
@ -703,6 +789,7 @@ function UsersSection({
|
|||
<tr>
|
||||
<th>Пользователь</th>
|
||||
<th>Клиент</th>
|
||||
<th>Кто пригласил</th>
|
||||
<th>Телефон</th>
|
||||
<th>Должность</th>
|
||||
<th>Заметки</th>
|
||||
|
|
@ -732,6 +819,9 @@ function UsersSection({
|
|||
</div>
|
||||
</td>
|
||||
<td>{client.name}</td>
|
||||
<td>
|
||||
<MembershipInviterCell data={data} membership={membership} />
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
|
|
@ -933,6 +1023,19 @@ const inviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = [
|
|||
{ value: "revoked", label: "Отозван", tone: "red" },
|
||||
];
|
||||
|
||||
const accessRequestStatusOptions: Array<AdminStatusOption<AccessRequestStatus>> = [
|
||||
{ value: "new", label: "Входящая", tone: "yellow" },
|
||||
{ value: "approved", label: "Подтверждено", tone: "green" },
|
||||
{ value: "rejected", label: "Отклонено", tone: "red" },
|
||||
];
|
||||
|
||||
const taskerInviteRequestStatusOptions: Array<AdminStatusOption<TaskerInviteRequest["status"]>> = [
|
||||
{ value: "new", label: "Ожидает", tone: "yellow" },
|
||||
{ value: "approved", label: "Подтверждено", tone: "green" },
|
||||
{ value: "rejected", label: "Отклонено", tone: "red" },
|
||||
{ value: "cancelled", label: "Отозвано", tone: "red" },
|
||||
];
|
||||
|
||||
const syncStatusOptions: Array<AdminStatusOption<SyncState>> = [
|
||||
{ value: "synced", label: "Синхронизировано", tone: "green" },
|
||||
{ value: "pending", label: "В очереди", tone: "yellow" },
|
||||
|
|
@ -954,6 +1057,14 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
|
|||
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
|
||||
];
|
||||
|
||||
const publicOperationalCoreAccessOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
|
||||
{ value: "unset", label: "—", description: "Не назначен" },
|
||||
{ value: "viewer", label: "Workspace Guest", description: "Доступ к приглашённому workspace", tone: "green" },
|
||||
{ value: "member", label: "Workspace Member", description: "Доступ к приглашённому workspace", tone: "green" },
|
||||
{ value: "admin", label: "Service Admin", description: "Self-service", tone: "green" },
|
||||
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
|
||||
];
|
||||
|
||||
const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
|
||||
...accessAssignmentOptions,
|
||||
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
||||
|
|
@ -1106,12 +1217,14 @@ const modalActionAccentRgb = [247, 248, 244] as const;
|
|||
|
||||
function ServicesSection({
|
||||
data,
|
||||
isPublicPoolContext,
|
||||
onUpdateService,
|
||||
onReorderServices,
|
||||
onCreateService,
|
||||
onDeleteService,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
isPublicPoolContext: boolean;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||||
onReorderServices: (orderedServiceIds: string[]) => void;
|
||||
onCreateService: () => void;
|
||||
|
|
@ -1167,7 +1280,14 @@ function ServicesSection({
|
|||
<>
|
||||
<GlassSurface className="table-shell services-table-shell">
|
||||
<div className="table-toolbar">
|
||||
<h3>Каталог сервисов</h3>
|
||||
<div>
|
||||
<h3>{isPublicPoolContext ? "Каталог сервисов · открытый контур" : "Каталог сервисов"}</h3>
|
||||
{isPublicPoolContext ? (
|
||||
<p className="admin-helper-note">
|
||||
Пока это общий каталог модулей. Видимость и запуск для public users регулируются grants в матрице доступа открытого контура.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<IconButton label="Создать сервис" className="admin-circle-action admin-circle-action--solid" type="button" onClick={onCreateService}>
|
||||
<Plus size={17} />
|
||||
</IconButton>
|
||||
|
|
@ -1776,7 +1896,7 @@ function ClientEditorModal({
|
|||
function toggleTaskManagerWorkspace(workspace: TaskManagerWorkspaceSummary) {
|
||||
const currentWorkspaces = getClientTaskManagerWorkspaces(draft);
|
||||
const exists = currentWorkspaces.some((item) => item.slug === workspace.slug);
|
||||
const nextWorkspaces = exists
|
||||
const nextWorkspaces: ClientTaskManagerWorkspaceBinding[] = exists
|
||||
? currentWorkspaces.filter((item) => item.slug !== workspace.slug)
|
||||
: [
|
||||
...currentWorkspaces,
|
||||
|
|
@ -2409,9 +2529,11 @@ function AccessSection({
|
|||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||||
}) {
|
||||
const hasUsers = matrix.users.length > 0;
|
||||
const isPublicPoolContext = isPublicPoolClientId(matrix.client.id);
|
||||
const clientTaskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client);
|
||||
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(matrix.client);
|
||||
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
|
||||
const detailsService = detailsCell ? getService(data, detailsCell.serviceId) : null;
|
||||
|
||||
if (!hasUsers) {
|
||||
return (
|
||||
|
|
@ -2461,6 +2583,7 @@ function AccessSection({
|
|||
if (!membership) return null;
|
||||
|
||||
const protectedUser = user.id === "user_root";
|
||||
const inviterMeta = getMembershipInviterMeta(data, membership);
|
||||
const pendingKey = `${matrix.client.id}:${user.id}:${primaryTaskManagerWorkspace?.slug ?? "primary"}`;
|
||||
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
|
||||
const forcedTaskManagerAdmin = membership.role === "client_owner";
|
||||
|
|
@ -2470,6 +2593,11 @@ function AccessSection({
|
|||
<div className="access-grid-cell access-grid-sticky access-user-cell" role="rowheader">
|
||||
<strong>{user.name}</strong>
|
||||
<small>{user.email}</small>
|
||||
{inviterMeta.showInAccessMatrix ? (
|
||||
<small className="access-user-cell__inviter" title={`${inviterMeta.title} · ${inviterMeta.sourceLabel}`}>
|
||||
через {inviterMeta.title}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="access-grid-cell" role="cell">
|
||||
<MainStatusControl
|
||||
|
|
@ -2489,6 +2617,7 @@ function AccessSection({
|
|||
const cell = matrix.cells.find((item) => item.userId === user.id && item.serviceId === service.id)!;
|
||||
const active = selectedCell?.userId === user.id && selectedCell.serviceId === service.id;
|
||||
const isTaskManagerService = isOperationalCoreService(service);
|
||||
const usePublicTaskerAccess = isPublicPoolContext && isTaskManagerService;
|
||||
|
||||
return (
|
||||
<div key={service.id} className="access-grid-cell" role="cell">
|
||||
|
|
@ -2496,21 +2625,24 @@ function AccessSection({
|
|||
cell={cell}
|
||||
active={active}
|
||||
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
|
||||
busy={isTaskManagerService && pendingTaskerAssignment}
|
||||
busy={!usePublicTaskerAccess && isTaskManagerService && pendingTaskerAssignment}
|
||||
publicSelfService={usePublicTaskerAccess}
|
||||
onSelectCell={onSelectCell}
|
||||
onSetAccess={(value) => {
|
||||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
|
||||
const nextValue = value;
|
||||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
|
||||
|
||||
if (usePublicTaskerAccess) return;
|
||||
if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
|
||||
|
||||
onSetTaskManagerWorkspaceMemberRole({
|
||||
clientId: matrix.client.id,
|
||||
userId: user.id,
|
||||
workspaceSlug: primaryTaskManagerWorkspace.slug,
|
||||
role: accessAssignmentToTaskManagerRole(value),
|
||||
role: accessAssignmentToTaskManagerRole(nextValue),
|
||||
});
|
||||
}}
|
||||
onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined}
|
||||
onOpenDetails={isTaskManagerService && !usePublicTaskerAccess ? () => setDetailsCell(cell) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2522,12 +2654,12 @@ function AccessSection({
|
|||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
{detailsCell ? (
|
||||
{detailsCell && detailsService ? (
|
||||
<OperationalCoreAccessModal
|
||||
data={data}
|
||||
client={matrix.client}
|
||||
user={getUser(data, detailsCell.userId)}
|
||||
service={getService(data, detailsCell.serviceId)}
|
||||
service={detailsService}
|
||||
cell={detailsCell}
|
||||
workspaces={clientTaskManagerWorkspaces}
|
||||
workspaceCatalog={taskManagerWorkspaceCatalog}
|
||||
|
|
@ -2809,6 +2941,7 @@ function AccessCellControl({
|
|||
active,
|
||||
pendingValue,
|
||||
busy = false,
|
||||
publicSelfService = false,
|
||||
onSelectCell,
|
||||
onSetAccess,
|
||||
onOpenDetails,
|
||||
|
|
@ -2817,18 +2950,33 @@ function AccessCellControl({
|
|||
active: boolean;
|
||||
pendingValue?: AccessAssignmentValue;
|
||||
busy?: boolean;
|
||||
publicSelfService?: boolean;
|
||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onSetAccess: (value: AccessAssignmentValue) => void;
|
||||
onOpenDetails?: () => void;
|
||||
}) {
|
||||
const isPending = pendingValue !== undefined || busy;
|
||||
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
|
||||
const selectValue = publicSelfService ? publicOperationalCoreSelectValue(assignmentValue) : assignmentValue;
|
||||
const selectOptions = publicSelfService ? publicOperationalCoreAccessOptions : accessAssignmentOptions;
|
||||
const displayTitle = isPending
|
||||
? publicSelfService
|
||||
? publicAccessAssignmentLabel(assignmentValue)
|
||||
: accessAssignmentLabel(assignmentValue)
|
||||
: publicSelfService
|
||||
? publicOperationalCoreCellTitle(cell)
|
||||
: accessCellTitle(cell);
|
||||
const displaySource = isPending
|
||||
? "Сохраняем..."
|
||||
: publicSelfService
|
||||
? publicOperationalCoreCellSubtitle(cell)
|
||||
: sourceLabel(cell.effectiveAccess.source);
|
||||
const cellClassName = cn(
|
||||
"access-cell",
|
||||
onOpenDetails && "access-cell--modal",
|
||||
cell.effectiveAccess.allowed && "access-cell--allowed",
|
||||
!cell.effectiveAccess.allowed && "access-cell--denied",
|
||||
cell.effectiveAccess.source === "exception" && "access-cell--exception",
|
||||
cell.effectiveAccess.source === "exception" && !publicSelfService && "access-cell--exception",
|
||||
isPending && "access-cell--pending",
|
||||
active && "access-cell--active"
|
||||
);
|
||||
|
|
@ -2845,16 +2993,16 @@ function AccessCellControl({
|
|||
onOpenDetails();
|
||||
}}
|
||||
>
|
||||
<strong>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
|
||||
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
|
||||
<strong>{displayTitle}</strong>
|
||||
<span>{displaySource}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeDcSelect
|
||||
value={assignmentValue}
|
||||
options={accessAssignmentOptions}
|
||||
value={selectValue}
|
||||
options={selectOptions}
|
||||
label={`Назначить доступ ${cell.userId} / ${cell.serviceId}`}
|
||||
minMenuWidth={172}
|
||||
menuClassName="access-cell-menu"
|
||||
|
|
@ -2872,8 +3020,8 @@ function AccessCellControl({
|
|||
toggle();
|
||||
}}
|
||||
>
|
||||
<strong>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
|
||||
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
|
||||
<strong>{displayTitle}</strong>
|
||||
<span>{displaySource}</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -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<Invite, "clientId" | "email" | "role">) => void;
|
||||
onUpdateInvite: (inviteId: string, patch: Partial<Invite>) => void;
|
||||
onDeleteInvite: (inviteId: string) => void;
|
||||
onUpdateAccessRequest: (
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
) => void;
|
||||
onApproveAccessRequest: (
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
) => void;
|
||||
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
||||
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<ClientMembershipRole>("member");
|
||||
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(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<NodeDcSelectOption<string>> = [
|
||||
{ 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 (
|
||||
<div className="invites-layout invites-layout--catalog">
|
||||
{isPublicPoolContext ? (
|
||||
<GlassSurface className="admin-tabs-card">
|
||||
<div className="admin-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
|
||||
onClick={() => setPublicInviteTab("incoming")}
|
||||
>
|
||||
Входящие · {pendingIncomingRequests}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("admin-tab-button", publicInviteTab === "outgoing" && "admin-tab-button--active")}
|
||||
onClick={() => setPublicInviteTab("outgoing")}
|
||||
>
|
||||
Исходящие · {invites.length}
|
||||
</button>
|
||||
</div>
|
||||
<p className="admin-helper-note">
|
||||
Входящие — заявки из формы “Запросить доступ”. Исходящие — инвайты, которые root-admin выпускает руками без заявки.
|
||||
</p>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{isPublicPoolContext && publicInviteTab === "incoming" ? (
|
||||
<AccessRequestsPanel
|
||||
data={data}
|
||||
clientOptions={clientOptions}
|
||||
copiedInviteId={copiedInviteId}
|
||||
onCopyInvite={handleCopyInvite}
|
||||
onUpdateAccessRequest={onUpdateAccessRequest}
|
||||
onApproveAccessRequest={onApproveAccessRequest}
|
||||
onRejectAccessRequest={onRejectAccessRequest}
|
||||
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
||||
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<GlassSurface className="invite-form invite-form--compact">
|
||||
<div className="table-toolbar">
|
||||
<div>
|
||||
|
|
@ -3064,6 +3276,386 @@ function InvitesSection({
|
|||
setDeleteInviteId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessRequestsPanel({
|
||||
data,
|
||||
clientOptions,
|
||||
copiedInviteId,
|
||||
onCopyInvite,
|
||||
onUpdateAccessRequest,
|
||||
onApproveAccessRequest,
|
||||
onRejectAccessRequest,
|
||||
onApproveTaskerInviteRequest,
|
||||
onRejectTaskerInviteRequest,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
clientOptions: Array<NodeDcSelectOption<string>>;
|
||||
copiedInviteId: string | null;
|
||||
onCopyInvite: (invite: Invite) => Promise<void>;
|
||||
onUpdateAccessRequest: (
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
) => void;
|
||||
onApproveAccessRequest: (
|
||||
accessRequestId: string,
|
||||
patch: Partial<Pick<AccessRequest, "targetClientId" | "role" | "comment">>
|
||||
) => void;
|
||||
onRejectAccessRequest: (accessRequestId: string, patch: Partial<Pick<AccessRequest, "comment">>) => void;
|
||||
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => 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 (
|
||||
<>
|
||||
<GlassSurface className="table-shell">
|
||||
<div className="table-toolbar">
|
||||
<div>
|
||||
<h3>Входящие запросы доступа</h3>
|
||||
<p className="admin-helper-note">
|
||||
Перед approve выберите целевой контур: оставить пользователя в открытом пуле или направить его в enterprise-клиента.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accessRequests.length === 0 ? (
|
||||
<div className="access-empty-state">
|
||||
<strong>Входящих заявок пока нет</strong>
|
||||
<span>Кнопка “Запросить доступ” на login будет отправлять пользователей в эту очередь.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="access-request-table-scroll">
|
||||
<table className="admin-data-table admin-data-table--access-requests">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Заявитель</th>
|
||||
<th>Контакты</th>
|
||||
<th>Компания</th>
|
||||
<th>Назначение</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Инвайт</th>
|
||||
<th aria-label="Действия" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={accessRequest.id}>
|
||||
<td>
|
||||
<div className="access-request-applicant">
|
||||
<strong>{formatAccessRequestName(accessRequest)}</strong>
|
||||
<small>{formatDateTime(accessRequest.createdAt)}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="access-request-contact">
|
||||
<span>{accessRequest.email}</span>
|
||||
<small>{accessRequest.phone}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>{accessRequest.company}</td>
|
||||
<td>
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={accessRequest.targetClientId}
|
||||
options={clientOptions}
|
||||
label={`Назначение заявки ${accessRequest.email}`}
|
||||
minMenuWidth={220}
|
||||
disabled={isTerminal}
|
||||
onChange={(targetClientId) => onUpdateAccessRequest(accessRequest.id, { targetClientId })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={accessRequest.role}
|
||||
options={inviteRoleOptions}
|
||||
label={`Роль заявки ${accessRequest.email}`}
|
||||
minMenuWidth={172}
|
||||
disabled={isTerminal}
|
||||
onChange={(role) => onUpdateAccessRequest(accessRequest.id, { role })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusPill value={accessRequest.status} options={accessRequestStatusOptions} />
|
||||
</td>
|
||||
<td>
|
||||
{approvedInvite ? (
|
||||
<div className="invite-link-cell">
|
||||
<code title={buildInviteUrl(approvedInvite.token)}>{buildInviteUrl(approvedInvite.token)}</code>
|
||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||
<IconButton
|
||||
label={`Скопировать инвайт ${approvedInvite.email}`}
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => void onCopyInvite(approvedInvite)}
|
||||
>
|
||||
<Copy size={11} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-text">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{accessRequest.status === "new" ? (
|
||||
<div className="access-request-decision-cluster">
|
||||
<button
|
||||
aria-label={`Подтвердить заявку ${accessRequest.email}`}
|
||||
className="access-request-decision-button access-request-decision-button--accept"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onApproveAccessRequest(accessRequest.id, {
|
||||
targetClientId: accessRequest.targetClientId,
|
||||
role: accessRequest.role,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Check size={16} strokeWidth={2.6} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Отклонить заявку ${accessRequest.email}`}
|
||||
className="access-request-decision-button access-request-decision-button--decline"
|
||||
type="button"
|
||||
onClick={() => onRejectAccessRequest(accessRequest.id, {})}
|
||||
>
|
||||
<X size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-text">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</GlassSurface>
|
||||
<TaskerInviteRequestsPanel
|
||||
requests={taskerInviteRequests}
|
||||
onApproveTaskerInviteRequest={onApproveTaskerInviteRequest}
|
||||
onRejectTaskerInviteRequest={onRejectTaskerInviteRequest}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskerInviteRequestsPanel({
|
||||
requests,
|
||||
onApproveTaskerInviteRequest,
|
||||
onRejectTaskerInviteRequest,
|
||||
}: {
|
||||
requests: TaskerInviteRequest[];
|
||||
onApproveTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
|
||||
}) {
|
||||
const [copiedRequestId, setCopiedRequestId] = useState<string | null>(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 (
|
||||
<GlassSurface className="table-shell">
|
||||
<div className="table-toolbar">
|
||||
<div>
|
||||
<h3>Запросы workspace-инвайтов</h3>
|
||||
<p className="admin-helper-note">
|
||||
Self-service админы Operational Core создают эти заявки из настроек workspace. После approve ссылка становится доступна
|
||||
пригласившему пользователю.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<div className="access-empty-state">
|
||||
<strong>Workspace-инвайтов пока нет</strong>
|
||||
<span>Когда self-service admin добавит участника в Operational Core, заявка появится здесь.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="access-request-table-scroll">
|
||||
<table className="admin-data-table admin-data-table--access-requests">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Workspace</th>
|
||||
<th>Приглашённый</th>
|
||||
<th>Инициатор</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Ссылка</th>
|
||||
<th aria-label="Действия" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map((request) => {
|
||||
const isCopied = copiedRequestId === request.id;
|
||||
|
||||
return (
|
||||
<tr key={request.id}>
|
||||
<td>
|
||||
<div className="access-request-applicant">
|
||||
<strong>{request.workspaceName}</strong>
|
||||
<small>{request.workspaceSlug}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="access-request-contact">
|
||||
<span>{request.inviteeEmail}</span>
|
||||
<small>{formatDateTime(request.createdAt)}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="access-request-contact">
|
||||
<span>{request.inviterName}</span>
|
||||
<small>{request.inviterEmail}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>{taskerInviteRoleLabel(request.role)}</td>
|
||||
<td>
|
||||
<AdminStatusPill value={request.status} options={taskerInviteRequestStatusOptions} />
|
||||
</td>
|
||||
<td>
|
||||
{request.platformInviteToken ? (
|
||||
<div className="invite-link-cell">
|
||||
<code title={buildInviteUrl(request.platformInviteToken)}>{buildInviteUrl(request.platformInviteToken)}</code>
|
||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||
<IconButton
|
||||
label={`Скопировать workspace-инвайт ${request.inviteeEmail}`}
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => void handleCopyTaskerInvite(request)}
|
||||
>
|
||||
<Copy size={11} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-text">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{request.status === "new" ? (
|
||||
<div className="access-request-decision-cluster">
|
||||
<button
|
||||
aria-label={`Подтвердить workspace-инвайт ${request.inviteeEmail}`}
|
||||
className="access-request-decision-button access-request-decision-button--accept"
|
||||
type="button"
|
||||
onClick={() => onApproveTaskerInviteRequest(request.id, {})}
|
||||
>
|
||||
<Check size={16} strokeWidth={2.6} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Отклонить workspace-инвайт ${request.inviteeEmail}`}
|
||||
className="access-request-decision-button access-request-decision-button--decline"
|
||||
type="button"
|
||||
onClick={() => onRejectTaskerInviteRequest(request.id, {})}
|
||||
>
|
||||
<X size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted-text">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="membership-inviter-cell">
|
||||
<span>{inviterMeta.title}</span>
|
||||
<small>{inviterMeta.subtitle}</small>
|
||||
<small>{inviterMeta.sourceLabel}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3324,6 +3916,16 @@ function roleLabel(role: string): string {
|
|||
return labels[role] ?? role;
|
||||
}
|
||||
|
||||
function taskerInviteRoleLabel(role: TaskerInviteRequest["role"]): string {
|
||||
const labels: Record<TaskerInviteRequest["role"], string> = {
|
||||
guest: "Guest",
|
||||
member: "Member",
|
||||
admin: "Admin",
|
||||
};
|
||||
|
||||
return labels[role] ?? role;
|
||||
}
|
||||
|
||||
function sectionTitle(section: AdminSection): string {
|
||||
const labels: Record<AdminSection, string> = {
|
||||
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue