Compare commits
5 Commits
168010f05e
...
784b3ca5c3
| Author | SHA1 | Date |
|---|---|---|
|
|
784b3ca5c3 | |
|
|
5f461d57ea | |
|
|
d4eba0ff3a | |
|
|
897c7145f0 | |
|
|
6b002ec176 |
|
|
@ -14,7 +14,14 @@
|
||||||
"contactEmail": "dcctouch@gmail.com",
|
"contactEmail": "dcctouch@gmail.com",
|
||||||
"notes": "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.",
|
"notes": "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.",
|
||||||
"createdAt": "2026-05-04T00:00:00.000Z",
|
"createdAt": "2026-05-04T00:00:00.000Z",
|
||||||
"updatedAt": "2026-05-04T12:55:13.842Z"
|
"updatedAt": "2026-05-06T08:44:44.882Z",
|
||||||
|
"integrations": {
|
||||||
|
"taskManager": {
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inn": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"users": [
|
"users": [
|
||||||
|
|
@ -42,7 +49,7 @@
|
||||||
"avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg",
|
"avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg",
|
||||||
"globalStatus": "active",
|
"globalStatus": "active",
|
||||||
"createdAt": "2026-05-04T00:00:00.000Z",
|
"createdAt": "2026-05-04T00:00:00.000Z",
|
||||||
"updatedAt": "2026-05-04T15:26:08.500Z"
|
"updatedAt": "2026-05-07T11:04:47.398Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "user_constr_dc_yahoo_com",
|
"id": "user_constr_dc_yahoo_com",
|
||||||
|
|
@ -55,7 +62,7 @@
|
||||||
"avatarUrl": "/storage/uploads/1777992885416-502c0a5d-94-944112_unicorn-clipart-mystical-unicorn-web-server.png",
|
"avatarUrl": "/storage/uploads/1777992885416-502c0a5d-94-944112_unicorn-clipart-mystical-unicorn-web-server.png",
|
||||||
"globalStatus": "active",
|
"globalStatus": "active",
|
||||||
"createdAt": "2026-05-05T14:53:26.607Z",
|
"createdAt": "2026-05-05T14:53:26.607Z",
|
||||||
"updatedAt": "2026-05-05T14:57:13.515Z"
|
"updatedAt": "2026-05-07T09:41:41.158Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "user_support_dctouch_ru",
|
"id": "user_support_dctouch_ru",
|
||||||
|
|
@ -81,7 +88,7 @@
|
||||||
"avatarUrl": null,
|
"avatarUrl": null,
|
||||||
"globalStatus": "active",
|
"globalStatus": "active",
|
||||||
"createdAt": "2026-05-05T17:26:50.184Z",
|
"createdAt": "2026-05-05T17:26:50.184Z",
|
||||||
"updatedAt": "2026-05-05T17:27:40.754Z"
|
"updatedAt": "2026-05-07T11:39:41.562Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "user_abramov_dcconstructions_ru",
|
"id": "user_abramov_dcconstructions_ru",
|
||||||
|
|
@ -124,19 +131,19 @@
|
||||||
"id": "mem_silver_psih_dctouch",
|
"id": "mem_silver_psih_dctouch",
|
||||||
"clientId": "client_romashka",
|
"clientId": "client_romashka",
|
||||||
"userId": "user_silver_psih",
|
"userId": "user_silver_psih",
|
||||||
"role": "member",
|
"role": "client_admin",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"createdAt": "2026-05-04T00:00:00.000Z",
|
"createdAt": "2026-05-04T00:00:00.000Z",
|
||||||
"updatedAt": "2026-05-04T12:55:13.842Z"
|
"updatedAt": "2026-05-07T11:04:47.173Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mem_client_romashka_constr_dc_yahoo_com",
|
"id": "mem_client_romashka_constr_dc_yahoo_com",
|
||||||
"clientId": "client_romashka",
|
"clientId": "client_romashka",
|
||||||
"userId": "user_constr_dc_yahoo_com",
|
"userId": "user_constr_dc_yahoo_com",
|
||||||
"role": "member",
|
"role": "client_admin",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"createdAt": "2026-05-05T14:53:26.607Z",
|
"createdAt": "2026-05-05T14:53:26.607Z",
|
||||||
"updatedAt": "2026-05-05T14:53:26.607Z"
|
"updatedAt": "2026-05-07T09:41:39.935Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mem_client_romashka_support_dctouch_ru",
|
"id": "mem_client_romashka_support_dctouch_ru",
|
||||||
|
|
@ -151,10 +158,10 @@
|
||||||
"id": "mem_client_romashka_silverpsih007_gmail_com",
|
"id": "mem_client_romashka_silverpsih007_gmail_com",
|
||||||
"clientId": "client_romashka",
|
"clientId": "client_romashka",
|
||||||
"userId": "user_silverpsih007_gmail_com",
|
"userId": "user_silverpsih007_gmail_com",
|
||||||
"role": "member",
|
"role": "client_admin",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"createdAt": "2026-05-05T17:26:50.184Z",
|
"createdAt": "2026-05-05T17:26:50.184Z",
|
||||||
"updatedAt": "2026-05-05T17:26:50.184Z"
|
"updatedAt": "2026-05-07T11:39:39.407Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mem_client_romashka_abramov_dcconstructions_ru",
|
"id": "mem_client_romashka_abramov_dcconstructions_ru",
|
||||||
|
|
@ -491,10 +498,10 @@
|
||||||
"objectName": "DCTOUCH",
|
"objectName": "DCTOUCH",
|
||||||
"objectType": "client",
|
"objectType": "client",
|
||||||
"target": "authentik",
|
"target": "authentik",
|
||||||
"state": "synced",
|
"state": "pending",
|
||||||
"lastSyncAt": "2026-05-04T12:55:13.842Z",
|
"lastSyncAt": "2026-05-04T12:55:13.842Z",
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-04T12:55:13.842Z"
|
"updatedAt": "2026-05-06T08:44:44.887Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sync_dc_touch_authentik",
|
"id": "sync_dc_touch_authentik",
|
||||||
|
|
@ -514,9 +521,9 @@
|
||||||
"objectType": "user",
|
"objectType": "user",
|
||||||
"target": "authentik",
|
"target": "authentik",
|
||||||
"state": "synced",
|
"state": "synced",
|
||||||
"lastSyncAt": "2026-05-04T15:26:08.500Z",
|
"lastSyncAt": "2026-05-07T11:04:47.398Z",
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-04T15:26:08.500Z"
|
"updatedAt": "2026-05-07T11:04:47.398Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sync_dctouch_groups_authentik",
|
"id": "sync_dctouch_groups_authentik",
|
||||||
|
|
@ -580,9 +587,9 @@
|
||||||
"objectType": "user",
|
"objectType": "user",
|
||||||
"target": "authentik",
|
"target": "authentik",
|
||||||
"state": "synced",
|
"state": "synced",
|
||||||
"lastSyncAt": "2026-05-05T14:57:13.515Z",
|
"lastSyncAt": "2026-05-07T09:41:41.158Z",
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-05T14:57:13.515Z"
|
"updatedAt": "2026-05-07T09:41:41.158Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sync_grant_service_task_manager_user_constr_dc_yahoo_com",
|
"id": "sync_grant_service_task_manager_user_constr_dc_yahoo_com",
|
||||||
|
|
@ -646,9 +653,9 @@
|
||||||
"objectType": "user",
|
"objectType": "user",
|
||||||
"target": "authentik",
|
"target": "authentik",
|
||||||
"state": "synced",
|
"state": "synced",
|
||||||
"lastSyncAt": "2026-05-05T17:27:40.754Z",
|
"lastSyncAt": "2026-05-07T11:39:41.562Z",
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-05T17:27:40.754Z"
|
"updatedAt": "2026-05-07T11:39:41.562Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sync_grant_service_task_manager_user_silverpsih007_gmail_com",
|
"id": "sync_grant_service_task_manager_user_silverpsih007_gmail_com",
|
||||||
|
|
@ -1952,11 +1959,501 @@
|
||||||
"clientId": null,
|
"clientId": null,
|
||||||
"result": "success",
|
"result": "success",
|
||||||
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_brand_settings_2",
|
||||||
|
"at": "2026-05-06T06:52:46.025Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлены системные настройки",
|
||||||
|
"objectType": "settings",
|
||||||
|
"objectName": "Brand settings",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Logo link: http://launcher.local.nodedc/; Tasker workspace policy: task_admins_only"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dctouch",
|
||||||
|
"at": "2026-05-06T08:44:44.887Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлён клиент",
|
||||||
|
"objectType": "client",
|
||||||
|
"objectName": "DCTOUCH",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constr",
|
||||||
|
"at": "2026-05-06T09:02:42.183Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTR",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: 15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constr_2",
|
||||||
|
"at": "2026-05-06T09:02:45.197Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTR",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: 15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constr_3",
|
||||||
|
"at": "2026-05-06T09:02:57.971Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTR",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: 15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constr_4",
|
||||||
|
"at": "2026-05-06T09:02:59.293Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTR",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: 15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constr_5",
|
||||||
|
"at": "2026-05-06T09:09:09.389Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTR",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: 15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_silver",
|
||||||
|
"at": "2026-05-06T09:46:34.612Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SILVER",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constr_6",
|
||||||
|
"at": "2026-05-06T09:46:41.427Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTR",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_sudo",
|
||||||
|
"at": "2026-05-06T10:17:58.710Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SUDO",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_sudo_2",
|
||||||
|
"at": "2026-05-06T10:21:44.717Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Снят Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SUDO",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: nodedc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_support",
|
||||||
|
"at": "2026-05-06T10:38:27.410Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SUPPORT",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_silver_2",
|
||||||
|
"at": "2026-05-06T10:51:20.914Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SILVER",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_sudo_3",
|
||||||
|
"at": "2026-05-06T10:54:33.543Z",
|
||||||
|
"actorUserId": "system",
|
||||||
|
"actorName": "NODE.DC System",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SUDO",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_silver007",
|
||||||
|
"at": "2026-05-06T11:20:45.826Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SILVER007",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_abramov",
|
||||||
|
"at": "2026-05-06T11:20:47.255Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC ABRAMOV",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constrictions",
|
||||||
|
"at": "2026-05-06T11:20:48.841Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTRICTIONS",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_constr_dc_yahoo_com_11",
|
||||||
|
"at": "2026-05-07T09:41:39.942Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлено членство",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "constr_dc@yahoo.com",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Role: client_admin; status: active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_constr_dc_yahoo_com_12",
|
||||||
|
"at": "2026-05-07T09:41:41.158Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "constr_dc@yahoo.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_constr_7",
|
||||||
|
"at": "2026-05-07T09:41:45.137Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC CONSTR",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silver_psih_yahoo_com_43",
|
||||||
|
"at": "2026-05-07T11:04:26.162Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Обновлено членство",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silver_psih@yahoo.com",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Role: client_admin; status: active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silver_psih_yahoo_com_44",
|
||||||
|
"at": "2026-05-07T11:04:26.890Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silver_psih@yahoo.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_silver_3",
|
||||||
|
"at": "2026-05-07T11:04:32.210Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SILVER",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_silver_4",
|
||||||
|
"at": "2026-05-07T11:04:34.414Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SILVER",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_silver_5",
|
||||||
|
"at": "2026-05-07T11:04:40.683Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SILVER",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_dc_silver_6",
|
||||||
|
"at": "2026-05-07T11:04:42.616Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Назначен Tasker workspace",
|
||||||
|
"objectType": "task-manager-membership",
|
||||||
|
"objectName": "DC SILVER",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Workspace: NODE DC; Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silver_psih_yahoo_com_45",
|
||||||
|
"at": "2026-05-07T11:04:45.081Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Обновлено членство",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silver_psih@yahoo.com",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Role: client_admin; status: disabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silver_psih_yahoo_com_46",
|
||||||
|
"at": "2026-05-07T11:04:45.343Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silver_psih@yahoo.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silver_psih_yahoo_com_47",
|
||||||
|
"at": "2026-05-07T11:04:47.173Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Обновлено членство",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silver_psih@yahoo.com",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Role: client_admin; status: active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silver_psih_yahoo_com_48",
|
||||||
|
"at": "2026-05-07T11:04:47.399Z",
|
||||||
|
"actorUserId": "user_constr_dc_yahoo_com",
|
||||||
|
"actorName": "DC CONSTR",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silver_psih@yahoo.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silverpsih007_gmail_com_6",
|
||||||
|
"at": "2026-05-07T11:39:37.305Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлено членство",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silverpsih007@gmail.com",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Role: client_admin; status: active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silverpsih007_gmail_com_7",
|
||||||
|
"at": "2026-05-07T11:39:39.407Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлено членство",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silverpsih007@gmail.com",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Role: client_admin; status: active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silverpsih007_gmail_com_8",
|
||||||
|
"at": "2026-05-07T11:39:40.076Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silverpsih007@gmail.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_silverpsih007_gmail_com_9",
|
||||||
|
"at": "2026-05-07T11:39:41.562Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "silverpsih007@gmail.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"brand": {
|
"brand": {
|
||||||
"logoLinkUrl": "http://launcher.local.nodedc/"
|
"logoLinkUrl": "http://launcher.local.nodedc/"
|
||||||
|
},
|
||||||
|
"taskManager": {
|
||||||
|
"workspaceCreationPolicy": "task_admins_only"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"taskManagerMemberships": [
|
||||||
|
{
|
||||||
|
"id": "tasker_mem_client_romashka_user_silver_psih_nodedc",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_silver_psih",
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC",
|
||||||
|
"role": "member",
|
||||||
|
"planeUserId": "7315d59a-50e1-4d26-8de8-ae632777b46e",
|
||||||
|
"planeRole": 15,
|
||||||
|
"updatedAt": "2026-05-07T11:04:42.615Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tasker_mem_client_romashka_user_constr_dc_yahoo_com_nodedc",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_constr_dc_yahoo_com",
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC",
|
||||||
|
"role": "admin",
|
||||||
|
"planeUserId": "62aa744d-32ef-427f-8ad2-184d2ec2f5e5",
|
||||||
|
"planeRole": 20,
|
||||||
|
"updatedAt": "2026-05-07T09:41:45.137Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tasker_mem_client_romashka_user_support_dctouch_ru_nodedc",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_support_dctouch_ru",
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC",
|
||||||
|
"role": "member",
|
||||||
|
"planeUserId": "53e56870-8772-4dfd-9c8c-2eeacfb53caa",
|
||||||
|
"planeRole": 15,
|
||||||
|
"updatedAt": "2026-05-06T10:38:27.409Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tasker_mem_client_romashka_user_root_nodedc",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_root",
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC",
|
||||||
|
"role": "admin",
|
||||||
|
"planeUserId": "844d7f18-285d-4671-8371-8ca9ca5ffa39",
|
||||||
|
"planeRole": 20,
|
||||||
|
"updatedAt": "2026-05-06T10:54:33.542Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tasker_mem_client_romashka_user_silverpsih007_gmail_com_nodedc",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_silverpsih007_gmail_com",
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC",
|
||||||
|
"role": "member",
|
||||||
|
"planeUserId": "52817493-1ff4-44f9-aae4-463ecd512d51",
|
||||||
|
"planeRole": 15,
|
||||||
|
"updatedAt": "2026-05-06T11:20:45.826Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tasker_mem_client_romashka_user_abramov_dcconstructions_ru_nodedc",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_abramov_dcconstructions_ru",
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC",
|
||||||
|
"role": "member",
|
||||||
|
"planeUserId": "d28a2d28-da56-4625-a211-d9bb3d06b0d3",
|
||||||
|
"planeRole": 15,
|
||||||
|
"updatedAt": "2026-05-06T11:20:47.255Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tasker_mem_client_romashka_user_support_dcconstructions_ru_nodedc",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_support_dcconstructions_ru",
|
||||||
|
"workspaceSlug": "nodedc",
|
||||||
|
"workspaceName": "NODE DC",
|
||||||
|
"role": "member",
|
||||||
|
"planeUserId": "1cc7ae3a-1f42-41ac-8cc2-1ff0fce59554",
|
||||||
|
"planeRole": 15,
|
||||||
|
"updatedAt": "2026-05-06T11:20:48.841Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const collectionKeys = [
|
||||||
"invites",
|
"invites",
|
||||||
"syncStatuses",
|
"syncStatuses",
|
||||||
"auditEvents",
|
"auditEvents",
|
||||||
|
"taskManagerMemberships",
|
||||||
];
|
];
|
||||||
|
|
||||||
const clientTypes = new Set(["company", "person"]);
|
const clientTypes = new Set(["company", "person"]);
|
||||||
|
|
@ -29,6 +30,9 @@ const defaultSettings = {
|
||||||
brand: {
|
brand: {
|
||||||
logoLinkUrl: "/",
|
logoLinkUrl: "/",
|
||||||
},
|
},
|
||||||
|
taskManager: {
|
||||||
|
workspaceCreationPolicy: "any_authorized_user",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createControlPlaneStore({ projectRoot }) {
|
export function createControlPlaneStore({ projectRoot }) {
|
||||||
|
|
@ -100,6 +104,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
demoEndsAt: nullableString(payload?.demoEndsAt),
|
demoEndsAt: nullableString(payload?.demoEndsAt),
|
||||||
contactName: nullableString(payload?.contactName),
|
contactName: nullableString(payload?.contactName),
|
||||||
contactEmail: nullableString(payload?.contactEmail),
|
contactEmail: nullableString(payload?.contactEmail),
|
||||||
|
integrations: normalizeClientIntegrations(payload?.integrations),
|
||||||
notes: nullableString(payload?.notes),
|
notes: nullableString(payload?.notes),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
@ -135,6 +140,9 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null);
|
client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null);
|
||||||
client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null);
|
client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null);
|
||||||
client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null);
|
client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null);
|
||||||
|
if ("integrations" in (payload ?? {})) {
|
||||||
|
client.integrations = normalizeClientIntegrations(payload.integrations, client.integrations);
|
||||||
|
}
|
||||||
client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null);
|
client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null);
|
||||||
client.updatedAt = isoNow();
|
client.updatedAt = isoNow();
|
||||||
|
|
||||||
|
|
@ -151,6 +159,62 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
return { client, data };
|
return { client, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recordTaskManagerWorkspaceMembership(payload, identity) {
|
||||||
|
const data = readData();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const client = findById(data.clients, payload?.clientId, "client");
|
||||||
|
const user = findById(data.users, payload?.userId, "user");
|
||||||
|
const taskManager = typeof payload?.taskManager === "object" && payload.taskManager !== null ? payload.taskManager : {};
|
||||||
|
const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {};
|
||||||
|
const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {};
|
||||||
|
|
||||||
|
upsertTaskManagerMembership(data, {
|
||||||
|
clientId: client.id,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
|
||||||
|
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
|
||||||
|
role: normalizeTaskManagerMembershipRole(payload?.role),
|
||||||
|
planeUserId: membership.member?.id ?? null,
|
||||||
|
planeRole: typeof membership.role === "number" ? membership.role : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: "Назначен Tasker workspace",
|
||||||
|
objectType: "task-manager-membership",
|
||||||
|
objectName: user.name,
|
||||||
|
clientId: client.id,
|
||||||
|
result: "success",
|
||||||
|
details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${payload?.role ?? "member"}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTaskManagerWorkspaceMembership(payload, identity) {
|
||||||
|
const data = readData();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const client = findById(data.clients, payload?.clientId, "client");
|
||||||
|
const user = findById(data.users, payload?.userId, "user");
|
||||||
|
const workspaceSlug = requireString(payload?.workspaceSlug ?? client.integrations?.taskManager?.workspaceSlug, "workspaceSlug");
|
||||||
|
|
||||||
|
data.taskManagerMemberships = data.taskManagerMemberships.filter(
|
||||||
|
(membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug)
|
||||||
|
);
|
||||||
|
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: "Снят Tasker workspace",
|
||||||
|
objectType: "task-manager-membership",
|
||||||
|
objectName: user.name,
|
||||||
|
clientId: client.id,
|
||||||
|
result: "success",
|
||||||
|
details: `Workspace: ${workspaceSlug}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteClient(clientId, identity) {
|
async function deleteClient(clientId, identity) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
|
|
@ -195,6 +259,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
...(data.settings?.brand ?? {}),
|
...(data.settings?.brand ?? {}),
|
||||||
...(patch.brand ?? {}),
|
...(patch.brand ?? {}),
|
||||||
},
|
},
|
||||||
|
taskManager: {
|
||||||
|
...(data.settings?.taskManager ?? {}),
|
||||||
|
...(patch.taskManager ?? {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
data.settings = settings;
|
data.settings = settings;
|
||||||
|
|
@ -203,7 +271,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
objectType: "settings",
|
objectType: "settings",
|
||||||
objectName: "Brand settings",
|
objectName: "Brand settings",
|
||||||
result: "success",
|
result: "success",
|
||||||
details: `Logo link: ${settings.brand.logoLinkUrl}`,
|
details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeData(data);
|
await writeData(data);
|
||||||
|
|
@ -1021,6 +1089,8 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
reorderServices,
|
reorderServices,
|
||||||
retrySync,
|
retrySync,
|
||||||
markUserAuthentikProvisioned,
|
markUserAuthentikProvisioned,
|
||||||
|
recordTaskManagerWorkspaceMembership,
|
||||||
|
removeTaskManagerWorkspaceMembership,
|
||||||
setUserServiceAccess,
|
setUserServiceAccess,
|
||||||
updateClient,
|
updateClient,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
|
|
@ -1045,20 +1115,77 @@ function normalizeData(payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
data.settings = normalizeSettings(data.settings);
|
data.settings = normalizeSettings(data.settings);
|
||||||
|
data.clients = data.clients.map((client) => ({
|
||||||
|
...client,
|
||||||
|
integrations: normalizeClientIntegrations(client.integrations),
|
||||||
|
}));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSettings(payload) {
|
function normalizeSettings(payload) {
|
||||||
const settings = typeof payload === "object" && payload !== null ? payload : {};
|
const settings = typeof payload === "object" && payload !== null ? payload : {};
|
||||||
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
|
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
|
||||||
|
const taskManager = typeof settings.taskManager === "object" && settings.taskManager !== null ? settings.taskManager : {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand: {
|
brand: {
|
||||||
logoLinkUrl: optionalString(brand.logoLinkUrl, defaultSettings.brand.logoLinkUrl),
|
logoLinkUrl: optionalString(brand.logoLinkUrl, defaultSettings.brand.logoLinkUrl),
|
||||||
},
|
},
|
||||||
|
taskManager: {
|
||||||
|
workspaceCreationPolicy: pickEnum(
|
||||||
|
taskManager.workspaceCreationPolicy,
|
||||||
|
new Set(["any_authorized_user", "task_admins_only", "disabled"]),
|
||||||
|
defaultSettings.taskManager.workspaceCreationPolicy
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeClientIntegrations(payload, fallback = {}) {
|
||||||
|
const integrations = typeof payload === "object" && payload !== null ? payload : {};
|
||||||
|
const fallbackIntegrations = typeof fallback === "object" && fallback !== null ? fallback : {};
|
||||||
|
const taskManager = typeof integrations.taskManager === "object" && integrations.taskManager !== null ? integrations.taskManager : {};
|
||||||
|
const fallbackTaskManager =
|
||||||
|
typeof fallbackIntegrations.taskManager === "object" && fallbackIntegrations.taskManager !== null ? fallbackIntegrations.taskManager : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskManager: {
|
||||||
|
workspaceSlug: nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null),
|
||||||
|
workspaceName: nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskManagerMembershipRole(value) {
|
||||||
|
return value === "guest" || value === "admin" || value === "member" ? value : "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertTaskManagerMembership(data, payload) {
|
||||||
|
const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug");
|
||||||
|
const existingMembership = data.taskManagerMemberships.find(
|
||||||
|
(membership) => membership.clientId === payload.clientId && membership.userId === payload.userId && membership.workspaceSlug === workspaceSlug
|
||||||
|
);
|
||||||
|
const nextMembership = {
|
||||||
|
id: existingMembership?.id ?? uniqueId(data.taskManagerMemberships, "tasker_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}`),
|
||||||
|
clientId: payload.clientId,
|
||||||
|
userId: payload.userId,
|
||||||
|
workspaceSlug,
|
||||||
|
workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null),
|
||||||
|
role: normalizeTaskManagerMembershipRole(payload.role),
|
||||||
|
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
|
||||||
|
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
|
||||||
|
updatedAt: isoNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingMembership) {
|
||||||
|
Object.assign(existingMembership, nextMembership);
|
||||||
|
return existingMembership;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.taskManagerMemberships.push(nextMembership);
|
||||||
|
return nextMembership;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveActor(data, identity) {
|
function resolveActor(data, identity) {
|
||||||
const user = data.users.find(
|
const user = data.users.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ app.post("/api/internal/handoff/consume", (req, res) => {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: resolveUserAvatarPublicUrl(user),
|
||||||
subject: user.authentikUserId || handoff.user.sub,
|
subject: user.authentikUserId || handoff.user.sub,
|
||||||
authentikUserId: user.authentikUserId ?? null,
|
authentikUserId: user.authentikUserId ?? null,
|
||||||
groups,
|
groups,
|
||||||
|
|
@ -373,6 +373,8 @@ app.post("/api/internal/access/check", (req, res) => {
|
||||||
const groups = resolveRequiredGroups(snapshot.data, user);
|
const groups = resolveRequiredGroups(snapshot.data, user);
|
||||||
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
|
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
|
||||||
const allowed = Boolean(app?.hasAccess);
|
const allowed = Boolean(app?.hasAccess);
|
||||||
|
const workspacePolicy =
|
||||||
|
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed) : null;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -381,6 +383,7 @@ app.post("/api/internal/access/check", (req, res) => {
|
||||||
serviceSlug,
|
serviceSlug,
|
||||||
groups,
|
groups,
|
||||||
matchedGroups: app?.matchedGroups ?? [],
|
matchedGroups: app?.matchedGroups ?? [],
|
||||||
|
workspacePolicy,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|
@ -503,41 +506,192 @@ app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, re
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
|
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
|
||||||
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
|
res.json(scopeAdminSnapshot(req));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/api/admin/settings", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/settings", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.settings.updated");
|
publishControlPlaneEvent("admin.settings.updated");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
|
app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
|
||||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
const snapshot = scopeAdminSnapshot(req);
|
||||||
res.json({ clients: snapshot.data.clients });
|
res.json({ clients: snapshot.data.clients });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!req.nodedcAdminScope?.isRoot) {
|
||||||
|
res.json({ ok: true, workspaces: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/");
|
||||||
|
res.json(taskManager);
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : "";
|
||||||
|
const userId = typeof req.body?.userId === "string" ? req.body.userId : "";
|
||||||
|
const client = snapshot.data.clients.find((candidate) => candidate.id === clientId);
|
||||||
|
const user = snapshot.data.users.find((candidate) => candidate.id === userId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
res.status(404).json({ ok: false, error: "client_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ ok: false, error: "user_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
|
||||||
|
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null;
|
||||||
|
|
||||||
|
if (!workspaceSlug) {
|
||||||
|
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = normalizeTaskManagerRole(req.body?.role) ?? resolveTaskManagerRoleForMembership(membership?.role);
|
||||||
|
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
workspaceSlug,
|
||||||
|
email: user.email,
|
||||||
|
subject: user.authentikUserId ?? undefined,
|
||||||
|
avatarUrl: resolveUserAvatarPublicUrl(user),
|
||||||
|
role,
|
||||||
|
companyRole: membership?.role ?? null,
|
||||||
|
setLastWorkspace: req.body?.setLastWorkspace !== false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
|
||||||
|
{
|
||||||
|
clientId: client.id,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceSlug,
|
||||||
|
role,
|
||||||
|
taskManager,
|
||||||
|
},
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
|
||||||
|
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : "";
|
||||||
|
const userId = typeof req.body?.userId === "string" ? req.body.userId : "";
|
||||||
|
const client = snapshot.data.clients.find((candidate) => candidate.id === clientId);
|
||||||
|
const user = snapshot.data.users.find((candidate) => candidate.id === userId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
res.status(404).json({ ok: false, error: "client_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ ok: false, error: "user_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
|
||||||
|
const workspaceSlug = client.integrations?.taskManager?.workspaceSlug ?? null;
|
||||||
|
|
||||||
|
if (!workspaceSlug) {
|
||||||
|
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership?.role === "client_owner") {
|
||||||
|
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
workspaceSlug,
|
||||||
|
email: user.email,
|
||||||
|
subject: user.authentikUserId ?? undefined,
|
||||||
|
avatarUrl: resolveUserAvatarPublicUrl(user),
|
||||||
|
role: "admin",
|
||||||
|
companyRole: membership.role,
|
||||||
|
setLastWorkspace: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
|
||||||
|
{
|
||||||
|
clientId: client.id,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceSlug,
|
||||||
|
role: "admin",
|
||||||
|
taskManager,
|
||||||
|
},
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
|
||||||
|
res.json({ ...scopeAdminMutationResult(req, result), taskManager, protected: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/remove/", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
workspaceSlug,
|
||||||
|
email: user.email,
|
||||||
|
subject: user.authentikUserId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await controlPlaneStore.removeTaskManagerWorkspaceMembership(
|
||||||
|
{
|
||||||
|
clientId: client.id,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceSlug,
|
||||||
|
},
|
||||||
|
req.nodedcSession.user
|
||||||
|
);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
|
||||||
|
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/admin/clients", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user);
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.updateClient(req.params.clientId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateClient(req.params.clientId, req.body, req.nodedcSession.user);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.deleteClient(req.params.clientId, req.nodedcSession.user);
|
const result = await controlPlaneStore.deleteClient(req.params.clientId, req.nodedcSession.user);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/admin/users", requireLauncherAdmin, (req, res) => {
|
app.get("/api/admin/users", requireLauncherAdmin, (req, res) => {
|
||||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
const snapshot = scopeAdminSnapshot(req);
|
||||||
res.json({ users: snapshot.data.users, memberships: snapshot.data.memberships });
|
res.json({ users: snapshot.data.users, memberships: snapshot.data.memberships });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.createUser(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.createUser(req.body, req.nodedcSession.user);
|
||||||
let provisioning = null;
|
let provisioning = null;
|
||||||
|
|
||||||
|
|
@ -554,17 +708,25 @@ app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) =
|
||||||
}
|
}
|
||||||
|
|
||||||
publishControlPlaneEvent("admin.user.created", [result.user.id]);
|
publishControlPlaneEvent("admin.user.created", [result.user.id]);
|
||||||
res.status(201).json({ ...result, provisioning });
|
res.status(201).json({ ...scopeAdminMutationResult(req, result), provisioning });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
|
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
const provisionedUser = await authentikSyncClient.provisionUser({
|
const provisionedUser = await authentikSyncClient.provisionUser({
|
||||||
data: snapshot.data,
|
data: snapshot.data,
|
||||||
|
|
@ -575,51 +737,118 @@ app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, a
|
||||||
const result = await controlPlaneStore.markUserAuthentikProvisioned(req.params.userId, provisionedUser, req.nodedcSession.user);
|
const result = await controlPlaneStore.markUserAuthentikProvisioned(req.params.userId, provisionedUser, req.nodedcSession.user);
|
||||||
|
|
||||||
publishControlPlaneEvent("admin.user.provisioned", [req.params.userId]);
|
publishControlPlaneEvent("admin.user.provisioned", [req.params.userId]);
|
||||||
res.json({ ...result, provisioning: toProvisioningResponse(provisionedUser) });
|
res.json({ ...scopeAdminMutationResult(req, result), provisioning: toProvisioningResponse(provisionedUser) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
res.status(404).json({ error: "membership_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageMembership(req, res, membership)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.membership.updated", syncResult.userIds);
|
publishControlPlaneEvent("admin.membership.updated", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
res.status(404).json({ error: "membership_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageMembership(req, res, membership)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.deleteMembership(req.params.membershipId, req.nodedcSession.user);
|
const result = await controlPlaneStore.deleteMembership(req.params.membershipId, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.membership.deleted", syncResult.userIds);
|
publishControlPlaneEvent("admin.membership.deleted", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/invites", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/invites", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.createInvite(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.createInvite(req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.invite.created");
|
publishControlPlaneEvent("admin.invite.created");
|
||||||
res.status(201).json(result);
|
res.status(201).json(scopeAdminMutationResult(req, result));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId);
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
res.status(404).json({ error: "invite_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageClient(req, res, invite.clientId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.updateInvite(req.params.inviteId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateInvite(req.params.inviteId, req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.invite.updated");
|
publishControlPlaneEvent("admin.invite.updated");
|
||||||
res.json(result);
|
res.json(scopeAdminMutationResult(req, result));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId);
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
res.status(404).json({ error: "invite_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageClient(req, res, invite.clientId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.deleteInvite(req.params.inviteId, req.nodedcSession.user);
|
const result = await controlPlaneStore.deleteInvite(req.params.inviteId, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.invite.deleted");
|
publishControlPlaneEvent("admin.invite.deleted");
|
||||||
res.json(result);
|
res.json(scopeAdminMutationResult(req, result));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!assertAdminCanManageClient(req, res, req.body?.clientId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.createGroup(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.createGroup(req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.group.created", syncResult.userIds);
|
publishControlPlaneEvent("admin.group.created", syncResult.userIds);
|
||||||
res.status(201).json({ ...result, data: syncResult.data });
|
res.status(201).json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
const previousMemberIds = beforeSnapshot.data.groups.find((group) => group.id === req.params.groupId)?.memberIds ?? [];
|
const group = beforeSnapshot.data.groups.find((candidate) => candidate.id === req.params.groupId);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
res.status(404).json({ error: "group_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageClient(req, res, group.clientId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMemberIds = group.memberIds;
|
||||||
const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(
|
const syncResult = await syncUsersToAuthentik(
|
||||||
result.data,
|
result.data,
|
||||||
|
|
@ -627,41 +856,59 @@ app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (
|
||||||
req.nodedcSession.user
|
req.nodedcSession.user
|
||||||
);
|
);
|
||||||
publishControlPlaneEvent("admin.group.updated", syncResult.userIds);
|
publishControlPlaneEvent("admin.group.updated", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const group = snapshot.data.groups.find((candidate) => candidate.id === req.params.groupId);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
res.status(404).json({ error: "group_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assertAdminCanManageClient(req, res, group.clientId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.deleteGroup(req.params.groupId, req.nodedcSession.user);
|
const result = await controlPlaneStore.deleteGroup(req.params.groupId, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.group.deleted", syncResult.userIds);
|
publishControlPlaneEvent("admin.group.deleted", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/services", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/services", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.createService(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.createService(req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.service.created");
|
publishControlPlaneEvent("admin.service.created");
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/admin/services/reorder", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/services/reorder", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.reorderServices(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.reorderServices(req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.service.reordered");
|
publishControlPlaneEvent("admin.service.reordered");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.updateService(req.params.serviceId, req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateService(req.params.serviceId, req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.service.updated");
|
publishControlPlaneEvent("admin.service.updated");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.deleteService(req.params.serviceId, req.nodedcSession.user);
|
const result = await controlPlaneStore.deleteService(req.params.serviceId, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.service.deleted");
|
publishControlPlaneEvent("admin.service.deleted");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
|
||||||
|
if (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(
|
const syncResult = await syncUsersToAuthentik(
|
||||||
result.data,
|
result.data,
|
||||||
|
|
@ -669,30 +916,38 @@ app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req
|
||||||
req.nodedcSession.user
|
req.nodedcSession.user
|
||||||
);
|
);
|
||||||
publishControlPlaneEvent("admin.access.grant.updated", syncResult.userIds);
|
publishControlPlaneEvent("admin.access.grant.updated", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!assertAdminCanManageUser(req, res, req.body?.userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.upsertException(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.upsertException(req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, [result.exception.userId], req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, [result.exception.userId], req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.access.exception.updated", syncResult.userIds);
|
publishControlPlaneEvent("admin.access.exception.updated", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
if (!assertAdminCanManageUser(req, res, req.body?.userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
|
||||||
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
|
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
|
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
|
||||||
res.json({ ...result, data: syncResult.data });
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
|
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.sync.retry");
|
publishControlPlaneEvent("admin.sync.retry");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, (_req, res) => {
|
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => {
|
||||||
res.json(controlPlaneStore.buildAuthentikSyncPlan());
|
res.json(controlPlaneStore.buildAuthentikSyncPlan());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -701,7 +956,7 @@ app.post("/api/storage/upload", asyncRoute(async (req, res) => {
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/storage/data", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
await saveLauncherData(req.body);
|
await saveLauncherData(req.body);
|
||||||
publishControlPlaneEvent("storage.data.updated");
|
publishControlPlaneEvent("storage.data.updated");
|
||||||
res.json({ ok: true, url: "/storage/launcher-data.json" });
|
res.json({ ok: true, url: "/storage/launcher-data.json" });
|
||||||
|
|
@ -1187,6 +1442,54 @@ function getTaskBaseUrl() {
|
||||||
return taskBaseUrl.replace(/\/$/, "");
|
return taskBaseUrl.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestTaskManagerInternalJson(pathname, init = {}) {
|
||||||
|
if (!config.internalAccessToken) {
|
||||||
|
throw new Error("NODE.DC internal access token is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = new URL(pathname, `${getTaskBaseUrl()}/`);
|
||||||
|
const hasBody = typeof init.body === "object" && init.body !== null;
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: init.method ?? (hasBody ? "POST" : "GET"),
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${config.internalAccessToken}`,
|
||||||
|
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
body: hasBody ? JSON.stringify(init.body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = typeof payload?.error === "string" ? payload.error : `Task Manager internal API failed: ${response.status}`;
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonResponse(text, url) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Task Manager internal API returned non-JSON response: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalText(value) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskManagerRole(value) {
|
||||||
|
return value === "guest" || value === "admin" || value === "member" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskManagerRoleForMembership(role) {
|
||||||
|
return role === "client_owner" || role === "client_admin" ? "admin" : "member";
|
||||||
|
}
|
||||||
|
|
||||||
function createServiceHandoff(serviceSlug, user) {
|
function createServiceHandoff(serviceSlug, user) {
|
||||||
pruneExpiredServiceHandoffs();
|
pruneExpiredServiceHandoffs();
|
||||||
|
|
||||||
|
|
@ -1234,6 +1537,43 @@ function pruneExpiredServiceHandoffs() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess) {
|
||||||
|
const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
|
||||||
|
const groupSet = new Set(groups);
|
||||||
|
const isSuperAdmin = groupSet.has("nodedc:superadmin");
|
||||||
|
const isTaskManagerAdmin = groupSet.has("nodedc:taskmanager:admin");
|
||||||
|
|
||||||
|
if (!hasTaskManagerAccess) {
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
canCreateWorkspace: false,
|
||||||
|
reason: "Нет доступа к Operational Core.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "disabled") {
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
canCreateWorkspace: false,
|
||||||
|
reason: "Создание рабочих пространств отключено на уровне платформы.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "task_admins_only" && !isSuperAdmin && !isTaskManagerAdmin) {
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
canCreateWorkspace: false,
|
||||||
|
reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
canCreateWorkspace: true,
|
||||||
|
reason: "Создание рабочих пространств разрешено платформенной policy.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getFrontchannelLogoutUrls() {
|
function getFrontchannelLogoutUrls() {
|
||||||
const urls = [config.taskLogoutUrl];
|
const urls = [config.taskLogoutUrl];
|
||||||
const launcherData = readLauncherData();
|
const launcherData = readLauncherData();
|
||||||
|
|
@ -1452,6 +1792,11 @@ function resolvePublicUrl(value, baseUrl) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveUserAvatarPublicUrl(user) {
|
||||||
|
if (!user?.avatarUrl) return null;
|
||||||
|
return resolvePublicUrl(user.avatarUrl, config.appBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveUploadedFile(payload) {
|
async function saveUploadedFile(payload) {
|
||||||
if (!isUploadPayload(payload)) {
|
if (!isUploadPayload(payload)) {
|
||||||
throw new Error("Некорректный payload загрузки");
|
throw new Error("Некорректный payload загрузки");
|
||||||
|
|
@ -1617,13 +1962,24 @@ function requireLauncherAdmin(req, res, next) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeContext = getRuntimeSessionContext(session);
|
const runtimeContext = getRuntimeSessionContext(session);
|
||||||
|
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
|
||||||
|
|
||||||
if (!isLauncherAdmin(runtimeContext.groups)) {
|
if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
|
||||||
res.status(403).json({ error: "Недостаточно прав Launcher admin" });
|
res.status(403).json({ error: "Недостаточно прав Launcher admin" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
req.nodedcSession = { ...session, user: runtimeContext.user };
|
req.nodedcSession = { ...session, user: runtimeContext.user };
|
||||||
|
req.nodedcAdminScope = adminScope;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRootLauncherAdmin(req, res, next) {
|
||||||
|
if (!req.nodedcAdminScope?.isRoot) {
|
||||||
|
res.status(403).json({ error: "Действие доступно только суперпользователю NODE.DC" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1708,6 +2064,158 @@ function isLauncherAdmin(groups) {
|
||||||
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
|
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientAdminMembershipRoles = new Set(["client_owner", "client_admin"]);
|
||||||
|
const protectedLauncherUserIds = new Set(["user_root"]);
|
||||||
|
|
||||||
|
function resolveAdminScope(identity, groups) {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(identity);
|
||||||
|
const actorId = snapshot.actor.source === "launcher" ? snapshot.actor.id : null;
|
||||||
|
const isRoot = isLauncherAdmin(groups);
|
||||||
|
const clientIds = new Set(
|
||||||
|
isRoot
|
||||||
|
? snapshot.data.clients.map((client) => client.id)
|
||||||
|
: snapshot.data.memberships
|
||||||
|
.filter((membership) => membership.userId === actorId && membership.status === "active" && clientAdminMembershipRoles.has(membership.role))
|
||||||
|
.map((membership) => membership.clientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
actorId,
|
||||||
|
clientIds,
|
||||||
|
isRoot,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAdminManageClient(req, clientId) {
|
||||||
|
return Boolean(req.nodedcAdminScope?.isRoot || req.nodedcAdminScope?.clientIds.has(clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAdminManageUser(req, userId) {
|
||||||
|
if (protectedLauncherUserIds.has(userId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.nodedcAdminScope?.isRoot) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return req.nodedcAdminScope?.snapshot.data.memberships.some(
|
||||||
|
(membership) => membership.userId === userId && req.nodedcAdminScope.clientIds.has(membership.clientId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAdminCanManageClient(req, res, clientId) {
|
||||||
|
if (canAdminManageClient(req, clientId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).json({ error: "Недостаточно прав для управления этим клиентом" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAdminCanManageUser(req, res, userId) {
|
||||||
|
if (canAdminManageUser(req, userId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).json({ error: "Недостаточно прав для управления этим пользователем" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAdminCanManageMembership(req, res, membership) {
|
||||||
|
if (!assertAdminCanManageClient(req, res, membership.clientId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assertAdminCanManageUser(req, res, membership.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId) {
|
||||||
|
if (req.nodedcAdminScope?.isRoot) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === "client") {
|
||||||
|
return assertAdminCanManageClient(req, res, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === "group") {
|
||||||
|
const group = data.groups.find((candidate) => candidate.id === targetId);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
res.status(404).json({ error: "group_not_found" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assertAdminCanManageClient(req, res, group.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === "user") {
|
||||||
|
return assertAdminCanManageUser(req, res, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).json({ error: "Недостаточно прав для управления этим доступом" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeAdminSnapshot(req, snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user)) {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
data: scopeControlPlaneData(snapshot.data, req.nodedcAdminScope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeAdminMutationResult(req, result) {
|
||||||
|
if (!result?.data) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: scopeControlPlaneData(result.data, req.nodedcAdminScope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeControlPlaneData(data, scope) {
|
||||||
|
if (!scope || scope.isRoot) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIds = scope.clientIds;
|
||||||
|
const memberships = data.memberships.filter((membership) => clientIds.has(membership.clientId));
|
||||||
|
const userIds = new Set(memberships.map((membership) => membership.userId));
|
||||||
|
|
||||||
|
if (scope.actorId) {
|
||||||
|
userIds.add(scope.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
clients: data.clients.filter((client) => clientIds.has(client.id)),
|
||||||
|
users: data.users.filter((user) => userIds.has(user.id)),
|
||||||
|
memberships,
|
||||||
|
groups: data.groups.filter((group) => clientIds.has(group.clientId)),
|
||||||
|
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
|
||||||
|
grants: data.grants.filter((grant) => {
|
||||||
|
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
||||||
|
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
||||||
|
if (grant.targetType === "user") return userIds.has(grant.targetId);
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
exceptions: data.exceptions.filter((exception) => userIds.has(exception.userId)),
|
||||||
|
taskManagerMemberships: data.taskManagerMemberships.filter(
|
||||||
|
(membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId)
|
||||||
|
),
|
||||||
|
syncStatuses: data.syncStatuses.filter(
|
||||||
|
(syncStatus) => clientIds.has(syncStatus.objectId) || userIds.has(syncStatus.objectId) || groupIds.has(syncStatus.objectId)
|
||||||
|
),
|
||||||
|
auditEvents: data.auditEvents.filter((event) => !event.clientId || clientIds.has(event.clientId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cookieOptions(maxAgeMs) {
|
function cookieOptions(maxAgeMs) {
|
||||||
const options = {
|
const options = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,12 @@ import {
|
||||||
deleteAdminInvite,
|
deleteAdminInvite,
|
||||||
deleteAdminMembership,
|
deleteAdminMembership,
|
||||||
deleteAdminService,
|
deleteAdminService,
|
||||||
|
ensureAdminTaskManagerWorkspaceMembership,
|
||||||
|
fetchAdminTaskManagerWorkspaces,
|
||||||
fetchControlPlaneSnapshot,
|
fetchControlPlaneSnapshot,
|
||||||
reorderAdminServices,
|
reorderAdminServices,
|
||||||
retryAdminSync,
|
retryAdminSync,
|
||||||
|
removeAdminTaskManagerWorkspaceMembership,
|
||||||
setAdminUserServiceAccess,
|
setAdminUserServiceAccess,
|
||||||
updateAdminClient,
|
updateAdminClient,
|
||||||
updateAdminGroup,
|
updateAdminGroup,
|
||||||
|
|
@ -27,6 +30,8 @@ import {
|
||||||
updateAdminSettings,
|
updateAdminSettings,
|
||||||
updateAdminUserProfile,
|
updateAdminUserProfile,
|
||||||
type ControlPlaneMutationResult,
|
type ControlPlaneMutationResult,
|
||||||
|
type TaskManagerWorkspaceMemberRole,
|
||||||
|
type TaskManagerWorkspaceSummary,
|
||||||
} from "../shared/api/adminApi";
|
} from "../shared/api/adminApi";
|
||||||
import {
|
import {
|
||||||
buildLauncherServices,
|
buildLauncherServices,
|
||||||
|
|
@ -81,6 +86,10 @@ export function LauncherApp() {
|
||||||
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
||||||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||||||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||||||
|
const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState<Record<string, boolean>>({});
|
||||||
|
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
|
||||||
|
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
|
||||||
|
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
|
||||||
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
|
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
|
||||||
|
|
||||||
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
||||||
|
|
@ -108,6 +117,7 @@ export function LauncherApp() {
|
||||||
};
|
};
|
||||||
}, [authSession, me]);
|
}, [authSession, me]);
|
||||||
const resolvedClientId = me.activeClientId;
|
const resolvedClientId = me.activeClientId;
|
||||||
|
const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin);
|
||||||
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
|
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
|
||||||
const launcherServices = useMemo(
|
const launcherServices = useMemo(
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -303,7 +313,7 @@ export function LauncherApp() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession?.authenticated || !canUseAdminApi(authSession)) return;
|
if (!canOpenAdminApi) return;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
|
|
@ -320,7 +330,12 @@ export function LauncherApp() {
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [authSession]);
|
}, [canOpenAdminApi]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adminOpen || !canOpenAdminApi) return;
|
||||||
|
void refreshTaskManagerWorkspaces();
|
||||||
|
}, [adminOpen, canOpenAdminApi]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession?.authenticated) return;
|
if (!authSession?.authenticated) return;
|
||||||
|
|
@ -340,8 +355,10 @@ export function LauncherApp() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextContext = resolveAuthenticatedContext(data, nextSession, activeProfileId, activeClientId);
|
||||||
|
const nextMe = buildMe(data, nextContext.profileId, nextContext.clientId);
|
||||||
const [persistedData, apps] = await Promise.all([
|
const [persistedData, apps] = await Promise.all([
|
||||||
canUseAdminApi(nextSession)
|
nextMe.permissions.canOpenAdmin
|
||||||
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
|
||||||
: loadPersistedLauncherData(),
|
: loadPersistedLauncherData(),
|
||||||
fetchAvailableApps(),
|
fetchAvailableApps(),
|
||||||
|
|
@ -418,6 +435,20 @@ export function LauncherApp() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshTaskManagerWorkspaces() {
|
||||||
|
setTaskManagerWorkspacesLoading(true);
|
||||||
|
setTaskManagerWorkspacesError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminTaskManagerWorkspaces();
|
||||||
|
setTaskManagerWorkspaces(result.workspaces ?? []);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setTaskManagerWorkspacesError(error instanceof Error ? error.message : "Не удалось загрузить workspace Operational Core");
|
||||||
|
} finally {
|
||||||
|
setTaskManagerWorkspacesLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) {
|
function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) {
|
||||||
const assignmentKey = accessAssignmentKey(userId, serviceId);
|
const assignmentKey = accessAssignmentKey(userId, serviceId);
|
||||||
|
|
||||||
|
|
@ -441,6 +472,39 @@ export function LauncherApp() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole }) {
|
||||||
|
const membershipKey = `${command.clientId}:${command.userId}`;
|
||||||
|
|
||||||
|
if (pendingTaskManagerMemberships[membershipKey]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true }));
|
||||||
|
const request =
|
||||||
|
command.role === "unset"
|
||||||
|
? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId })
|
||||||
|
: ensureAdminTaskManagerWorkspaceMembership({
|
||||||
|
clientId: command.clientId,
|
||||||
|
userId: command.userId,
|
||||||
|
role: command.role,
|
||||||
|
setLastWorkspace: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
request
|
||||||
|
.then((result) => {
|
||||||
|
setData(syncLauncherServiceLinks(result.data));
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать Tasker");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setPendingTaskManagerMemberships((current) => {
|
||||||
|
const { [membershipKey]: _completed, ...rest } = current;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
||||||
applyControlPlaneMutation(createAdminInvite(invite));
|
applyControlPlaneMutation(createAdminInvite(invite));
|
||||||
}
|
}
|
||||||
|
|
@ -683,6 +747,12 @@ export function LauncherApp() {
|
||||||
onCreateService={handleCreateService}
|
onCreateService={handleCreateService}
|
||||||
onDeleteService={handleDeleteService}
|
onDeleteService={handleDeleteService}
|
||||||
onUpdateSettings={handleUpdateSettings}
|
onUpdateSettings={handleUpdateSettings}
|
||||||
|
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||||
|
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||||||
|
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||||
|
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||||
|
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
|
||||||
|
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{profileSettingsOpen && activeProfileUser ? (
|
{profileSettingsOpen && activeProfileUser ? (
|
||||||
|
|
@ -712,13 +782,6 @@ function accessAssignmentKey(userId: string, serviceId: string) {
|
||||||
return `${userId}:${serviceId}`;
|
return `${userId}:${serviceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseAdminApi(session: AuthSession): boolean {
|
|
||||||
return (
|
|
||||||
session.authenticated &&
|
|
||||||
(session.isSuperAdmin || session.groups.includes("nodedc:launcher:admin") || session.groups.includes("nodedc:superadmin"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAuthenticatedContext(
|
function resolveAuthenticatedContext(
|
||||||
data: LauncherData,
|
data: LauncherData,
|
||||||
session: AuthenticatedSession,
|
session: AuthenticatedSession,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ export interface Client {
|
||||||
demoEndsAt?: string | null;
|
demoEndsAt?: string | null;
|
||||||
contactName?: string | null;
|
contactName?: string | null;
|
||||||
contactEmail?: string | null;
|
contactEmail?: string | null;
|
||||||
|
integrations?: {
|
||||||
|
taskManager?: {
|
||||||
|
workspaceSlug?: string | null;
|
||||||
|
workspaceName?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,57 @@ export interface ControlPlaneMutationResult {
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskManagerWorkspaceSummary {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
ownerEmail: string | null;
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskManagerWorkspaceMembershipResult {
|
||||||
|
created: boolean;
|
||||||
|
workspace: TaskManagerWorkspaceSummary;
|
||||||
|
member: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
role: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isBanned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskManagerWorkspaceMemberRole = "unset" | "guest" | "member" | "admin";
|
||||||
|
|
||||||
|
export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult {
|
||||||
|
taskManager: {
|
||||||
|
ok: boolean;
|
||||||
|
membership: TaskManagerWorkspaceMembershipResult;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskManagerWorkspaceMembershipRemoveMutationResult extends ControlPlaneMutationResult {
|
||||||
|
taskManager: {
|
||||||
|
ok: boolean;
|
||||||
|
removed: boolean;
|
||||||
|
workspace: TaskManagerWorkspaceSummary;
|
||||||
|
member: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> {
|
export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> {
|
||||||
return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane");
|
return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTaskManagerWorkspaces(): Promise<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }> {
|
||||||
|
return requestJson<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }>("/api/admin/task-manager/workspaces");
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> {
|
export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> {
|
||||||
return requestJson<ControlPlaneMutationResult>("/api/admin/clients", {
|
return requestJson<ControlPlaneMutationResult>("/api/admin/clients", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -100,6 +147,28 @@ export async function deleteAdminMembership(membershipId: string): Promise<Contr
|
||||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { method: "DELETE" });
|
return requestJson<ControlPlaneMutationResult>(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
role?: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
|
||||||
|
setLastWorkspace?: boolean;
|
||||||
|
}): Promise<TaskManagerWorkspaceMembershipMutationResult> {
|
||||||
|
return requestJson<TaskManagerWorkspaceMembershipMutationResult>("/api/admin/task-manager/workspace-memberships/ensure", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAdminTaskManagerWorkspaceMembership(payload: {
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<TaskManagerWorkspaceMembershipRemoveMutationResult> {
|
||||||
|
return requestJson<TaskManagerWorkspaceMembershipRemoveMutationResult>("/api/admin/task-manager/workspace-memberships/remove", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAdminGroup(payload: Pick<ClientGroup, "clientId"> & Partial<ClientGroup>): Promise<ControlPlaneMutationResult> {
|
export async function createAdminGroup(payload: Pick<ClientGroup, "clientId"> & Partial<ClientGroup>): Promise<ControlPlaneMutationResult> {
|
||||||
return requestJson<ControlPlaneMutationResult>("/api/admin/groups", {
|
return requestJson<ControlPlaneMutationResult>("/api/admin/groups", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,33 @@ export interface LauncherData {
|
||||||
invites: Invite[];
|
invites: Invite[];
|
||||||
syncStatuses: SyncStatus[];
|
syncStatuses: SyncStatus[];
|
||||||
auditEvents: typeof mockAuditEvents;
|
auditEvents: typeof mockAuditEvents;
|
||||||
|
taskManagerMemberships: TaskManagerMembershipAssignment[];
|
||||||
settings: LauncherSettings;
|
settings: LauncherSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskManagerMembershipAssignment {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
workspaceName?: string | null;
|
||||||
|
role: "guest" | "member" | "admin";
|
||||||
|
planeUserId?: string | null;
|
||||||
|
planeRole?: number | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LauncherSettings {
|
export interface LauncherSettings {
|
||||||
brand: {
|
brand: {
|
||||||
logoLinkUrl: string;
|
logoLinkUrl: string;
|
||||||
};
|
};
|
||||||
|
taskManager: {
|
||||||
|
workspaceCreationPolicy: TaskManagerWorkspaceCreationPolicy;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaskManagerWorkspaceCreationPolicy = "any_authorized_user" | "task_admins_only" | "disabled";
|
||||||
|
|
||||||
export interface ProfileOption {
|
export interface ProfileOption {
|
||||||
userId: string;
|
userId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -94,6 +112,9 @@ export const defaultLauncherSettings: LauncherSettings = {
|
||||||
brand: {
|
brand: {
|
||||||
logoLinkUrl: "/",
|
logoLinkUrl: "/",
|
||||||
},
|
},
|
||||||
|
taskManager: {
|
||||||
|
workspaceCreationPolicy: "any_authorized_user",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialLauncherData: LauncherData = normalizeLauncherData({
|
export const initialLauncherData: LauncherData = normalizeLauncherData({
|
||||||
|
|
@ -115,15 +136,29 @@ export function normalizeLauncherSettings(settings?: Partial<LauncherSettings> |
|
||||||
typeof settings?.brand === "object" && settings.brand !== null
|
typeof settings?.brand === "object" && settings.brand !== null
|
||||||
? settings.brand
|
? settings.brand
|
||||||
: ({} as Partial<LauncherSettings["brand"]>);
|
: ({} as Partial<LauncherSettings["brand"]>);
|
||||||
|
const taskManager =
|
||||||
|
typeof settings?.taskManager === "object" && settings.taskManager !== null
|
||||||
|
? settings.taskManager
|
||||||
|
: ({} as Partial<LauncherSettings["taskManager"]>);
|
||||||
const logoLinkUrl = typeof brand.logoLinkUrl === "string" && brand.logoLinkUrl.trim() ? brand.logoLinkUrl.trim() : "/";
|
const logoLinkUrl = typeof brand.logoLinkUrl === "string" && brand.logoLinkUrl.trim() ? brand.logoLinkUrl.trim() : "/";
|
||||||
|
const workspaceCreationPolicy = isTaskManagerWorkspaceCreationPolicy(taskManager.workspaceCreationPolicy)
|
||||||
|
? taskManager.workspaceCreationPolicy
|
||||||
|
: defaultLauncherSettings.taskManager.workspaceCreationPolicy;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand: {
|
brand: {
|
||||||
logoLinkUrl,
|
logoLinkUrl,
|
||||||
},
|
},
|
||||||
|
taskManager: {
|
||||||
|
workspaceCreationPolicy,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskManagerWorkspaceCreationPolicy(value: unknown): value is TaskManagerWorkspaceCreationPolicy {
|
||||||
|
return value === "any_authorized_user" || value === "task_admins_only" || value === "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeLauncherData(data: Partial<LauncherData> | null | undefined): LauncherData {
|
export function normalizeLauncherData(data: Partial<LauncherData> | null | undefined): LauncherData {
|
||||||
const payload = data ?? {};
|
const payload = data ?? {};
|
||||||
|
|
||||||
|
|
@ -138,6 +173,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
||||||
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
||||||
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||||
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||||
|
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
|
||||||
settings: normalizeLauncherSettings(payload.settings),
|
settings: normalizeLauncherSettings(payload.settings),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export interface NodeDcSelectOption<T extends string> {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
tone?: string;
|
tone?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeDcSelectTriggerApi<T extends string> {
|
interface NodeDcSelectTriggerApi<T extends string> {
|
||||||
|
|
@ -59,14 +60,15 @@ export function NodeDcSelect<T extends string>({
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const menuOptions = useMemo(() => options.filter((option) => !option.hidden), [options]);
|
||||||
const visibleOptions = useMemo(() => {
|
const visibleOptions = useMemo(() => {
|
||||||
if (!normalizedQuery) return options;
|
if (!normalizedQuery) return menuOptions;
|
||||||
|
|
||||||
return options.filter((option) => {
|
return menuOptions.filter((option) => {
|
||||||
const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase();
|
const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase();
|
||||||
return haystack.includes(normalizedQuery);
|
return haystack.includes(normalizedQuery);
|
||||||
});
|
});
|
||||||
}, [normalizedQuery, options]);
|
}, [menuOptions, normalizedQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeDcDropdown
|
<NodeDcDropdown
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,11 @@ code {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-main:has(.admin-panel-layer--fullscreen) .service-rail {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.service-stage {
|
.service-stage {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -665,9 +670,15 @@ code {
|
||||||
transition:
|
transition:
|
||||||
padding-left 440ms cubic-bezier(0.22, 1, 0.36, 1),
|
padding-left 440ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
padding-right 440ms cubic-bezier(0.22, 1, 0.36, 1),
|
padding-right 440ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 440ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
background 220ms ease;
|
background 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-main:has(.admin-panel-layer--fullscreen) .service-stage {
|
||||||
|
transform: translateX(calc(100vw + var(--launcher-page-pad)));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.service-stage--empty {
|
.service-stage--empty {
|
||||||
background: #050506;
|
background: #050506;
|
||||||
}
|
}
|
||||||
|
|
@ -1388,6 +1399,11 @@ code {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-panel-layer--fullscreen {
|
||||||
|
right: var(--launcher-page-pad);
|
||||||
|
bottom: var(--launcher-page-pad);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-panel-nav,
|
.admin-panel-nav,
|
||||||
.admin-panel-content {
|
.admin-panel-content {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|
@ -1424,6 +1440,11 @@ code {
|
||||||
animation: adminPanelSlide 460ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: adminPanelSlide 460ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-panel-layer--fullscreen .admin-panel-content {
|
||||||
|
width: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-settings-layer {
|
.profile-settings-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
|
|
@ -1996,6 +2017,13 @@ code {
|
||||||
color: rgb(var(--nodedc-on-accent-rgb));
|
color: rgb(var(--nodedc-on-accent-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-circle-action--active,
|
||||||
|
.admin-circle-action--active:hover {
|
||||||
|
border-color: rgba(247, 248, 244, 0.98);
|
||||||
|
background: rgba(247, 248, 244, 0.98) !important;
|
||||||
|
color: rgb(var(--nodedc-on-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
.admin-circle-action:disabled {
|
.admin-circle-action:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.36;
|
opacity: 0.36;
|
||||||
|
|
@ -2070,6 +2098,111 @@ code {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-matrix {
|
||||||
|
--access-matrix-table-bg: rgb(20, 20, 22);
|
||||||
|
background: var(--access-matrix-table-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell--users {
|
||||||
|
position: relative;
|
||||||
|
--admin-users-table-bg: rgb(20, 20, 22);
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0 0 1rem;
|
||||||
|
background: var(--admin-users-table-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell--users .table-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 6;
|
||||||
|
width: 16rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1rem 0.7rem;
|
||||||
|
border-top-left-radius: var(--launcher-radius-card);
|
||||||
|
background: var(--admin-users-table-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell--users .admin-data-table {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users {
|
||||||
|
min-width: 66rem;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th,
|
||||||
|
.admin-data-table--users td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th:nth-child(1),
|
||||||
|
.admin-data-table--users td:nth-child(1) {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
width: 16rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
background: var(--admin-users-table-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th:nth-child(1) {
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th:nth-child(2),
|
||||||
|
.admin-data-table--users td:nth-child(2) {
|
||||||
|
width: 8.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th:nth-child(3),
|
||||||
|
.admin-data-table--users td:nth-child(3) {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th:nth-child(4),
|
||||||
|
.admin-data-table--users td:nth-child(4) {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th:nth-child(5),
|
||||||
|
.admin-data-table--users td:nth-child(5) {
|
||||||
|
width: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--users th:nth-child(6),
|
||||||
|
.admin-data-table--users td:nth-child(6) {
|
||||||
|
width: 10.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-static-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.08rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: var(--launcher-radius-circle);
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 0.72rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 780;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-action-placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
width: var(--admin-control-ring);
|
||||||
|
height: var(--admin-control-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-cell__fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.table-toolbar {
|
.table-toolbar {
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
@ -2194,6 +2327,19 @@ code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 2.85rem;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-content-field small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.service-status-dropdown {
|
.service-status-dropdown {
|
||||||
width: 7.45rem;
|
width: 7.45rem;
|
||||||
min-width: 7.45rem;
|
min-width: 7.45rem;
|
||||||
|
|
@ -2915,6 +3061,81 @@ code {
|
||||||
|
|
||||||
.matrix-scroll {
|
.matrix-scroll {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
border-radius: calc(var(--launcher-radius-card) - 0.85rem);
|
||||||
|
background: var(--access-matrix-table-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-matrix-grid {
|
||||||
|
display: grid;
|
||||||
|
min-width: max-content;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--access-matrix-table-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-grid-head,
|
||||||
|
.access-grid-cell {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.075);
|
||||||
|
background: var(--access-matrix-table-bg);
|
||||||
|
padding: 0.48rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-grid-head {
|
||||||
|
min-height: 2.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-grid-cell {
|
||||||
|
min-height: 4.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-grid-sticky {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
|
background: var(--access-matrix-table-bg);
|
||||||
|
box-shadow: 1px 0 0 rgba(255, 255, 255, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-grid-head.access-grid-sticky {
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-user-cell {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-user-cell strong,
|
||||||
|
.access-user-cell small {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-user-cell small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-main-stack {
|
||||||
|
display: grid;
|
||||||
|
width: 10.8rem;
|
||||||
|
gap: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-cell--main {
|
||||||
|
min-width: 8.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-cell--readonly {
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.access-cell {
|
.access-cell {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
|
|
@ -27,6 +27,8 @@ import {
|
||||||
Link2,
|
Link2,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
MailPlus,
|
MailPlus,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
|
|
@ -62,7 +64,9 @@ import {
|
||||||
type LauncherData,
|
type LauncherData,
|
||||||
type LauncherSettings,
|
type LauncherSettings,
|
||||||
type MeResponse,
|
type MeResponse,
|
||||||
|
type TaskManagerWorkspaceCreationPolicy,
|
||||||
} from "../../shared/api/mockApi";
|
} from "../../shared/api/mockApi";
|
||||||
|
import type { TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
|
||||||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||||
import { cn } from "../../shared/lib/cn";
|
import { cn } from "../../shared/lib/cn";
|
||||||
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
||||||
|
|
@ -85,6 +89,7 @@ type AdminSection =
|
||||||
|
|
||||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||||||
|
type TaskManagerRoleSelectValue = TaskManagerWorkspaceMemberRole | "pending";
|
||||||
|
|
||||||
export interface SetUserServiceAccessCommand {
|
export interface SetUserServiceAccessCommand {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -102,6 +107,12 @@ export interface CreateUserCommand {
|
||||||
generatePassword: boolean;
|
generatePassword: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnsureTaskManagerWorkspaceMemberCommand {
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
role: TaskManagerWorkspaceMemberRole;
|
||||||
|
}
|
||||||
|
|
||||||
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||||
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
|
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
|
||||||
|
|
@ -122,7 +133,6 @@ const clientSections: Array<{ id: AdminSection; label: string; icon: React.React
|
||||||
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
{ id: "access", label: "Доступы", icon: <KeyRound size={16} /> },
|
||||||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
||||||
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
|
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
|
||||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminOverlay({
|
export function AdminOverlay({
|
||||||
|
|
@ -151,6 +161,12 @@ export function AdminOverlay({
|
||||||
onCreateService,
|
onCreateService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
onUpdateSettings,
|
onUpdateSettings,
|
||||||
|
taskManagerWorkspaces,
|
||||||
|
taskManagerWorkspacesLoading,
|
||||||
|
taskManagerWorkspacesError,
|
||||||
|
pendingTaskManagerMemberships,
|
||||||
|
onRefreshTaskManagerWorkspaces,
|
||||||
|
onSetTaskManagerWorkspaceMemberRole,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
me: MeResponse;
|
me: MeResponse;
|
||||||
|
|
@ -177,10 +193,17 @@ export function AdminOverlay({
|
||||||
onCreateService: () => void;
|
onCreateService: () => void;
|
||||||
onDeleteService: (serviceId: string) => void;
|
onDeleteService: (serviceId: string) => void;
|
||||||
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||||||
|
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||||||
|
taskManagerWorkspacesLoading: boolean;
|
||||||
|
taskManagerWorkspacesError: string | null;
|
||||||
|
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||||
|
onRefreshTaskManagerWorkspaces: () => void;
|
||||||
|
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||||
}) {
|
}) {
|
||||||
const isRoot = me.launcherRole === "root_admin";
|
const isRoot = me.launcherRole === "root_admin";
|
||||||
const sections = isRoot ? rootSections : clientSections;
|
const sections = isRoot ? rootSections : clientSections;
|
||||||
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
|
const [activeSection, setActiveSection] = useState<AdminSection | null>(null);
|
||||||
|
const [isContentFullscreen, setIsContentFullscreen] = useState(false);
|
||||||
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
|
const [selectedClientId, setSelectedClientId] = useState(activeClientId);
|
||||||
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null);
|
||||||
|
|
||||||
|
|
@ -210,8 +233,18 @@ export function AdminOverlay({
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSection) setIsContentFullscreen(false);
|
||||||
|
}, [activeSection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("admin-panel-layer", activeSection && "admin-panel-layer--content-open")}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"admin-panel-layer",
|
||||||
|
activeSection && "admin-panel-layer--content-open",
|
||||||
|
isContentFullscreen && "admin-panel-layer--fullscreen"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<aside className="admin-panel-nav">
|
<aside className="admin-panel-nav">
|
||||||
<div className="admin-panel-nav__head">
|
<div className="admin-panel-nav__head">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -284,11 +317,24 @@ export function AdminOverlay({
|
||||||
|
|
||||||
{activeSection ? (
|
{activeSection ? (
|
||||||
<section className="admin-panel-content">
|
<section className="admin-panel-content">
|
||||||
<AdminHeader onCloseContent={() => setActiveSection(null)} />
|
<AdminHeader
|
||||||
|
isFullscreen={isContentFullscreen}
|
||||||
|
onToggleFullscreen={() => setIsContentFullscreen((current) => !current)}
|
||||||
|
onCloseContent={() => setActiveSection(null)}
|
||||||
|
/>
|
||||||
<div className="admin-panel-content__body">
|
<div className="admin-panel-content__body">
|
||||||
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
|
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
|
||||||
{activeSection === "clients" && isRoot ? (
|
{activeSection === "clients" && isRoot ? (
|
||||||
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} onDeleteClient={onDeleteClient} />
|
<ClientsSection
|
||||||
|
data={data}
|
||||||
|
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||||
|
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||||||
|
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||||
|
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
|
||||||
|
onCreateClient={onCreateClient}
|
||||||
|
onUpdateClient={onUpdateClient}
|
||||||
|
onDeleteClient={onDeleteClient}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "users" ? (
|
{activeSection === "users" ? (
|
||||||
<UsersSection
|
<UsersSection
|
||||||
|
|
@ -297,8 +343,6 @@ export function AdminOverlay({
|
||||||
isRoot={isRoot}
|
isRoot={isRoot}
|
||||||
onCreateUser={onCreateUser}
|
onCreateUser={onCreateUser}
|
||||||
onUpdateUser={onUpdateUser}
|
onUpdateUser={onUpdateUser}
|
||||||
onUpdateMembership={onUpdateMembership}
|
|
||||||
onDeleteMembership={onDeleteMembership}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "groups" ? (
|
{activeSection === "groups" ? (
|
||||||
|
|
@ -327,6 +371,10 @@ export function AdminOverlay({
|
||||||
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
|
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
|
||||||
onSetUserServiceAccess={onSetUserServiceAccess}
|
onSetUserServiceAccess={onSetUserServiceAccess}
|
||||||
pendingAccessAssignments={pendingAccessAssignments}
|
pendingAccessAssignments={pendingAccessAssignments}
|
||||||
|
onUpdateUser={onUpdateUser}
|
||||||
|
onUpdateMembership={onUpdateMembership}
|
||||||
|
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||||
|
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "invites" ? (
|
{activeSection === "invites" ? (
|
||||||
|
|
@ -350,15 +398,29 @@ export function AdminOverlay({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminHeader({ onCloseContent }: { onCloseContent: () => void }) {
|
function AdminHeader({
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onCloseContent,
|
||||||
|
}: {
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onCloseContent: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
<div className="admin-header__actions">
|
<div className="admin-header__actions">
|
||||||
<IconButton label="Проверить доступы" className="admin-circle-action" type="button">
|
<IconButton label="Проверить доступы" className="admin-circle-action" type="button">
|
||||||
<SearchCheck size={16} />
|
<SearchCheck size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton label="Синхронизация" className="admin-circle-action admin-circle-action--solid" type="button">
|
<IconButton
|
||||||
<RefreshCw size={16} />
|
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
|
||||||
|
className={cn("admin-circle-action", isFullscreen && "admin-circle-action--active")}
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
aria-pressed={isFullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton label="Закрыть панель раздела" className="admin-circle-action admin-content-close" type="button" onClick={onCloseContent}>
|
<IconButton label="Закрыть панель раздела" className="admin-circle-action admin-content-close" type="button" onClick={onCloseContent}>
|
||||||
<X size={15} strokeWidth={1.45} />
|
<X size={15} strokeWidth={1.45} />
|
||||||
|
|
@ -398,11 +460,19 @@ function OverviewSection({ data, clientId, isRoot }: { data: LauncherData; clien
|
||||||
|
|
||||||
function ClientsSection({
|
function ClientsSection({
|
||||||
data,
|
data,
|
||||||
|
taskManagerWorkspaces,
|
||||||
|
taskManagerWorkspacesLoading,
|
||||||
|
taskManagerWorkspacesError,
|
||||||
|
onRefreshTaskManagerWorkspaces,
|
||||||
onCreateClient,
|
onCreateClient,
|
||||||
onUpdateClient,
|
onUpdateClient,
|
||||||
onDeleteClient,
|
onDeleteClient,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
|
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||||||
|
taskManagerWorkspacesLoading: boolean;
|
||||||
|
taskManagerWorkspacesError: string | null;
|
||||||
|
onRefreshTaskManagerWorkspaces: () => void;
|
||||||
onCreateClient: () => void;
|
onCreateClient: () => void;
|
||||||
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
|
||||||
onDeleteClient: (clientId: string) => void;
|
onDeleteClient: (clientId: string) => void;
|
||||||
|
|
@ -502,6 +572,10 @@ function ClientsSection({
|
||||||
{editingClient ? (
|
{editingClient ? (
|
||||||
<ClientEditorModal
|
<ClientEditorModal
|
||||||
client={editingClient}
|
client={editingClient}
|
||||||
|
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||||
|
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||||||
|
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||||
|
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
|
||||||
onClose={() => setEditingClientId(null)}
|
onClose={() => setEditingClientId(null)}
|
||||||
onSave={(patch) => {
|
onSave={(patch) => {
|
||||||
onUpdateClient(editingClient.id, patch);
|
onUpdateClient(editingClient.id, patch);
|
||||||
|
|
@ -524,18 +598,13 @@ function UsersSection({
|
||||||
isRoot,
|
isRoot,
|
||||||
onCreateUser,
|
onCreateUser,
|
||||||
onUpdateUser,
|
onUpdateUser,
|
||||||
onUpdateMembership,
|
|
||||||
onDeleteMembership,
|
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
isRoot: boolean;
|
isRoot: boolean;
|
||||||
onCreateUser: (command: CreateUserCommand) => void;
|
onCreateUser: (command: CreateUserCommand) => void;
|
||||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
|
||||||
onDeleteMembership: (membershipId: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
|
|
||||||
const [newUserEmail, setNewUserEmail] = useState("");
|
const [newUserEmail, setNewUserEmail] = useState("");
|
||||||
const [newUserName, setNewUserName] = useState("");
|
const [newUserName, setNewUserName] = useState("");
|
||||||
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
|
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
|
||||||
|
|
@ -545,7 +614,6 @@ function UsersSection({
|
||||||
: data.memberships
|
: data.memberships
|
||||||
.filter((membership) => membership.clientId === clientId)
|
.filter((membership) => membership.clientId === clientId)
|
||||||
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
|
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
|
||||||
const editingRow = rows.find((row) => row.membership.id === editingMembershipId) ?? null;
|
|
||||||
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
|
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
|
||||||
const groupOptions: Array<NodeDcSelectOption<string>> = [
|
const groupOptions: Array<NodeDcSelectOption<string>> = [
|
||||||
{ value: "none", label: "Без группы" },
|
{ value: "none", label: "Без группы" },
|
||||||
|
|
@ -614,106 +682,89 @@ function UsersSection({
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell table-shell--users">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<h3>Участники</h3>
|
<h3>Участники</h3>
|
||||||
</div>
|
</div>
|
||||||
<table className="admin-data-table">
|
<table className="admin-data-table admin-data-table--users">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Пользователь</th>
|
<th>Пользователь</th>
|
||||||
{isRoot ? <th>Клиент</th> : null}
|
<th>Клиент</th>
|
||||||
<th>Роль</th>
|
<th>Телефон</th>
|
||||||
<th>Группы</th>
|
<th>Должность</th>
|
||||||
<th>Статус</th>
|
<th>Заметки</th>
|
||||||
<th>Доступ</th>
|
<th>Статус аккаунта</th>
|
||||||
<th aria-label="Редактирование" />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map(({ membership, user, client }) => (
|
{rows.map(({ membership, user, client }) => {
|
||||||
<tr key={membership.id}>
|
const protectedUser = user.id === "user_root";
|
||||||
<td className="services-admin-table__service">
|
|
||||||
<input
|
return (
|
||||||
className="admin-table-input admin-table-input--strong"
|
<tr key={membership.id}>
|
||||||
value={user.name}
|
<td className="admin-user-cell">
|
||||||
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
|
<div className="admin-user-cell__fields">
|
||||||
aria-label={`Имя пользователя ${user.name}`}
|
<input
|
||||||
/>
|
className="admin-table-input admin-table-input--strong"
|
||||||
<input
|
value={user.name}
|
||||||
className="admin-table-input admin-table-input--muted"
|
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
|
||||||
value={user.email}
|
aria-label={`Имя пользователя ${user.name}`}
|
||||||
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
/>
|
||||||
aria-label={`Email пользователя ${user.name}`}
|
<input
|
||||||
/>
|
className="admin-table-input admin-table-input--muted"
|
||||||
</td>
|
value={user.email}
|
||||||
{isRoot ? <td>{client.name}</td> : null}
|
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
||||||
<td>
|
aria-label={`Email пользователя ${user.name}`}
|
||||||
<NodeDcSelect
|
/>
|
||||||
className="admin-table-select-wrap"
|
</div>
|
||||||
triggerClassName="admin-table-select-trigger"
|
</td>
|
||||||
value={membership.role}
|
<td>{client.name}</td>
|
||||||
options={membershipRoleOptions}
|
<td>
|
||||||
label={`Роль ${user.name}`}
|
<input
|
||||||
minMenuWidth={198}
|
className="admin-table-input"
|
||||||
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
value={user.phone ?? ""}
|
||||||
/>
|
onChange={(event) => onUpdateUser(user.id, { phone: event.target.value || null })}
|
||||||
</td>
|
placeholder="—"
|
||||||
<td>
|
aria-label={`Телефон пользователя ${user.name}`}
|
||||||
{data.groups
|
/>
|
||||||
.filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id))
|
</td>
|
||||||
.map((group) => group.name)
|
<td>
|
||||||
.join(", ") || "—"}
|
<input
|
||||||
</td>
|
className="admin-table-input"
|
||||||
<td>
|
value={user.position ?? ""}
|
||||||
<AdminStatusDropdown
|
onChange={(event) => onUpdateUser(user.id, { position: event.target.value || null })}
|
||||||
value={user.globalStatus}
|
placeholder="—"
|
||||||
options={userStatusOptions}
|
aria-label={`Должность пользователя ${user.name}`}
|
||||||
label={`Статус пользователя ${user.name}`}
|
/>
|
||||||
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
</td>
|
||||||
/>
|
<td>
|
||||||
</td>
|
<input
|
||||||
<td>
|
className="admin-table-input"
|
||||||
<AdminStatusDropdown
|
value={user.notes ?? ""}
|
||||||
value={membership.status}
|
onChange={(event) => onUpdateUser(user.id, { notes: event.target.value || null })}
|
||||||
options={membershipStatusOptions}
|
placeholder="—"
|
||||||
label={`Доступ пользователя ${user.name}`}
|
aria-label={`Заметки пользователя ${user.name}`}
|
||||||
onChange={(status) => onUpdateMembership(membership.id, { status })}
|
/>
|
||||||
/>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td className="services-admin-table__actions">
|
{protectedUser ? (
|
||||||
<IconButton
|
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
|
||||||
label={`Редактировать пользователя ${user.name}`}
|
) : (
|
||||||
className="admin-circle-action services-admin-table__edit"
|
<AdminStatusDropdown
|
||||||
type="button"
|
value={user.globalStatus}
|
||||||
onClick={() => setEditingMembershipId(membership.id)}
|
options={userStatusOptions}
|
||||||
>
|
label={`Статус аккаунта ${user.name}`}
|
||||||
<Edit3 size={15} />
|
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||||
</IconButton>
|
/>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
{editingRow ? (
|
|
||||||
<UserEditorModal
|
|
||||||
user={editingRow.user}
|
|
||||||
membership={editingRow.membership}
|
|
||||||
client={editingRow.client}
|
|
||||||
onClose={() => setEditingMembershipId(null)}
|
|
||||||
onSave={(userPatch, membershipPatch) => {
|
|
||||||
onUpdateUser(editingRow.user.id, userPatch);
|
|
||||||
onUpdateMembership(editingRow.membership.id, membershipPatch);
|
|
||||||
setEditingMembershipId(null);
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
onDeleteMembership(editingRow.membership.id);
|
|
||||||
setEditingMembershipId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -851,6 +902,12 @@ const userStatusOptions: Array<AdminStatusOption<LauncherUserStatus>> = [
|
||||||
{ value: "blocked", label: "Заблокирован", tone: "red" },
|
{ value: "blocked", label: "Заблокирован", tone: "red" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mainStatusOptions: Array<NodeDcSelectOption<LauncherUserStatus>> = [
|
||||||
|
{ value: "active", label: "Активен", tone: "green" },
|
||||||
|
{ value: "blocked", label: "Заблокирован", tone: "red" },
|
||||||
|
{ value: "invited", label: "Приглашён", tone: "yellow", hidden: true },
|
||||||
|
];
|
||||||
|
|
||||||
const membershipStatusOptions: Array<AdminStatusOption<ClientMembershipStatus>> = [
|
const membershipStatusOptions: Array<AdminStatusOption<ClientMembershipStatus>> = [
|
||||||
{ value: "active", label: "Включён", tone: "green" },
|
{ value: "active", label: "Включён", tone: "green" },
|
||||||
{ value: "disabled", label: "Отключён", tone: "red" },
|
{ value: "disabled", label: "Отключён", tone: "red" },
|
||||||
|
|
@ -885,6 +942,71 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
|
||||||
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
|
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function buildTaskManagerRoleOptions({
|
||||||
|
hasWorkspace,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
hasWorkspace: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}): Array<NodeDcSelectOption<TaskManagerRoleSelectValue>> {
|
||||||
|
return [
|
||||||
|
{ value: "unset", label: hasWorkspace ? "—" : "Workspace не выбран" },
|
||||||
|
{ value: "guest", label: "Гость", disabled: !hasWorkspace || disabled },
|
||||||
|
{ value: "member", label: "Участник", disabled: !hasWorkspace || disabled },
|
||||||
|
{ value: "admin", label: "Админ", disabled: !hasWorkspace || disabled },
|
||||||
|
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function membershipRoleLabel(role: ClientMembershipRole): string {
|
||||||
|
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOptionLabel<T extends string>(options: Array<AdminStatusOption<T>>, value: T): string {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainStatusLabel(value: LauncherUserStatus): string {
|
||||||
|
return mainStatusOptions.find((option) => option.value === value)?.label ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskManagerRoleLabel(role: TaskManagerWorkspaceMemberRole | TaskManagerRoleSelectValue): string {
|
||||||
|
const labels: Record<TaskManagerRoleSelectValue, string> = {
|
||||||
|
unset: "—",
|
||||||
|
guest: "Гость",
|
||||||
|
member: "Участник",
|
||||||
|
admin: "Админ",
|
||||||
|
pending: "Сохраняем...",
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminStaticPill({ children }: { children: ReactNode }) {
|
||||||
|
return <span className="admin-static-pill">{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWorkspaceCreationPolicy>> = [
|
||||||
|
{
|
||||||
|
value: "any_authorized_user",
|
||||||
|
label: "Все с доступом",
|
||||||
|
description: "Пользователь с доступом к Operational Core может создать собственный workspace.",
|
||||||
|
tone: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "task_admins_only",
|
||||||
|
label: "Только админы",
|
||||||
|
description: "Workspace создают только суперпользователь и админы Operational Core.",
|
||||||
|
tone: "yellow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "disabled",
|
||||||
|
label: "Отключено",
|
||||||
|
description: "Создание workspace закрыто для всех через платформенную policy.",
|
||||||
|
tone: "red",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
|
const mediaAccept = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv";
|
||||||
const modalActionAccentRgb = [247, 248, 244] as const;
|
const modalActionAccentRgb = [247, 248, 244] as const;
|
||||||
|
|
||||||
|
|
@ -1467,18 +1589,35 @@ function ServiceContentModal({
|
||||||
|
|
||||||
function ClientEditorModal({
|
function ClientEditorModal({
|
||||||
client,
|
client,
|
||||||
|
taskManagerWorkspaces,
|
||||||
|
taskManagerWorkspacesLoading,
|
||||||
|
taskManagerWorkspacesError,
|
||||||
|
onRefreshTaskManagerWorkspaces,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
canDelete,
|
canDelete,
|
||||||
}: {
|
}: {
|
||||||
client: Client;
|
client: Client;
|
||||||
|
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||||||
|
taskManagerWorkspacesLoading: boolean;
|
||||||
|
taskManagerWorkspacesError: string | null;
|
||||||
|
onRefreshTaskManagerWorkspaces: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (patch: Partial<Client>) => void;
|
onSave: (patch: Partial<Client>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<Client>(client);
|
const [draft, setDraft] = useState<Client>(client);
|
||||||
|
const taskManagerWorkspaceOptions: Array<NodeDcSelectOption<string>> = [
|
||||||
|
{ value: "none", label: "Не привязан" },
|
||||||
|
...taskManagerWorkspaces.map((workspace) => ({
|
||||||
|
value: workspace.slug,
|
||||||
|
label: workspace.name,
|
||||||
|
description: `${workspace.slug} · ${workspace.memberCount} участников`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const selectedTaskManagerWorkspaceSlug = draft.integrations?.taskManager?.workspaceSlug ?? "none";
|
||||||
|
|
||||||
useEffect(() => setDraft(client), [client]);
|
useEffect(() => setDraft(client), [client]);
|
||||||
|
|
||||||
|
|
@ -1486,6 +1625,22 @@ function ClientEditorModal({
|
||||||
setDraft((current) => ({ ...current, [key]: value }));
|
setDraft((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateTaskManagerWorkspace(workspaceSlug: string) {
|
||||||
|
const selectedWorkspace = taskManagerWorkspaces.find((workspace) => workspace.slug === workspaceSlug);
|
||||||
|
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
integrations: {
|
||||||
|
...current.integrations,
|
||||||
|
taskManager: {
|
||||||
|
...current.integrations?.taskManager,
|
||||||
|
workspaceSlug: workspaceSlug === "none" ? null : workspaceSlug,
|
||||||
|
workspaceName: selectedWorkspace?.name ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
|
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
|
||||||
<article className="service-content-modal admin-entity-modal">
|
<article className="service-content-modal admin-entity-modal">
|
||||||
|
|
@ -1518,6 +1673,35 @@ function ClientEditorModal({
|
||||||
<span>Статус</span>
|
<span>Статус</span>
|
||||||
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
|
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="service-content-field service-content-field--wide">
|
||||||
|
<span>Operational Core workspace</span>
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<NodeDcSelect
|
||||||
|
className="admin-modal-select-wrap"
|
||||||
|
triggerClassName="admin-modal-select-trigger"
|
||||||
|
value={selectedTaskManagerWorkspaceSlug}
|
||||||
|
options={taskManagerWorkspaceOptions}
|
||||||
|
label="Operational Core workspace"
|
||||||
|
searchable
|
||||||
|
minMenuWidth={280}
|
||||||
|
onChange={updateTaskManagerWorkspace}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
|
||||||
|
className="admin-circle-action admin-circle-action--solid"
|
||||||
|
type="button"
|
||||||
|
disabled={taskManagerWorkspacesLoading}
|
||||||
|
onClick={onRefreshTaskManagerWorkspaces}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
{taskManagerWorkspacesError
|
||||||
|
? taskManagerWorkspacesError
|
||||||
|
: "Эта привязка используется для назначения участников клиента в workspace Task Manager."}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<label className="service-content-field">
|
<label className="service-content-field">
|
||||||
<span>Контактное лицо</span>
|
<span>Контактное лицо</span>
|
||||||
<input value={draft.contactName ?? ""} onChange={(event) => update("contactName", event.target.value || null)} />
|
<input value={draft.contactName ?? ""} onChange={(event) => update("contactName", event.target.value || null)} />
|
||||||
|
|
@ -1918,6 +2102,10 @@ function AccessSection({
|
||||||
onSelectCell,
|
onSelectCell,
|
||||||
onSetUserServiceAccess,
|
onSetUserServiceAccess,
|
||||||
pendingAccessAssignments,
|
pendingAccessAssignments,
|
||||||
|
onUpdateUser,
|
||||||
|
onUpdateMembership,
|
||||||
|
pendingTaskManagerMemberships,
|
||||||
|
onSetTaskManagerWorkspaceMemberRole,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
matrix: ReturnType<typeof buildAccessMatrix>;
|
matrix: ReturnType<typeof buildAccessMatrix>;
|
||||||
|
|
@ -1925,10 +2113,15 @@ function AccessSection({
|
||||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||||
|
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||||
|
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||||
|
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||||
|
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||||
}) {
|
}) {
|
||||||
const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null;
|
const hasUsers = matrix.users.length > 0;
|
||||||
|
const taskManagerWorkspace = matrix.client.integrations?.taskManager?.workspaceSlug ?? null;
|
||||||
|
|
||||||
if (!hasMatrixData) {
|
if (!hasUsers) {
|
||||||
return (
|
return (
|
||||||
<div className="access-layout">
|
<div className="access-layout">
|
||||||
<GlassSurface className="access-matrix">
|
<GlassSurface className="access-matrix">
|
||||||
|
|
@ -1954,8 +2147,9 @@ function AccessSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedUser = getUser(data, selectedCell.userId);
|
const selectedUser = selectedCell ? getUser(data, selectedCell.userId) : null;
|
||||||
const selectedService = getService(data, selectedCell.serviceId);
|
const selectedService = selectedCell ? getService(data, selectedCell.serviceId) : null;
|
||||||
|
const accessGridTemplateColumns = `15rem repeat(${matrix.services.length + 2}, 9.7rem)`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="access-layout">
|
<div className="access-layout">
|
||||||
|
|
@ -1965,75 +2159,214 @@ function AccessSection({
|
||||||
<span className="muted-text">Клик по ячейке открывает назначение</span>
|
<span className="muted-text">Клик по ячейке открывает назначение</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="matrix-scroll">
|
<div className="matrix-scroll">
|
||||||
<table>
|
<div className="access-matrix-grid" style={{ gridTemplateColumns: accessGridTemplateColumns }} role="table">
|
||||||
<thead>
|
<div className="access-grid-head access-grid-sticky" role="columnheader">
|
||||||
<tr>
|
Участник
|
||||||
<th>Участник</th>
|
</div>
|
||||||
{matrix.services.map((service) => (
|
<div className="access-grid-head" role="columnheader">
|
||||||
<th key={service.id}>{service.title}</th>
|
MAIN
|
||||||
))}
|
</div>
|
||||||
</tr>
|
<div className="access-grid-head" role="columnheader">
|
||||||
</thead>
|
MAIN ROLE
|
||||||
<tbody>
|
</div>
|
||||||
{matrix.users.map((user) => (
|
{matrix.services.map((service) => (
|
||||||
<tr key={user.id}>
|
<div key={service.id} className="access-grid-head" role="columnheader">
|
||||||
<td>
|
{service.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{matrix.users.map((user) => {
|
||||||
|
const membership = data.memberships.find((item) => item.clientId === matrix.client.id && item.userId === user.id);
|
||||||
|
if (!membership) return null;
|
||||||
|
|
||||||
|
const protectedUser = user.id === "user_root";
|
||||||
|
const pendingKey = `${matrix.client.id}:${user.id}`;
|
||||||
|
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
|
||||||
|
const forcedTaskManagerAdmin = membership.role === "client_owner";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={user.id}>
|
||||||
|
<div className="access-grid-cell access-grid-sticky access-user-cell" role="rowheader">
|
||||||
<strong>{user.name}</strong>
|
<strong>{user.name}</strong>
|
||||||
<small>{user.email}</small>
|
<small>{user.email}</small>
|
||||||
</td>
|
</div>
|
||||||
|
<div className="access-grid-cell" role="cell">
|
||||||
|
<MainStatusControl
|
||||||
|
value={user.globalStatus}
|
||||||
|
protectedUser={protectedUser}
|
||||||
|
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="access-grid-cell" role="cell">
|
||||||
|
<MainRoleControl
|
||||||
|
value={membership.role}
|
||||||
|
protectedUser={protectedUser}
|
||||||
|
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{matrix.services.map((service) => {
|
{matrix.services.map((service) => {
|
||||||
const cell = matrix.cells.find((item) => item.userId === user.id && item.serviceId === service.id)!;
|
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 active = selectedCell?.userId === user.id && selectedCell.serviceId === service.id;
|
||||||
|
const isTaskManagerService = isOperationalCoreService(service);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td key={service.id}>
|
<div key={service.id} className="access-grid-cell" role="cell">
|
||||||
<AccessCellControl
|
<AccessCellControl
|
||||||
cell={cell}
|
cell={cell}
|
||||||
active={active}
|
active={active}
|
||||||
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
|
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
|
||||||
|
busy={isTaskManagerService && pendingTaskerAssignment}
|
||||||
onSelectCell={onSelectCell}
|
onSelectCell={onSelectCell}
|
||||||
onSetAccess={(value) => onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value })}
|
onSetAccess={(value) => {
|
||||||
|
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
|
||||||
|
|
||||||
|
if (!isTaskManagerService || !taskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
|
||||||
|
|
||||||
|
onSetTaskManagerWorkspaceMemberRole({
|
||||||
|
clientId: matrix.client.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: accessAssignmentToTaskManagerRole(value),
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</Fragment>
|
||||||
))}
|
);
|
||||||
</tbody>
|
})}
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
<GlassSurface className="access-explanation">
|
<GlassSurface className="access-explanation">
|
||||||
<p className="eyebrow">Explanation panel</p>
|
<p className="eyebrow">Explanation panel</p>
|
||||||
<h3>
|
{selectedCell && selectedUser && selectedService ? (
|
||||||
{selectedUser.name} / {selectedService.title}
|
<>
|
||||||
</h3>
|
<h3>
|
||||||
<div className="explanation-stack">
|
{selectedUser.name} / {selectedService.title}
|
||||||
<InfoLine label="Итог" value={selectedCell.effectiveAccess.allowed ? "Есть доступ" : "Нет доступа"} />
|
</h3>
|
||||||
<InfoLine label="Можно открыть" value={selectedCell.effectiveAccess.openEnabled ? "Да" : "Нет"} />
|
<div className="explanation-stack">
|
||||||
<InfoLine label="Причина" value={selectedCell.effectiveAccess.reason} />
|
<InfoLine label="Итог" value={selectedCell.effectiveAccess.allowed ? "Есть доступ" : "Нет доступа"} />
|
||||||
<InfoLine label="Источник" value={sourceLabel(selectedCell.effectiveAccess.source)} />
|
<InfoLine label="Можно открыть" value={selectedCell.effectiveAccess.openEnabled ? "Да" : "Нет"} />
|
||||||
<InfoLine label="Роль" value={selectedCell.effectiveAccess.appRole ?? "—"} />
|
<InfoLine label="Причина" value={selectedCell.effectiveAccess.reason} />
|
||||||
</div>
|
<InfoLine label="Источник" value={sourceLabel(selectedCell.effectiveAccess.source)} />
|
||||||
|
<InfoLine label="Роль" value={selectedCell.effectiveAccess.appRole ?? "—"} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3>Сервис не выбран</h3>
|
||||||
|
<div className="explanation-stack">
|
||||||
|
<InfoLine label="Итог" value="Выберите сервисную ячейку" />
|
||||||
|
<InfoLine label="MAIN" value="Базовые роли и статусы применяются сразу" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MainStatusControl({
|
||||||
|
value,
|
||||||
|
protectedUser,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: LauncherUserStatus;
|
||||||
|
protectedUser: boolean;
|
||||||
|
onChange: (value: LauncherUserStatus) => void;
|
||||||
|
}) {
|
||||||
|
const label = mainStatusLabel(value);
|
||||||
|
const allowed = value === "active";
|
||||||
|
|
||||||
|
if (protectedUser) {
|
||||||
|
return (
|
||||||
|
<span className={cn("access-cell access-cell--main access-cell--readonly", allowed ? "access-cell--allowed" : "access-cell--exception")}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<span>MAIN</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeDcSelect
|
||||||
|
value={value}
|
||||||
|
options={mainStatusOptions}
|
||||||
|
label="MAIN статус"
|
||||||
|
minMenuWidth={172}
|
||||||
|
menuClassName="access-cell-menu"
|
||||||
|
onChange={onChange}
|
||||||
|
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
|
||||||
|
<button
|
||||||
|
ref={setTriggerRef}
|
||||||
|
className={cn("access-cell access-cell--main", value === "active" ? "access-cell--allowed" : "access-cell--exception")}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<strong>{selectedOption.label}</strong>
|
||||||
|
<span>MAIN</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainRoleControl({
|
||||||
|
value,
|
||||||
|
protectedUser,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: ClientMembershipRole;
|
||||||
|
protectedUser: boolean;
|
||||||
|
onChange: (value: ClientMembershipRole) => void;
|
||||||
|
}) {
|
||||||
|
const label = membershipRoleLabel(value);
|
||||||
|
|
||||||
|
if (protectedUser) {
|
||||||
|
return (
|
||||||
|
<span className="access-cell access-cell--main access-cell--allowed access-cell--readonly">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<span>MAIN роль</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeDcSelect
|
||||||
|
value={value}
|
||||||
|
options={membershipRoleOptions}
|
||||||
|
label="MAIN роль"
|
||||||
|
minMenuWidth={198}
|
||||||
|
menuClassName="access-cell-menu"
|
||||||
|
onChange={onChange}
|
||||||
|
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
|
||||||
|
<button ref={setTriggerRef} className="access-cell access-cell--main access-cell--allowed" type="button" aria-expanded={open} onClick={toggle}>
|
||||||
|
<strong>{selectedOption.label}</strong>
|
||||||
|
<span>MAIN роль</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AccessCellControl({
|
function AccessCellControl({
|
||||||
cell,
|
cell,
|
||||||
active,
|
active,
|
||||||
pendingValue,
|
pendingValue,
|
||||||
|
busy = false,
|
||||||
onSelectCell,
|
onSelectCell,
|
||||||
onSetAccess,
|
onSetAccess,
|
||||||
}: {
|
}: {
|
||||||
cell: AccessMatrixCell;
|
cell: AccessMatrixCell;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
pendingValue?: AccessAssignmentValue;
|
pendingValue?: AccessAssignmentValue;
|
||||||
|
busy?: boolean;
|
||||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||||
onSetAccess: (value: AccessAssignmentValue) => void;
|
onSetAccess: (value: AccessAssignmentValue) => void;
|
||||||
}) {
|
}) {
|
||||||
const isPending = pendingValue !== undefined;
|
const isPending = pendingValue !== undefined || busy;
|
||||||
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
|
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -2401,13 +2734,19 @@ function MiscSection({
|
||||||
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
|
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
|
||||||
|
const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState(
|
||||||
|
data.settings.taskManager.workspaceCreationPolicy
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogoLinkUrl(data.settings.brand.logoLinkUrl);
|
setLogoLinkUrl(data.settings.brand.logoLinkUrl);
|
||||||
}, [data.settings.brand.logoLinkUrl]);
|
setWorkspaceCreationPolicy(data.settings.taskManager.workspaceCreationPolicy);
|
||||||
|
}, [data.settings.brand.logoLinkUrl, data.settings.taskManager.workspaceCreationPolicy]);
|
||||||
|
|
||||||
const normalizedLogoLinkUrl = logoLinkUrl.trim() || "/";
|
const normalizedLogoLinkUrl = logoLinkUrl.trim() || "/";
|
||||||
const hasChanges = normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl;
|
const hasChanges =
|
||||||
|
normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl ||
|
||||||
|
workspaceCreationPolicy !== data.settings.taskManager.workspaceCreationPolicy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassSurface className="table-shell admin-settings-panel">
|
<GlassSurface className="table-shell admin-settings-panel">
|
||||||
|
|
@ -2423,7 +2762,12 @@ function MiscSection({
|
||||||
type="button"
|
type="button"
|
||||||
icon={<Save size={16} />}
|
icon={<Save size={16} />}
|
||||||
disabled={!hasChanges}
|
disabled={!hasChanges}
|
||||||
onClick={() => onUpdateSettings({ brand: { logoLinkUrl: normalizedLogoLinkUrl } })}
|
onClick={() =>
|
||||||
|
onUpdateSettings({
|
||||||
|
brand: { logoLinkUrl: normalizedLogoLinkUrl },
|
||||||
|
taskManager: { workspaceCreationPolicy },
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -2440,6 +2784,18 @@ function MiscSection({
|
||||||
/>
|
/>
|
||||||
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
|
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="admin-settings-field">
|
||||||
|
<span>Operational Core: создание workspace</span>
|
||||||
|
<NodeDcSelect
|
||||||
|
className="admin-modal-select-wrap"
|
||||||
|
triggerClassName="admin-modal-select-trigger"
|
||||||
|
value={workspaceCreationPolicy}
|
||||||
|
options={taskManagerWorkspacePolicyOptions}
|
||||||
|
label="Политика создания workspace в Operational Core"
|
||||||
|
onChange={(value) => setWorkspaceCreationPolicy(value)}
|
||||||
|
/>
|
||||||
|
<small>Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.</small>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
);
|
);
|
||||||
|
|
@ -2531,6 +2887,16 @@ function accessCellKey(userId: string, serviceId: string): string {
|
||||||
return `${userId}:${serviceId}`;
|
return `${userId}:${serviceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOperationalCoreService(service: Service): boolean {
|
||||||
|
return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager";
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessAssignmentToTaskManagerRole(value: AccessAssignmentValue): TaskManagerWorkspaceMemberRole {
|
||||||
|
if (value === "admin" || value === "member") return value;
|
||||||
|
if (value === "viewer") return "guest";
|
||||||
|
return "unset";
|
||||||
|
}
|
||||||
|
|
||||||
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
|
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
|
||||||
if (!source) return "—";
|
if (!source) return "—";
|
||||||
const labels = {
|
const labels = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue