ФУНКЦИИ - NODEDC LAUNCHER: управление пользователями платформы
This commit is contained in:
parent
8be33c53da
commit
2b34cf9f1b
|
|
@ -144,19 +144,6 @@
|
||||||
"createdAt": "2026-05-09T11:33:58.892Z",
|
"createdAt": "2026-05-09T11:33:58.892Z",
|
||||||
"updatedAt": "2026-05-09T12:34:39.358Z"
|
"updatedAt": "2026-05-09T12:34:39.358Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "user_alah_gmail_com",
|
|
||||||
"authentikUserId": "1a3e1273-8d77-4747-947a-295f8ac89418",
|
|
||||||
"name": "ALAH",
|
|
||||||
"email": "alah@gmail.com",
|
|
||||||
"phone": null,
|
|
||||||
"position": null,
|
|
||||||
"notes": "Создан через публичную регистрацию по инвайту клиента Открытый контур.",
|
|
||||||
"avatarUrl": null,
|
|
||||||
"globalStatus": "active",
|
|
||||||
"createdAt": "2026-05-09T17:26:58.823Z",
|
|
||||||
"updatedAt": "2026-05-09T18:58:28.494Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "user_pupa_mail_ru",
|
"id": "user_pupa_mail_ru",
|
||||||
"authentikUserId": "a2a1b489-f492-45a0-a5bd-04f6c1ede80d",
|
"authentikUserId": "a2a1b489-f492-45a0-a5bd-04f6c1ede80d",
|
||||||
|
|
@ -171,17 +158,17 @@
|
||||||
"updatedAt": "2026-05-09T19:37:43.533Z"
|
"updatedAt": "2026-05-09T19:37:43.533Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "user_alla_mail_ru",
|
"id": "user_realla_mail_ru",
|
||||||
"authentikUserId": "216bb9c1-112e-4c5b-bcc3-a6a516955759",
|
"authentikUserId": "95b13198-08d8-4674-afb9-03bf45b5f6da",
|
||||||
"email": "alla@mail.ru",
|
"name": "АВ",
|
||||||
"name": "Абрамова Алла В",
|
"email": "realla@mail.ru",
|
||||||
"phone": "+79856118477",
|
"phone": null,
|
||||||
"position": null,
|
"position": null,
|
||||||
"notes": "Public access request: СВК",
|
"notes": "Создан через публичную регистрацию по инвайту клиента Открытый контур.",
|
||||||
"avatarUrl": null,
|
"avatarUrl": null,
|
||||||
"globalStatus": "active",
|
"globalStatus": "active",
|
||||||
"createdAt": "2026-05-10T14:10:54.544Z",
|
"createdAt": "2026-05-11T09:16:35.282Z",
|
||||||
"updatedAt": "2026-05-10T14:10:55.837Z"
|
"updatedAt": "2026-05-11T09:16:35.291Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"memberships": [
|
"memberships": [
|
||||||
|
|
@ -257,19 +244,6 @@
|
||||||
"createdAt": "2026-05-09T11:33:58.892Z",
|
"createdAt": "2026-05-09T11:33:58.892Z",
|
||||||
"updatedAt": "2026-05-09T11:33:58.892Z"
|
"updatedAt": "2026-05-09T11:33:58.892Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "mem_client_public_pool_alah_gmail_com",
|
|
||||||
"clientId": "client_public_pool",
|
|
||||||
"userId": "user_alah_gmail_com",
|
|
||||||
"role": "member",
|
|
||||||
"status": "active",
|
|
||||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
|
||||||
"inviteId": "invite_ayoyoyo_alah_gmail_com_2",
|
|
||||||
"source": "tasker_workspace_invite",
|
|
||||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com_2",
|
|
||||||
"createdAt": "2026-05-09T17:26:58.823Z",
|
|
||||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "mem_client_public_pool_pupa_mail_ru",
|
"id": "mem_client_public_pool_pupa_mail_ru",
|
||||||
"clientId": "client_public_pool",
|
"clientId": "client_public_pool",
|
||||||
|
|
@ -284,17 +258,17 @@
|
||||||
"updatedAt": "2026-05-09T19:37:43.521Z"
|
"updatedAt": "2026-05-09T19:37:43.521Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mem_client_public_pool_user_alla_mail_ru",
|
"id": "mem_client_public_pool_realla_mail_ru",
|
||||||
"clientId": "client_public_pool",
|
"clientId": "client_public_pool",
|
||||||
"userId": "user_alla_mail_ru",
|
"userId": "user_realla_mail_ru",
|
||||||
"role": "member",
|
"role": "member",
|
||||||
"status": "disabled",
|
"status": "active",
|
||||||
"invitedByUserId": null,
|
"invitedByUserId": "user_root",
|
||||||
"inviteId": null,
|
"inviteId": "invite_realla_mail_ru",
|
||||||
"source": "access_request",
|
"source": "access_request",
|
||||||
"sourceTaskerInviteRequestId": null,
|
"sourceTaskerInviteRequestId": null,
|
||||||
"createdAt": "2026-05-10T14:10:54.544Z",
|
"createdAt": "2026-05-11T09:16:35.282Z",
|
||||||
"updatedAt": "2026-05-10T14:10:54.544Z"
|
"updatedAt": "2026-05-11T09:16:35.282Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
|
|
@ -542,16 +516,6 @@
|
||||||
"createdAt": "2026-05-09T12:34:38.766Z",
|
"createdAt": "2026-05-09T12:34:38.766Z",
|
||||||
"updatedAt": "2026-05-09T12:34:38.766Z"
|
"updatedAt": "2026-05-09T12:34:38.766Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "grant_task_manager_user_alah_gmail_com",
|
|
||||||
"serviceId": "service_task_manager",
|
|
||||||
"targetType": "user",
|
|
||||||
"targetId": "user_alah_gmail_com",
|
|
||||||
"appRole": "member",
|
|
||||||
"status": "active",
|
|
||||||
"createdAt": "2026-05-09T18:58:27.834Z",
|
|
||||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "grant_task_manager_user_pupa_mail_ru",
|
"id": "grant_task_manager_user_pupa_mail_ru",
|
||||||
"serviceId": "service_task_manager",
|
"serviceId": "service_task_manager",
|
||||||
|
|
@ -647,60 +611,6 @@
|
||||||
"createdAt": "2026-05-09T11:32:40.370Z",
|
"createdAt": "2026-05-09T11:32:40.370Z",
|
||||||
"updatedAt": "2026-05-09T11:33:58.892Z"
|
"updatedAt": "2026-05-09T11:33:58.892Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "invite_ayoyoyo_alah_mail_ru",
|
|
||||||
"clientId": "client_public_pool",
|
|
||||||
"email": "alah@mail.ru",
|
|
||||||
"role": "member",
|
|
||||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
|
||||||
"source": "tasker_workspace_invite",
|
|
||||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_mail_ru_2",
|
|
||||||
"sourceTaskerInviteId": "bcb82953-f277-4f0c-a61a-1b0791a8a381",
|
|
||||||
"sourceWorkspaceSlug": "ayoyoyo",
|
|
||||||
"sourceWorkspaceName": "AYOYOYO",
|
|
||||||
"sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=bcb82953-f277-4f0c-a61a-1b0791a8a381&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzM2NjcxLjIzNTc3fQ.wyKAbnfPd2NasrAEbmg8KaYo-nOGFKHgC0nERaekuys",
|
|
||||||
"token": "e0726f74-82c0-49d8-bd08-e6df097bde44",
|
|
||||||
"expiresAt": "2026-05-16T17:08:10.886Z",
|
|
||||||
"status": "revoked",
|
|
||||||
"createdAt": "2026-05-09T17:08:10.886Z",
|
|
||||||
"updatedAt": "2026-05-09T17:25:17.778Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "invite_ayoyoyo_alah_gmail_com",
|
|
||||||
"clientId": "client_public_pool",
|
|
||||||
"email": "alah@gmail.com",
|
|
||||||
"role": "member",
|
|
||||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
|
||||||
"source": "tasker_workspace_invite",
|
|
||||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com",
|
|
||||||
"sourceTaskerInviteId": "359913f5-80e6-4772-82f9-19e653cf1147",
|
|
||||||
"sourceWorkspaceSlug": "ayoyoyo",
|
|
||||||
"sourceWorkspaceName": "AYOYOYO",
|
|
||||||
"sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=359913f5-80e6-4772-82f9-19e653cf1147&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNDc1NTQuMzg3MTV9.ECLpdg1RAaCll_GfNZEx1OTI3sf3bfuRxmJShpEZruM",
|
|
||||||
"token": "410184c9-926b-4e6a-8649-3c0178c7d248",
|
|
||||||
"expiresAt": "2026-05-16T17:26:04.673Z",
|
|
||||||
"status": "accepted",
|
|
||||||
"createdAt": "2026-05-09T17:26:04.673Z",
|
|
||||||
"updatedAt": "2026-05-09T17:26:58.823Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "invite_ayoyoyo_alah_gmail_com_2",
|
|
||||||
"clientId": "client_public_pool",
|
|
||||||
"email": "alah@gmail.com",
|
|
||||||
"role": "member",
|
|
||||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
|
||||||
"source": "tasker_workspace_invite",
|
|
||||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com_2",
|
|
||||||
"sourceTaskerInviteId": "c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c",
|
|
||||||
"sourceWorkspaceSlug": "ayoyoyo",
|
|
||||||
"sourceWorkspaceName": "AYOYOYO",
|
|
||||||
"sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNTMwNTIuMzYxODc5fQ.NtsQ5-48bXpOCgU7Z4u4QxLHZZvgiWVXtJ6e_yUrpfg",
|
|
||||||
"token": "c8ca208c-7a8b-4d32-8559-81bf6fd360d9",
|
|
||||||
"expiresAt": "2026-05-16T18:58:00.118Z",
|
|
||||||
"status": "accepted",
|
|
||||||
"createdAt": "2026-05-09T18:58:00.118Z",
|
|
||||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "invite_ayoyoyo_pupa_mail_ru",
|
"id": "invite_ayoyoyo_pupa_mail_ru",
|
||||||
"clientId": "client_public_pool",
|
"clientId": "client_public_pool",
|
||||||
|
|
@ -732,9 +642,9 @@
|
||||||
"sourceWorkspaceName": null,
|
"sourceWorkspaceName": null,
|
||||||
"token": "02bbfc59-0bc4-4eb0-8128-46eabee23a46",
|
"token": "02bbfc59-0bc4-4eb0-8128-46eabee23a46",
|
||||||
"expiresAt": "2026-05-17T13:18:41.837Z",
|
"expiresAt": "2026-05-17T13:18:41.837Z",
|
||||||
"status": "created",
|
"status": "accepted",
|
||||||
"createdAt": "2026-05-10T13:18:41.837Z",
|
"createdAt": "2026-05-10T13:18:41.837Z",
|
||||||
"updatedAt": "2026-05-10T13:18:41.837Z"
|
"updatedAt": "2026-05-11T09:16:35.282Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"syncStatuses": [
|
"syncStatuses": [
|
||||||
|
|
@ -1024,17 +934,6 @@
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
"updatedAt": "2026-05-09T18:58:27.834Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "sync_user_user_alah_gmail_com",
|
|
||||||
"objectId": "user_alah_gmail_com",
|
|
||||||
"objectName": "alah@gmail.com",
|
|
||||||
"objectType": "user",
|
|
||||||
"target": "authentik",
|
|
||||||
"state": "synced",
|
|
||||||
"lastSyncAt": "2026-05-09T18:58:28.494Z",
|
|
||||||
"error": null,
|
|
||||||
"updatedAt": "2026-05-09T18:58:28.494Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "sync_grant_service_task_manager_user_pupa_mail_ru",
|
"id": "sync_grant_service_task_manager_user_pupa_mail_ru",
|
||||||
"objectId": "service_task_manager:user_pupa_mail_ru",
|
"objectId": "service_task_manager:user_pupa_mail_ru",
|
||||||
|
|
@ -1066,18 +965,29 @@
|
||||||
"state": "pending",
|
"state": "pending",
|
||||||
"lastSyncAt": null,
|
"lastSyncAt": null,
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-10T13:18:41.837Z"
|
"updatedAt": "2026-05-11T09:11:46.244Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sync_user_user_alla_mail_ru",
|
"id": "sync_grant_service_task_manager_user_alla_mail_ru",
|
||||||
"objectId": "user_alla_mail_ru",
|
"objectId": "service_task_manager:user_alla_mail_ru",
|
||||||
"objectName": "alla@mail.ru",
|
"objectName": "task-manager:alla@mail.ru",
|
||||||
|
"objectType": "grant",
|
||||||
|
"target": "authentik",
|
||||||
|
"state": "pending",
|
||||||
|
"lastSyncAt": null,
|
||||||
|
"error": null,
|
||||||
|
"updatedAt": "2026-05-10T18:48:11.279Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sync_user_user_realla_mail_ru",
|
||||||
|
"objectId": "user_realla_mail_ru",
|
||||||
|
"objectName": "realla@mail.ru",
|
||||||
"objectType": "user",
|
"objectType": "user",
|
||||||
"target": "authentik",
|
"target": "authentik",
|
||||||
"state": "synced",
|
"state": "synced",
|
||||||
"lastSyncAt": "2026-05-10T14:10:55.837Z",
|
"lastSyncAt": "2026-05-11T09:16:35.291Z",
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-10T14:10:55.837Z"
|
"updatedAt": "2026-05-11T09:16:35.291Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auditEvents": [
|
"auditEvents": [
|
||||||
|
|
@ -3851,6 +3761,186 @@
|
||||||
"clientId": "client_2",
|
"clientId": "client_2",
|
||||||
"result": "warning",
|
"result": "warning",
|
||||||
"details": null
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alla_mail_ru_3",
|
||||||
|
"at": "2026-05-10T18:47:35.011Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Подтверждена публичная заявка",
|
||||||
|
"objectType": "access_request",
|
||||||
|
"objectName": "alla@mail.ru",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Account activated; target: Открытый контур; role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alla_mail_ru_4",
|
||||||
|
"at": "2026-05-10T18:47:35.324Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alla@mail.ru",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alla_mail_ru_task_manager",
|
||||||
|
"at": "2026-05-10T18:48:11.279Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлён доступ пользователя к сервису",
|
||||||
|
"objectType": "grant",
|
||||||
|
"objectName": "alla@mail.ru / task-manager",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Value: admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alla_mail_ru_5",
|
||||||
|
"at": "2026-05-10T18:48:11.539Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alla@mail.ru",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, nodedc:taskmanager:admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alah_gmail_com_8",
|
||||||
|
"at": "2026-05-10T19:26:16.218Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлён инвайт",
|
||||||
|
"objectType": "invite",
|
||||||
|
"objectName": "alah@gmail.com",
|
||||||
|
"clientId": "client_public_pool",
|
||||||
|
"result": "success",
|
||||||
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alah_gmail_com_9",
|
||||||
|
"at": "2026-05-10T19:59:32.745Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлён профиль пользователя",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alah@gmail.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alah_gmail_com_10",
|
||||||
|
"at": "2026-05-10T19:59:33.312Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alah@gmail.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alah_gmail_com_11",
|
||||||
|
"at": "2026-05-10T20:00:08.117Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлён профиль пользователя",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alah@gmail.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alah_gmail_com_12",
|
||||||
|
"at": "2026-05-10T20:00:08.375Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alah@gmail.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alah_mail_ru_2",
|
||||||
|
"at": "2026-05-10T20:03:28.324Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Удалён инвайт",
|
||||||
|
"objectType": "invite",
|
||||||
|
"objectName": "alah@mail.ru",
|
||||||
|
"clientId": "client_public_pool",
|
||||||
|
"result": "warning",
|
||||||
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alah_gmail_com_13",
|
||||||
|
"at": "2026-05-11T07:44:22.481Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь удалён полностью",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alah@gmail.com",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "warning",
|
||||||
|
"details": "Удалены профиль, членства: 1, инвайты: 2, заявки: 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_alla_mail_ru_6",
|
||||||
|
"at": "2026-05-11T09:11:05.918Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Пользователь удалён полностью",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "alla@mail.ru",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "warning",
|
||||||
|
"details": "Удалены профиль, членства: 1, инвайты: 0, заявки: 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_realla_mail_ru_3",
|
||||||
|
"at": "2026-05-11T09:11:46.244Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлён инвайт",
|
||||||
|
"objectType": "invite",
|
||||||
|
"objectName": "realla@mail.ru",
|
||||||
|
"clientId": "client_public_pool",
|
||||||
|
"result": "success",
|
||||||
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_realla_mail_ru_4",
|
||||||
|
"at": "2026-05-11T09:16:35.282Z",
|
||||||
|
"actorUserId": "user_realla_mail_ru",
|
||||||
|
"actorName": "АВ",
|
||||||
|
"action": "Регистрация по инвайту",
|
||||||
|
"objectType": "invite",
|
||||||
|
"objectName": "realla@mail.ru",
|
||||||
|
"clientId": "client_public_pool",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Role: member"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_realla_mail_ru_5",
|
||||||
|
"at": "2026-05-11T09:16:35.291Z",
|
||||||
|
"actorUserId": "user_realla_mail_ru",
|
||||||
|
"actorName": "АВ",
|
||||||
|
"action": "Пользователь синхронизирован в Authentik",
|
||||||
|
"objectType": "user",
|
||||||
|
"objectName": "realla@mail.ru",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Groups: nodedc:launcher:user"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
@ -4062,24 +4152,6 @@
|
||||||
"comment": null,
|
"comment": null,
|
||||||
"createdAt": "2026-05-10T13:18:08.047Z",
|
"createdAt": "2026-05-10T13:18:08.047Z",
|
||||||
"updatedAt": "2026-05-10T13:18:41.837Z"
|
"updatedAt": "2026-05-10T13:18:41.837Z"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "access_request_alla_mail_ru",
|
|
||||||
"email": "alla@mail.ru",
|
|
||||||
"firstName": "Алла",
|
|
||||||
"lastName": "Абрамова",
|
|
||||||
"middleName": "В",
|
|
||||||
"phone": "+79856118477",
|
|
||||||
"company": "СВК",
|
|
||||||
"status": "new",
|
|
||||||
"targetClientId": "client_public_pool",
|
|
||||||
"role": "member",
|
|
||||||
"approvedInviteId": null,
|
|
||||||
"reviewedByUserId": null,
|
|
||||||
"reviewedAt": null,
|
|
||||||
"comment": null,
|
|
||||||
"createdAt": "2026-05-10T14:10:54.544Z",
|
|
||||||
"updatedAt": "2026-05-10T14:10:54.544Z"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"taskerInviteRequests": [
|
"taskerInviteRequests": [
|
||||||
|
|
@ -4127,50 +4199,6 @@
|
||||||
"createdAt": "2026-05-09T14:24:31.256Z",
|
"createdAt": "2026-05-09T14:24:31.256Z",
|
||||||
"updatedAt": "2026-05-09T17:25:17.778Z"
|
"updatedAt": "2026-05-09T17:25:17.778Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "tasker_invite_request_ayoyoyo_alah_gmail_com",
|
|
||||||
"taskerInviteId": "359913f5-80e6-4772-82f9-19e653cf1147",
|
|
||||||
"workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2",
|
|
||||||
"workspaceSlug": "ayoyoyo",
|
|
||||||
"workspaceName": "AYOYOYO",
|
|
||||||
"inviteeEmail": "alah@gmail.com",
|
|
||||||
"role": "member",
|
|
||||||
"inviterUserId": "user_ayo_ayo_ae_gmail_com",
|
|
||||||
"inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39",
|
|
||||||
"inviterEmail": "ayo.ayo.ae@gmail.com",
|
|
||||||
"inviterName": "Anna Ayo",
|
|
||||||
"status": "cancelled",
|
|
||||||
"taskerInviteLink": null,
|
|
||||||
"platformInviteId": "invite_ayoyoyo_alah_gmail_com",
|
|
||||||
"platformInviteToken": "410184c9-926b-4e6a-8649-3c0178c7d248",
|
|
||||||
"reviewedByUserId": "user_root",
|
|
||||||
"reviewedAt": "2026-05-09T17:26:04.726Z",
|
|
||||||
"comment": "Пользователь удалён из workspace Operational Core.",
|
|
||||||
"createdAt": "2026-05-09T17:25:54.405Z",
|
|
||||||
"updatedAt": "2026-05-09T18:22:48.238Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tasker_invite_request_ayoyoyo_alah_gmail_com_2",
|
|
||||||
"taskerInviteId": "c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c",
|
|
||||||
"workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2",
|
|
||||||
"workspaceSlug": "ayoyoyo",
|
|
||||||
"workspaceName": "AYOYOYO",
|
|
||||||
"inviteeEmail": "alah@gmail.com",
|
|
||||||
"role": "member",
|
|
||||||
"inviterUserId": "user_ayo_ayo_ae_gmail_com",
|
|
||||||
"inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39",
|
|
||||||
"inviterEmail": "ayo.ayo.ae@gmail.com",
|
|
||||||
"inviterName": "Anna Ayo",
|
|
||||||
"status": "approved",
|
|
||||||
"taskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNTMwNTIuMzYxODc5fQ.NtsQ5-48bXpOCgU7Z4u4QxLHZZvgiWVXtJ6e_yUrpfg",
|
|
||||||
"platformInviteId": "invite_ayoyoyo_alah_gmail_com_2",
|
|
||||||
"platformInviteToken": "c8ca208c-7a8b-4d32-8559-81bf6fd360d9",
|
|
||||||
"reviewedByUserId": "user_root",
|
|
||||||
"reviewedAt": "2026-05-09T18:58:00.166Z",
|
|
||||||
"comment": null,
|
|
||||||
"createdAt": "2026-05-09T18:57:32.379Z",
|
|
||||||
"updatedAt": "2026-05-09T18:58:00.166Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "tasker_invite_request_ayoyoyo_pupa_mail_ru",
|
"id": "tasker_invite_request_ayoyoyo_pupa_mail_ru",
|
||||||
"taskerInviteId": "9b470198-5685-4ebf-b330-75b357363e97",
|
"taskerInviteId": "9b470198-5685-4ebf-b330-75b357363e97",
|
||||||
|
|
@ -4193,5 +4221,21 @@
|
||||||
"createdAt": "2026-05-09T19:36:19.353Z",
|
"createdAt": "2026-05-09T19:36:19.353Z",
|
||||||
"updatedAt": "2026-05-09T19:36:41.169Z"
|
"updatedAt": "2026-05-09T19:36:41.169Z"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"revokedAccounts": [
|
||||||
|
{
|
||||||
|
"id": "revoked_account_alla_mail_ru",
|
||||||
|
"email": "alla@mail.ru",
|
||||||
|
"name": "Абрамова Алла В",
|
||||||
|
"sourceUserId": "user_alla_mail_ru",
|
||||||
|
"authentikUserId": "216bb9c1-112e-4c5b-bcc3-a6a516955759",
|
||||||
|
"reason": "hard_deleted",
|
||||||
|
"revokedByUserId": "user_root",
|
||||||
|
"revokedByUserEmail": "dcctouch@gmail.com",
|
||||||
|
"revokedByUserName": "DC SUDO",
|
||||||
|
"revokedAt": "2026-05-11T09:11:05.917Z",
|
||||||
|
"createdAt": "2026-05-11T09:11:05.917Z",
|
||||||
|
"updatedAt": "2026-05-11T09:11:05.917Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,31 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteUser({ data, userId }) {
|
||||||
|
ensureConfigured();
|
||||||
|
|
||||||
|
const user = findById(data.users, userId, "user");
|
||||||
|
const existingUser = await findUserByIdOrEmail(user.authentikUserId, user.email);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
return {
|
||||||
|
deleted: false,
|
||||||
|
email: user.email,
|
||||||
|
authentikUserId: user.authentikUserId ?? null,
|
||||||
|
authentikPk: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestJson(`/api/v3/core/users/${encodeURIComponent(existingUser.pk)}/`, { method: "DELETE" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: true,
|
||||||
|
email: user.email,
|
||||||
|
authentikUserId: user.authentikUserId ?? null,
|
||||||
|
authentikPk: existingUser.pk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function findUserByIdOrEmail(authentikUserId, email) {
|
async function findUserByIdOrEmail(authentikUserId, email) {
|
||||||
if (authentikUserId) {
|
if (authentikUserId) {
|
||||||
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`);
|
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`);
|
||||||
|
|
@ -155,6 +180,7 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
deleteUser,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
provisionUser,
|
provisionUser,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const collectionKeys = [
|
||||||
"exceptions",
|
"exceptions",
|
||||||
"invites",
|
"invites",
|
||||||
"accessRequests",
|
"accessRequests",
|
||||||
|
"revokedAccounts",
|
||||||
"taskerInviteRequests",
|
"taskerInviteRequests",
|
||||||
"syncStatuses",
|
"syncStatuses",
|
||||||
"auditEvents",
|
"auditEvents",
|
||||||
|
|
@ -473,6 +474,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearRevokedAccount(data, email);
|
||||||
addAuditEvent(data, actor, {
|
addAuditEvent(data, actor, {
|
||||||
action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь",
|
action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь",
|
||||||
objectType: "user",
|
objectType: "user",
|
||||||
|
|
@ -541,6 +543,93 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
return { membership, data };
|
return { membership, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteUser(userId, identity) {
|
||||||
|
const data = readData();
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
const user = findById(data.users, userId, "user");
|
||||||
|
|
||||||
|
if (user.id === "user_root") {
|
||||||
|
throw new Error("Системного root-пользователя нельзя удалить");
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
|
const removedMembershipIds = new Set(data.memberships.filter((membership) => membership.userId === user.id).map((membership) => membership.id));
|
||||||
|
const removedInviteIds = new Set(
|
||||||
|
data.invites
|
||||||
|
.filter((invite) => invite.email.toLowerCase() === email)
|
||||||
|
.map((invite) => invite.id)
|
||||||
|
);
|
||||||
|
const removedAccessRequestIds = new Set(
|
||||||
|
data.accessRequests
|
||||||
|
.filter((request) => request.email.toLowerCase() === email)
|
||||||
|
.map((request) => request.id)
|
||||||
|
);
|
||||||
|
const removedTaskerInviteRequestIds = new Set(
|
||||||
|
data.taskerInviteRequests
|
||||||
|
.filter(
|
||||||
|
(request) =>
|
||||||
|
request.inviteeEmail.toLowerCase() === email ||
|
||||||
|
request.inviterEmail.toLowerCase() === email ||
|
||||||
|
request.inviterUserId === user.id
|
||||||
|
)
|
||||||
|
.map((request) => request.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
upsertRevokedAccount(data, user, actor);
|
||||||
|
data.users = data.users.filter((candidate) => candidate.id !== user.id);
|
||||||
|
data.memberships = data.memberships.filter((membership) => membership.userId !== user.id);
|
||||||
|
data.groups = data.groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
memberIds: group.memberIds.filter((memberId) => memberId !== user.id),
|
||||||
|
updatedAt: group.memberIds.includes(user.id) ? isoNow() : group.updatedAt,
|
||||||
|
}));
|
||||||
|
data.grants = data.grants.filter((grant) => !(grant.targetType === "user" && grant.targetId === user.id));
|
||||||
|
data.exceptions = data.exceptions.filter((exception) => exception.userId !== user.id);
|
||||||
|
data.invites = data.invites.filter(
|
||||||
|
(invite) => invite.email.toLowerCase() !== email && invite.invitedByUserId !== user.id
|
||||||
|
);
|
||||||
|
data.accessRequests = data.accessRequests.filter((request) => request.email.toLowerCase() !== email);
|
||||||
|
data.taskerInviteRequests = data.taskerInviteRequests.filter(
|
||||||
|
(request) =>
|
||||||
|
request.inviteeEmail.toLowerCase() !== email &&
|
||||||
|
request.inviterEmail.toLowerCase() !== email &&
|
||||||
|
request.inviterUserId !== user.id
|
||||||
|
);
|
||||||
|
data.taskManagerMemberships = data.taskManagerMemberships.filter((membership) => membership.userId !== user.id);
|
||||||
|
data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter((membership) => membership.userId !== user.id);
|
||||||
|
data.syncStatuses = data.syncStatuses.filter((syncStatus) => {
|
||||||
|
if (syncStatus.objectId === user.id || syncStatus.objectName?.toLowerCase?.() === email) return false;
|
||||||
|
if (removedInviteIds.has(syncStatus.objectId)) return false;
|
||||||
|
if (removedAccessRequestIds.has(syncStatus.objectId)) return false;
|
||||||
|
if (removedTaskerInviteRequestIds.has(syncStatus.objectId)) return false;
|
||||||
|
if (removedMembershipIds.has(syncStatus.objectId)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: "Пользователь удалён полностью",
|
||||||
|
objectType: "user",
|
||||||
|
objectName: email,
|
||||||
|
clientId: null,
|
||||||
|
result: "warning",
|
||||||
|
details: `Удалены профиль, членства: ${removedMembershipIds.size}, инвайты: ${removedInviteIds.size}, заявки: ${
|
||||||
|
removedAccessRequestIds.size + removedTaskerInviteRequestIds.size
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
affected: {
|
||||||
|
memberships: removedMembershipIds.size,
|
||||||
|
invites: removedInviteIds.size,
|
||||||
|
accessRequests: removedAccessRequestIds.size,
|
||||||
|
taskerInviteRequests: removedTaskerInviteRequestIds.size,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createInvite(payload, identity) {
|
async function createInvite(payload, identity) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
|
|
@ -590,6 +679,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
const email = requestPayload.email.toLowerCase();
|
const email = requestPayload.email.toLowerCase();
|
||||||
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
|
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
|
||||||
|
|
||||||
|
if (existingUser?.globalStatus === "blocked") {
|
||||||
|
throw new Error("Аккаунт с этой почтой заблокирован. Обратитесь к администратору NODE.DC.");
|
||||||
|
}
|
||||||
|
|
||||||
if (existingUser?.globalStatus === "active") {
|
if (existingUser?.globalStatus === "active") {
|
||||||
const hasOnlyPendingAccessRequest =
|
const hasOnlyPendingAccessRequest =
|
||||||
data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") &&
|
data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") &&
|
||||||
|
|
@ -608,6 +701,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
|
|
||||||
const user = upsertAccessRequestUser(data, requestPayload, now);
|
const user = upsertAccessRequestUser(data, requestPayload, now);
|
||||||
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
|
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
|
||||||
|
clearRevokedAccount(data, email);
|
||||||
markPendingSync(data, user, "user", user.email);
|
markPendingSync(data, user, "user", user.email);
|
||||||
const existingRequest = data.accessRequests.find(
|
const existingRequest = data.accessRequests.find(
|
||||||
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
|
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
|
||||||
|
|
@ -706,6 +800,11 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
||||||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||||||
|
|
||||||
|
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === accessRequest.email.toLowerCase());
|
||||||
|
if (existingUser?.globalStatus === "blocked") {
|
||||||
|
throw new Error("Заблокированный аккаунт нельзя подтвердить без ручного разблокирования.");
|
||||||
|
}
|
||||||
|
|
||||||
const user = upsertAccessRequestUser(data, accessRequest, now);
|
const user = upsertAccessRequestUser(data, accessRequest, now);
|
||||||
const client = findClientById(data, accessRequest.targetClientId);
|
const client = findClientById(data, accessRequest.targetClientId);
|
||||||
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
|
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
|
||||||
|
|
@ -1108,6 +1207,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||||||
|
|
||||||
|
if (user?.globalStatus === "blocked") {
|
||||||
|
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
|
||||||
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null);
|
user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null);
|
||||||
user.name = optionalString(identity?.name, user.name);
|
user.name = optionalString(identity?.name, user.name);
|
||||||
|
|
@ -1159,6 +1262,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||||||
|
clearRevokedAccount(data, email);
|
||||||
invite.status = "accepted";
|
invite.status = "accepted";
|
||||||
invite.updatedAt = now;
|
invite.updatedAt = now;
|
||||||
|
|
||||||
|
|
@ -1536,6 +1640,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
user.email = optionalString(provisioning.email, user.email).toLowerCase();
|
user.email = optionalString(provisioning.email, user.email).toLowerCase();
|
||||||
user.name = optionalString(provisioning.name, user.name);
|
user.name = optionalString(provisioning.name, user.name);
|
||||||
user.updatedAt = now;
|
user.updatedAt = now;
|
||||||
|
clearRevokedAccount(data, user.email);
|
||||||
|
|
||||||
const syncStatus = data.syncStatuses.find(
|
const syncStatus = data.syncStatuses.find(
|
||||||
(status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id
|
(status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id
|
||||||
|
|
@ -1615,6 +1720,34 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLoginAccountStatus(email) {
|
||||||
|
const data = readData();
|
||||||
|
const normalizedEmail = normalizeEmail(email);
|
||||||
|
|
||||||
|
if (!normalizedEmail) {
|
||||||
|
return { status: "unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = data.users.find((user) => normalizeEmail(user.email) === normalizedEmail);
|
||||||
|
if (existingUser) {
|
||||||
|
return { status: "unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokedAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === normalizedEmail);
|
||||||
|
if (revokedAccount) {
|
||||||
|
return { status: "revoked", revokedAt: revokedAccount.revokedAt ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardDeleteAuditEvent = data.auditEvents.find(
|
||||||
|
(event) =>
|
||||||
|
normalizeEmail(event.objectName) === normalizedEmail &&
|
||||||
|
event.objectType === "user" &&
|
||||||
|
event.action === "Пользователь удалён полностью"
|
||||||
|
);
|
||||||
|
|
||||||
|
return hardDeleteAuditEvent ? { status: "revoked", revokedAt: hardDeleteAuditEvent.at ?? null } : { status: "unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
approveAccessRequest,
|
approveAccessRequest,
|
||||||
approveTaskerInviteRequest,
|
approveTaskerInviteRequest,
|
||||||
|
|
@ -1632,11 +1765,13 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
deleteInvite,
|
deleteInvite,
|
||||||
deleteMembership,
|
deleteMembership,
|
||||||
deleteService,
|
deleteService,
|
||||||
|
deleteUser,
|
||||||
rejectAccessRequest,
|
rejectAccessRequest,
|
||||||
rejectTaskerInviteRequest,
|
rejectTaskerInviteRequest,
|
||||||
acceptInvite,
|
acceptInvite,
|
||||||
commitInviteRegistration,
|
commitInviteRegistration,
|
||||||
getInviteByToken,
|
getInviteByToken,
|
||||||
|
getLoginAccountStatus,
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
ensureTaskerInvitePlatformInvite,
|
ensureTaskerInvitePlatformInvite,
|
||||||
prepareInviteRegistration,
|
prepareInviteRegistration,
|
||||||
|
|
@ -1679,10 +1814,33 @@ function normalizeData(payload) {
|
||||||
integrations: normalizeClientIntegrations(client.integrations),
|
integrations: normalizeClientIntegrations(client.integrations),
|
||||||
}));
|
}));
|
||||||
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
|
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
|
||||||
|
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean);
|
||||||
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
|
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRevokedAccount(payload) {
|
||||||
|
if (typeof payload !== "object" || payload === null) return null;
|
||||||
|
const email = normalizeEmail(payload.email);
|
||||||
|
if (!email) return null;
|
||||||
|
const now = isoNow();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: optionalString(payload.id, `revoked_account_${slugify(email)}`),
|
||||||
|
email,
|
||||||
|
name: nullableStringWithFallback(payload.name, null),
|
||||||
|
sourceUserId: nullableStringWithFallback(payload.sourceUserId, null),
|
||||||
|
authentikUserId: nullableStringWithFallback(payload.authentikUserId, null),
|
||||||
|
reason: optionalString(payload.reason, "hard_deleted"),
|
||||||
|
revokedByUserId: nullableStringWithFallback(payload.revokedByUserId, null),
|
||||||
|
revokedByUserEmail: nullableStringWithFallback(payload.revokedByUserEmail, null),
|
||||||
|
revokedByUserName: nullableStringWithFallback(payload.revokedByUserName, null),
|
||||||
|
revokedAt: optionalString(payload.revokedAt, now),
|
||||||
|
createdAt: optionalString(payload.createdAt, now),
|
||||||
|
updatedAt: optionalString(payload.updatedAt, now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAccessRequest(payload) {
|
function normalizeAccessRequest(payload) {
|
||||||
if (typeof payload !== "object" || payload === null) return null;
|
if (typeof payload !== "object" || payload === null) return null;
|
||||||
const now = isoNow();
|
const now = isoNow();
|
||||||
|
|
@ -1943,6 +2101,44 @@ function addAuditEvent(data, actor, event) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertRevokedAccount(data, user, actor) {
|
||||||
|
const email = normalizeEmail(user.email);
|
||||||
|
if (!email) return null;
|
||||||
|
const now = isoNow();
|
||||||
|
const existingAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === email);
|
||||||
|
const patch = {
|
||||||
|
email,
|
||||||
|
name: user.name ?? null,
|
||||||
|
sourceUserId: user.id,
|
||||||
|
authentikUserId: user.authentikUserId ?? null,
|
||||||
|
reason: "hard_deleted",
|
||||||
|
revokedByUserId: actor.id ?? null,
|
||||||
|
revokedByUserEmail: actor.email ?? null,
|
||||||
|
revokedByUserName: actor.name ?? null,
|
||||||
|
revokedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingAccount) {
|
||||||
|
Object.assign(existingAccount, patch);
|
||||||
|
return existingAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokedAccount = {
|
||||||
|
id: uniqueId(data.revokedAccounts, "revoked_account", email),
|
||||||
|
...patch,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
data.revokedAccounts.push(revokedAccount);
|
||||||
|
return revokedAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRevokedAccount(data, email) {
|
||||||
|
const normalizedEmail = normalizeEmail(email);
|
||||||
|
if (!normalizedEmail) return;
|
||||||
|
data.revokedAccounts = data.revokedAccounts.filter((account) => normalizeEmail(account.email) !== normalizedEmail);
|
||||||
|
}
|
||||||
|
|
||||||
function markPendingSync(data, object, objectType, objectName = object.name ?? object.email ?? object.id) {
|
function markPendingSync(data, object, objectType, objectName = object.name ?? object.email ?? object.id) {
|
||||||
const now = isoNow();
|
const now = isoNow();
|
||||||
const existingStatus = data.syncStatuses.find(
|
const existingStatus = data.syncStatuses.find(
|
||||||
|
|
@ -2182,6 +2378,10 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
|
||||||
|
|
||||||
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||||||
|
|
||||||
|
if (user?.globalStatus === "blocked") {
|
||||||
|
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
|
||||||
|
}
|
||||||
|
|
||||||
if (user?.authentikUserId && !provisioning) {
|
if (user?.authentikUserId && !provisioning) {
|
||||||
throw new Error("Аккаунт уже существует. Войдите под почтой инвайта.");
|
throw new Error("Аккаунт уже существует. Войдите под почтой инвайта.");
|
||||||
}
|
}
|
||||||
|
|
@ -2236,6 +2436,7 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||||||
|
clearRevokedAccount(data, email);
|
||||||
invite.status = "accepted";
|
invite.status = "accepted";
|
||||||
invite.updatedAt = now;
|
invite.updatedAt = now;
|
||||||
markPendingSync(data, user, "user", email);
|
markPendingSync(data, user, "user", email);
|
||||||
|
|
@ -2441,6 +2642,10 @@ function nullableStringWithFallback(value, fallback) {
|
||||||
return value === undefined ? fallback : nullableString(value);
|
return value === undefined ? fallback : nullableString(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(value) {
|
||||||
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
function pickEnum(value, allowedValues, fallback) {
|
function pickEnum(value, allowedValues, fallback) {
|
||||||
return typeof value === "string" && allowedValues.has(value) ? value : fallback;
|
return typeof value === "string" && allowedValues.has(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,14 @@ app.get("/api/public/brand", (_req, res) => {
|
||||||
res.json(buildPublicBrandResponse(snapshot.data.settings));
|
res.json(buildPublicBrandResponse(snapshot.data.settings));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/public/login-account-status", (req, res) => {
|
||||||
|
const email = typeof req.query.email === "string" ? req.query.email : "";
|
||||||
|
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
setNoStore(res);
|
||||||
|
res.json(controlPlaneStore.getLoginAccountStatus(email));
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const password = sanitizeNewPassword(req.body?.password);
|
const password = sanitizeNewPassword(req.body?.password);
|
||||||
|
|
@ -1015,6 +1023,26 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a
|
||||||
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
|
const user = snapshot.data.users.find((candidate) => candidate.id === req.params.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: "user_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authentik = null;
|
||||||
|
|
||||||
|
if (authentikSyncClient.isConfigured()) {
|
||||||
|
authentik = await authentikSyncClient.deleteUser({ data: snapshot.data, userId: req.params.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await controlPlaneStore.deleteUser(req.params.userId, req.nodedcSession.user);
|
||||||
|
publishControlPlaneEvent("admin.user.deleted", [req.params.userId]);
|
||||||
|
res.json({ ...scopeAdminMutationResult(req, result), authentik });
|
||||||
|
}));
|
||||||
|
|
||||||
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)) {
|
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
deleteAdminInvite,
|
deleteAdminInvite,
|
||||||
deleteAdminMembership,
|
deleteAdminMembership,
|
||||||
deleteAdminService,
|
deleteAdminService,
|
||||||
|
deleteAdminUser,
|
||||||
ensureAdminTaskManagerProjectMembership,
|
ensureAdminTaskManagerProjectMembership,
|
||||||
ensureAdminTaskManagerWorkspaceMembership,
|
ensureAdminTaskManagerWorkspaceMembership,
|
||||||
fetchAdminTaskManagerWorkspaces,
|
fetchAdminTaskManagerWorkspaces,
|
||||||
|
|
@ -732,6 +733,10 @@ export function LauncherApp() {
|
||||||
applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
|
applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDeleteUser(userId: string) {
|
||||||
|
applyControlPlaneMutation(deleteAdminUser(userId));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) {
|
async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) {
|
||||||
const result = await updateOwnProfile(patch);
|
const result = await updateOwnProfile(patch);
|
||||||
setData(syncLauncherServiceLinks(result.data));
|
setData(syncLauncherServiceLinks(result.data));
|
||||||
|
|
@ -892,6 +897,7 @@ export function LauncherApp() {
|
||||||
onDeleteClient={handleDeleteClient}
|
onDeleteClient={handleDeleteClient}
|
||||||
onCreateUser={handleCreateUser}
|
onCreateUser={handleCreateUser}
|
||||||
onUpdateUser={handleUpdateUser}
|
onUpdateUser={handleUpdateUser}
|
||||||
|
onDeleteUser={handleDeleteUser}
|
||||||
onUpdateMembership={handleUpdateMembership}
|
onUpdateMembership={handleUpdateMembership}
|
||||||
onDeleteMembership={handleDeleteMembership}
|
onDeleteMembership={handleDeleteMembership}
|
||||||
pendingAccessAssignments={pendingAccessAssignments}
|
pendingAccessAssignments={pendingAccessAssignments}
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,10 @@ export async function updateAdminUserProfile(userId: string, patch: Partial<Laun
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminUser(userId: string): Promise<ControlPlaneMutationResult> {
|
||||||
|
return requestJson<ControlPlaneMutationResult>(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAdminUser(payload: {
|
export async function createAdminUser(payload: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export interface LauncherData {
|
||||||
exceptions: ServiceAccessException[];
|
exceptions: ServiceAccessException[];
|
||||||
invites: Invite[];
|
invites: Invite[];
|
||||||
accessRequests: AccessRequest[];
|
accessRequests: AccessRequest[];
|
||||||
|
revokedAccounts: RevokedAccount[];
|
||||||
taskerInviteRequests: TaskerInviteRequest[];
|
taskerInviteRequests: TaskerInviteRequest[];
|
||||||
syncStatuses: SyncStatus[];
|
syncStatuses: SyncStatus[];
|
||||||
auditEvents: typeof mockAuditEvents;
|
auditEvents: typeof mockAuditEvents;
|
||||||
|
|
@ -72,6 +73,21 @@ export interface LauncherData {
|
||||||
settings: LauncherSettings;
|
settings: LauncherSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RevokedAccount {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
sourceUserId?: string | null;
|
||||||
|
authentikUserId?: string | null;
|
||||||
|
reason: string;
|
||||||
|
revokedByUserId?: string | null;
|
||||||
|
revokedByUserEmail?: string | null;
|
||||||
|
revokedByUserName?: string | null;
|
||||||
|
revokedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskManagerMembershipAssignment {
|
export interface TaskManagerMembershipAssignment {
|
||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|
@ -152,6 +168,7 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({
|
||||||
exceptions: mockExceptions,
|
exceptions: mockExceptions,
|
||||||
invites: mockInvites,
|
invites: mockInvites,
|
||||||
accessRequests: mockAccessRequests,
|
accessRequests: mockAccessRequests,
|
||||||
|
revokedAccounts: [],
|
||||||
taskerInviteRequests: mockTaskerInviteRequests,
|
taskerInviteRequests: mockTaskerInviteRequests,
|
||||||
syncStatuses: mockSyncStatuses,
|
syncStatuses: mockSyncStatuses,
|
||||||
auditEvents: mockAuditEvents,
|
auditEvents: mockAuditEvents,
|
||||||
|
|
@ -199,6 +216,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
||||||
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
||||||
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
||||||
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
||||||
|
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],
|
||||||
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -2336,6 +2336,108 @@ code {
|
||||||
width: 10.2rem;
|
width: 10.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-shell--platform-users {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users {
|
||||||
|
min-width: 82rem;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users th,
|
||||||
|
.admin-data-table--platform-users td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users th:nth-child(1),
|
||||||
|
.admin-data-table--platform-users td:nth-child(1) {
|
||||||
|
width: 17rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users th:nth-child(2),
|
||||||
|
.admin-data-table--platform-users td:nth-child(2) {
|
||||||
|
width: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users th:nth-child(3),
|
||||||
|
.admin-data-table--platform-users td:nth-child(3) {
|
||||||
|
width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users th:nth-child(4),
|
||||||
|
.admin-data-table--platform-users td:nth-child(4) {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users th:nth-child(5),
|
||||||
|
.admin-data-table--platform-users td:nth-child(5) {
|
||||||
|
width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--platform-users th:nth-child(6),
|
||||||
|
.admin-data-table--platform-users td:nth-child(6) {
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-user-origin {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-user-origin strong,
|
||||||
|
.platform-user-origin small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-user-origin strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-user-origin small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--public-access-users {
|
||||||
|
min-width: 86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--public-access-users th:nth-child(1),
|
||||||
|
.admin-data-table--public-access-users td:nth-child(1) {
|
||||||
|
width: 17rem;
|
||||||
|
min-width: 17rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--public-access-users th:nth-child(2),
|
||||||
|
.admin-data-table--public-access-users td:nth-child(2) {
|
||||||
|
width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--public-access-users th:nth-child(3),
|
||||||
|
.admin-data-table--public-access-users td:nth-child(3),
|
||||||
|
.admin-data-table--public-access-users th:nth-child(4),
|
||||||
|
.admin-data-table--public-access-users td:nth-child(4),
|
||||||
|
.admin-data-table--public-access-users th:nth-child(5),
|
||||||
|
.admin-data-table--public-access-users td:nth-child(5) {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--public-access-users th:nth-child(6),
|
||||||
|
.admin-data-table--public-access-users td:nth-child(6) {
|
||||||
|
width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-table--public-access-users .access-cell {
|
||||||
|
max-width: 12.5rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.membership-inviter-cell {
|
.membership-inviter-cell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.18rem;
|
gap: 0.18rem;
|
||||||
|
|
@ -2510,6 +2612,20 @@ code {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-table-text {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-text--strong {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table-input--select {
|
.admin-table-input--select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: rgba(255, 255, 255, 0.045);
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
|
@ -3415,6 +3531,14 @@ code {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-layout--single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-tabs-card {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.matrix-scroll {
|
.matrix-scroll {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-radius: calc(var(--launcher-radius-card) - 0.85rem);
|
border-radius: calc(var(--launcher-radius-card) - 0.85rem);
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export interface EnsureTaskManagerProjectMemberCommand {
|
||||||
const platformSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
const platformSections: 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} /> },
|
||||||
|
{ id: "users", label: "Пользователи", icon: <UsersRound size={16} /> },
|
||||||
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
||||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||||||
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
||||||
|
|
@ -174,6 +175,7 @@ export function AdminOverlay({
|
||||||
onDeleteClient,
|
onDeleteClient,
|
||||||
onCreateUser,
|
onCreateUser,
|
||||||
onUpdateUser,
|
onUpdateUser,
|
||||||
|
onDeleteUser,
|
||||||
onUpdateMembership,
|
onUpdateMembership,
|
||||||
onDeleteMembership,
|
onDeleteMembership,
|
||||||
pendingAccessAssignments,
|
pendingAccessAssignments,
|
||||||
|
|
@ -220,6 +222,7 @@ export function AdminOverlay({
|
||||||
onDeleteClient: (clientId: string) => void;
|
onDeleteClient: (clientId: string) => void;
|
||||||
onCreateUser: (command: CreateUserCommand) => void;
|
onCreateUser: (command: CreateUserCommand) => void;
|
||||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||||
|
onDeleteUser: (userId: string) => void;
|
||||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||||
onDeleteMembership: (membershipId: string) => void;
|
onDeleteMembership: (membershipId: string) => void;
|
||||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||||
|
|
@ -468,12 +471,16 @@ export function AdminOverlay({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "users" ? (
|
{activeSection === "users" ? (
|
||||||
<UsersSection
|
isPlatformMode ? (
|
||||||
data={data}
|
<PlatformUsersSection data={data} onUpdateUser={onUpdateUser} onDeleteUser={onDeleteUser} />
|
||||||
clientId={scopedClientId}
|
) : (
|
||||||
onCreateUser={onCreateUser}
|
<UsersSection
|
||||||
onUpdateUser={onUpdateUser}
|
data={data}
|
||||||
/>
|
clientId={scopedClientId}
|
||||||
|
onCreateUser={onCreateUser}
|
||||||
|
onUpdateUser={onUpdateUser}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "groups" ? (
|
{activeSection === "groups" ? (
|
||||||
<GroupsSection
|
<GroupsSection
|
||||||
|
|
@ -1005,6 +1012,150 @@ function UsersSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlatformUsersSection({
|
||||||
|
data,
|
||||||
|
onUpdateUser,
|
||||||
|
onDeleteUser,
|
||||||
|
}: {
|
||||||
|
data: LauncherData;
|
||||||
|
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||||
|
onDeleteUser: (userId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
|
||||||
|
const deletingUser = data.users.find((user) => user.id === deleteUserId) ?? null;
|
||||||
|
const rows = data.users
|
||||||
|
.map((user) => {
|
||||||
|
const memberships = data.memberships.filter((membership) => membership.userId === user.id);
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
memberships,
|
||||||
|
origin: getPlatformUserOrigin(data, user, memberships),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.user.id === "user_root") return -1;
|
||||||
|
if (right.user.id === "user_root") return 1;
|
||||||
|
return left.origin.label.localeCompare(right.origin.label, "ru") || left.user.name.localeCompare(right.user.name, "ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlassSurface className="table-shell table-shell--platform-users">
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<div>
|
||||||
|
<h3>Пользователи платформы</h3>
|
||||||
|
<p className="admin-helper-note">
|
||||||
|
Глобальный реестр аккаунтов. Доступы к сервисам остаются в матрицах; здесь — происхождение, статус аккаунта и полное удаление.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className="admin-data-table admin-data-table--platform-users">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Происхождение</th>
|
||||||
|
<th>Контуры</th>
|
||||||
|
<th>Статус аккаунта</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th aria-label="Удаление" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(({ user, memberships, origin }) => {
|
||||||
|
const protectedUser = user.id === "user_root";
|
||||||
|
const contextLabel =
|
||||||
|
memberships.length === 0
|
||||||
|
? "—"
|
||||||
|
: memberships
|
||||||
|
.map((membership) => getClient(data, membership.clientId).name)
|
||||||
|
.filter((value, index, list) => list.indexOf(value) === index)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td className="admin-user-cell">
|
||||||
|
<div className="admin-user-cell__fields">
|
||||||
|
<input
|
||||||
|
className="admin-table-input admin-table-input--strong"
|
||||||
|
value={user.name}
|
||||||
|
disabled={protectedUser}
|
||||||
|
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
|
||||||
|
aria-label={`Имя пользователя ${user.name}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="admin-table-input admin-table-input--muted"
|
||||||
|
value={user.email}
|
||||||
|
disabled={protectedUser}
|
||||||
|
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
||||||
|
aria-label={`Email пользователя ${user.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="platform-user-origin">
|
||||||
|
<strong>{origin.label}</strong>
|
||||||
|
{origin.detail ? <small>{origin.detail}</small> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="admin-table-text" title={contextLabel}>
|
||||||
|
{contextLabel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{protectedUser ? (
|
||||||
|
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
|
||||||
|
) : (
|
||||||
|
<AdminStatusDropdown
|
||||||
|
value={user.globalStatus}
|
||||||
|
options={userStatusOptions}
|
||||||
|
label={`Статус аккаунта ${user.name}`}
|
||||||
|
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(user.createdAt)}</td>
|
||||||
|
<td className="services-admin-table__actions">
|
||||||
|
{protectedUser ? (
|
||||||
|
<span className="admin-table-action-placeholder" />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
label={`Удалить пользователя ${user.email} полностью`}
|
||||||
|
className="admin-icon-action invite-icon-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteUserId(user.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
|
<NodeDcDeleteModal
|
||||||
|
isOpen={Boolean(deletingUser)}
|
||||||
|
title="Удалить пользователя полностью"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Будут удалены профиль <strong>{deletingUser?.email}</strong>, членства, сервисные доступы, заявки, инвайты и sync-записи.
|
||||||
|
Действие необратимо.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Удалить везде"
|
||||||
|
onClose={() => setDeleteUserId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deletingUser) onDeleteUser(deletingUser.id);
|
||||||
|
setDeleteUserId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GroupsSection({
|
function GroupsSection({
|
||||||
data,
|
data,
|
||||||
clientId,
|
clientId,
|
||||||
|
|
@ -1156,6 +1307,9 @@ const inviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = [
|
||||||
{ value: "expired", label: "Истёк", tone: "red" },
|
{ value: "expired", label: "Истёк", tone: "red" },
|
||||||
{ value: "revoked", label: "Отозван", tone: "red" },
|
{ value: "revoked", label: "Отозван", tone: "red" },
|
||||||
];
|
];
|
||||||
|
const editableInviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = inviteStatusOptions.filter(
|
||||||
|
(option) => option.value !== "accepted"
|
||||||
|
);
|
||||||
|
|
||||||
const accessRequestStatusOptions: Array<AdminStatusOption<AccessRequestStatus>> = [
|
const accessRequestStatusOptions: Array<AdminStatusOption<AccessRequestStatus>> = [
|
||||||
{ value: "new", label: "Входящая", tone: "yellow" },
|
{ value: "new", label: "Входящая", tone: "yellow" },
|
||||||
|
|
@ -1216,6 +1370,23 @@ function membershipRoleLabel(role: ClientMembershipRole): string {
|
||||||
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inviteSourceLabel(invite: Invite): string {
|
||||||
|
if (invite.source === "access_request") return "Заявка доступа";
|
||||||
|
if (invite.source === "tasker_workspace_invite") return "Workspace-инвайт";
|
||||||
|
return "Ручной инвайт";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteCanBeCopied(invite: Invite): boolean {
|
||||||
|
return invite.status === "created" || invite.status === "sent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteTerminalLabel(invite: Invite): string {
|
||||||
|
if (invite.status === "accepted") return "Пользователь зарегистрирован";
|
||||||
|
if (invite.status === "revoked") return "Ссылка отозвана";
|
||||||
|
if (invite.status === "expired") return "Срок истёк";
|
||||||
|
return "Ссылка недоступна";
|
||||||
|
}
|
||||||
|
|
||||||
function statusOptionLabel<T extends string>(options: Array<AdminStatusOption<T>>, value: T): string {
|
function statusOptionLabel<T extends string>(options: Array<AdminStatusOption<T>>, value: T): string {
|
||||||
return options.find((option) => option.value === value)?.label ?? value;
|
return options.find((option) => option.value === value)?.label ?? value;
|
||||||
}
|
}
|
||||||
|
|
@ -2715,7 +2886,7 @@ function AccessSection({
|
||||||
|
|
||||||
if (!hasUsers) {
|
if (!hasUsers) {
|
||||||
return (
|
return (
|
||||||
<div className="access-layout">
|
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||||
<GlassSurface className="access-matrix">
|
<GlassSurface className="access-matrix">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||||
|
|
@ -2733,7 +2904,7 @@ function AccessSection({
|
||||||
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
|
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="access-layout">
|
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||||
<GlassSurface className="access-matrix">
|
<GlassSurface className="access-matrix">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||||
|
|
@ -2854,6 +3025,151 @@ function AccessSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PublicAccessUsersPanel({
|
||||||
|
data,
|
||||||
|
matrix,
|
||||||
|
selectedCell,
|
||||||
|
onSelectCell,
|
||||||
|
onSetUserServiceAccess,
|
||||||
|
pendingAccessAssignments,
|
||||||
|
onUpdateUser,
|
||||||
|
onUpdateMembership,
|
||||||
|
}: {
|
||||||
|
data: LauncherData;
|
||||||
|
matrix: ReturnType<typeof buildAccessMatrix>;
|
||||||
|
selectedCell: AccessMatrixCell | null;
|
||||||
|
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||||
|
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||||
|
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||||
|
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||||
|
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||||
|
}) {
|
||||||
|
const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassSurface className="table-shell table-shell--users table-shell--public-access-users">
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<div>
|
||||||
|
<h3>Пользователи открытого контура</h3>
|
||||||
|
<p className="admin-helper-note">
|
||||||
|
Блокировка аккаунта отключает вход через Authentik и не удаляет историю заявок, инвайтов и аудит.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className="admin-data-table admin-data-table--users admin-data-table--public-access-users">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Кто пригласил</th>
|
||||||
|
<th>Роль в контуре</th>
|
||||||
|
<th>Статус в контуре</th>
|
||||||
|
<th>Статус аккаунта</th>
|
||||||
|
<th>Operational Core</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 inviterMeta = getMembershipInviterMeta(data, membership);
|
||||||
|
const operationalCoreCell = operationalCoreService
|
||||||
|
? matrix.cells.find((cell) => cell.userId === user.id && cell.serviceId === operationalCoreService.id) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={membership.id}>
|
||||||
|
<td className="admin-user-cell">
|
||||||
|
<div className="admin-user-cell__fields">
|
||||||
|
<input
|
||||||
|
className="admin-table-input admin-table-input--strong"
|
||||||
|
value={user.name}
|
||||||
|
onChange={(event) => onUpdateUser(user.id, { name: event.target.value })}
|
||||||
|
aria-label={`Имя пользователя ${user.name}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="admin-table-input admin-table-input--muted"
|
||||||
|
value={user.email}
|
||||||
|
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
||||||
|
aria-label={`Email пользователя ${user.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{inviterMeta.showInAccessMatrix ? (
|
||||||
|
<div className="membership-inviter-cell">
|
||||||
|
<span>{inviterMeta.title}</span>
|
||||||
|
<small>{inviterMeta.sourceLabel}</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted-text">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{protectedUser ? (
|
||||||
|
<AdminStaticPill>{membershipRoleLabel(membership.role)}</AdminStaticPill>
|
||||||
|
) : (
|
||||||
|
<NodeDcSelect
|
||||||
|
className="admin-table-select-wrap"
|
||||||
|
triggerClassName="admin-table-select-trigger"
|
||||||
|
value={membership.role}
|
||||||
|
options={membershipRoleOptions}
|
||||||
|
label={`Роль в открытом контуре ${user.name}`}
|
||||||
|
minMenuWidth={198}
|
||||||
|
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<AdminStatusDropdown
|
||||||
|
value={membership.status}
|
||||||
|
options={membershipStatusOptions}
|
||||||
|
label={`Статус в открытом контуре ${user.name}`}
|
||||||
|
onChange={(status) => onUpdateMembership(membership.id, { status })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{protectedUser ? (
|
||||||
|
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
|
||||||
|
) : (
|
||||||
|
<AdminStatusDropdown
|
||||||
|
value={user.globalStatus}
|
||||||
|
options={userStatusOptions}
|
||||||
|
label={`Статус аккаунта ${user.name}`}
|
||||||
|
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{operationalCoreCell ? (
|
||||||
|
<AccessCellControl
|
||||||
|
cell={operationalCoreCell}
|
||||||
|
active={selectedCell?.userId === user.id && selectedCell.serviceId === operationalCoreCell.serviceId}
|
||||||
|
pendingValue={pendingAccessAssignments[accessCellKey(user.id, operationalCoreCell.serviceId)]}
|
||||||
|
publicSelfService
|
||||||
|
onSelectCell={onSelectCell}
|
||||||
|
onSetAccess={(value) =>
|
||||||
|
onSetUserServiceAccess({
|
||||||
|
userId: user.id,
|
||||||
|
serviceId: operationalCoreCell.serviceId,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="muted-text">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MainStatusControl({
|
function MainStatusControl({
|
||||||
value,
|
value,
|
||||||
protectedUser,
|
protectedUser,
|
||||||
|
|
@ -3245,6 +3561,8 @@ function InvitesSection({
|
||||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||||
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
|
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
|
||||||
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
||||||
|
const incomingRequestsTotal =
|
||||||
|
data.accessRequests.length + data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length;
|
||||||
const pendingIncomingRequests =
|
const pendingIncomingRequests =
|
||||||
data.accessRequests.filter((request) => request.status === "new").length +
|
data.accessRequests.filter((request) => request.status === "new").length +
|
||||||
data.taskerInviteRequests.filter((request) => request.status === "new").length;
|
data.taskerInviteRequests.filter((request) => request.status === "new").length;
|
||||||
|
|
@ -3293,7 +3611,7 @@ function InvitesSection({
|
||||||
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
|
className={cn("admin-tab-button", publicInviteTab === "incoming" && "admin-tab-button--active")}
|
||||||
onClick={() => setPublicInviteTab("incoming")}
|
onClick={() => setPublicInviteTab("incoming")}
|
||||||
>
|
>
|
||||||
Входящие · {pendingIncomingRequests}
|
Входящие · {incomingRequestsTotal}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -3304,7 +3622,9 @@ function InvitesSection({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="admin-helper-note">
|
<p className="admin-helper-note">
|
||||||
Входящие — заявки из формы “Запросить доступ”. Исходящие — инвайты, которые root-admin выпускает руками без заявки.
|
{publicInviteTab === "incoming"
|
||||||
|
? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.`
|
||||||
|
: "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}
|
||||||
</p>
|
</p>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -3354,12 +3674,20 @@ function InvitesSection({
|
||||||
|
|
||||||
<GlassSurface className="table-shell">
|
<GlassSurface className="table-shell">
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<h3>Инвайты</h3>
|
<div>
|
||||||
|
<h3>{isPublicPoolContext ? "Исходящие инвайты" : "Инвайты"}</h3>
|
||||||
|
{isPublicPoolContext ? (
|
||||||
|
<p className="admin-helper-note">
|
||||||
|
Отзыв блокирует только неиспользованную ссылку. Уже принятый инвайт управляется через Root Admin → Пользователи.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table className="admin-data-table">
|
<table className="admin-data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Источник</th>
|
||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Ссылка</th>
|
<th>Ссылка</th>
|
||||||
|
|
@ -3371,62 +3699,92 @@ function InvitesSection({
|
||||||
{invites.map((invite) => {
|
{invites.map((invite) => {
|
||||||
const inviteUrl = buildInviteUrl(invite.token);
|
const inviteUrl = buildInviteUrl(invite.token);
|
||||||
const isCopied = copiedInviteId === invite.id;
|
const isCopied = copiedInviteId === invite.id;
|
||||||
|
const isAccepted = invite.status === "accepted";
|
||||||
|
const canCopyInvite = inviteCanBeCopied(invite);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={invite.id}>
|
<tr key={invite.id}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
{isAccepted ? (
|
||||||
className="admin-table-input admin-table-input--strong"
|
<span className="admin-table-text admin-table-text--strong">{invite.email}</span>
|
||||||
value={invite.email}
|
) : (
|
||||||
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
|
<input
|
||||||
aria-label={`Email инвайта ${invite.email}`}
|
className="admin-table-input admin-table-input--strong"
|
||||||
/>
|
value={invite.email}
|
||||||
|
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
|
||||||
|
aria-label={`Email инвайта ${invite.email}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<NodeDcSelect
|
<div className="membership-inviter-cell">
|
||||||
className="admin-table-select-wrap"
|
<span>{inviteSourceLabel(invite)}</span>
|
||||||
triggerClassName="admin-table-select-trigger"
|
{invite.sourceWorkspaceName || invite.sourceWorkspaceSlug ? (
|
||||||
value={invite.role}
|
<small>{invite.sourceWorkspaceName ?? invite.sourceWorkspaceSlug}</small>
|
||||||
options={inviteRoleOptions}
|
) : null}
|
||||||
label={`Роль инвайта ${invite.email}`}
|
|
||||||
minMenuWidth={172}
|
|
||||||
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<AdminStatusDropdown
|
|
||||||
value={invite.status}
|
|
||||||
options={inviteStatusOptions}
|
|
||||||
label={`Статус инвайта ${invite.email}`}
|
|
||||||
onChange={(status) => onUpdateInvite(invite.id, { status })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="invite-link-cell">
|
|
||||||
<code title={inviteUrl}>{inviteUrl}</code>
|
|
||||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
|
||||||
<IconButton
|
|
||||||
label={isCopied ? `Инвайт ${invite.email} скопирован` : `Скопировать инвайт ${invite.email}`}
|
|
||||||
className="admin-icon-action invite-icon-action"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleCopyInvite(invite)}
|
|
||||||
>
|
|
||||||
<Copy size={11} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<NodeDcDateField
|
{isAccepted ? (
|
||||||
value={invite.expiresAt}
|
<AdminStaticPill>{membershipRoleLabel(invite.role)}</AdminStaticPill>
|
||||||
label={`Инвайт истекает ${invite.email}`}
|
) : (
|
||||||
onChange={(value) => {
|
<NodeDcSelect
|
||||||
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
className="admin-table-select-wrap"
|
||||||
}}
|
triggerClassName="admin-table-select-trigger"
|
||||||
/>
|
value={invite.role}
|
||||||
|
options={inviteRoleOptions}
|
||||||
|
label={`Роль инвайта ${invite.email}`}
|
||||||
|
minMenuWidth={172}
|
||||||
|
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isAccepted ? (
|
||||||
|
<AdminStatusPill value={invite.status} options={inviteStatusOptions} />
|
||||||
|
) : (
|
||||||
|
<AdminStatusDropdown
|
||||||
|
value={invite.status}
|
||||||
|
options={editableInviteStatusOptions}
|
||||||
|
label={`Статус инвайта ${invite.email}`}
|
||||||
|
onChange={(status) => onUpdateInvite(invite.id, { status })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{canCopyInvite ? (
|
||||||
|
<div className="invite-link-cell">
|
||||||
|
<code title={inviteUrl}>{inviteUrl}</code>
|
||||||
|
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||||
|
<IconButton
|
||||||
|
label={isCopied ? `Инвайт ${invite.email} скопирован` : `Скопировать инвайт ${invite.email}`}
|
||||||
|
className="admin-icon-action invite-icon-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopyInvite(invite)}
|
||||||
|
>
|
||||||
|
<Copy size={11} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted-text">{inviteTerminalLabel(invite)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isAccepted ? (
|
||||||
|
<span className="muted-text">{formatDate(invite.expiresAt)}</span>
|
||||||
|
) : (
|
||||||
|
<NodeDcDateField
|
||||||
|
value={invite.expiresAt}
|
||||||
|
label={`Инвайт истекает ${invite.email}`}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="services-admin-table__actions">
|
<td className="services-admin-table__actions">
|
||||||
<IconButton
|
<IconButton
|
||||||
label={`Удалить инвайт ${invite.email}`}
|
label={`Удалить запись инвайта ${invite.email}`}
|
||||||
className="admin-icon-action invite-icon-action"
|
className="admin-icon-action invite-icon-action"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDeleteInviteId(invite.id)}
|
onClick={() => setDeleteInviteId(invite.id)}
|
||||||
|
|
@ -3442,10 +3800,11 @@ function InvitesSection({
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
<NodeDcDeleteModal
|
<NodeDcDeleteModal
|
||||||
isOpen={Boolean(deletingInvite)}
|
isOpen={Boolean(deletingInvite)}
|
||||||
title="Удалить инвайт"
|
title="Удалить запись инвайта"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
|
Будет удалена только запись invite-ссылки для <strong>{deletingInvite?.email}</strong>. Пользователь, доступы и
|
||||||
|
история аккаунта не удаляются.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
onClose={() => setDeleteInviteId(null)}
|
onClose={() => setDeleteInviteId(null)}
|
||||||
|
|
@ -3532,6 +3891,7 @@ function AccessRequestsPanel({
|
||||||
: null;
|
: null;
|
||||||
const isTerminal = accessRequest.status !== "new";
|
const isTerminal = accessRequest.status !== "new";
|
||||||
const isCopied = Boolean(approvedInvite && copiedInviteId === approvedInvite.id);
|
const isCopied = Boolean(approvedInvite && copiedInviteId === approvedInvite.id);
|
||||||
|
const approvedInviteCanBeCopied = Boolean(approvedInvite && inviteCanBeCopied(approvedInvite));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={accessRequest.id}>
|
<tr key={accessRequest.id}>
|
||||||
|
|
@ -3576,7 +3936,7 @@ function AccessRequestsPanel({
|
||||||
<AdminStatusPill value={accessRequest.status} options={accessRequestStatusOptions} />
|
<AdminStatusPill value={accessRequest.status} options={accessRequestStatusOptions} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{approvedInvite ? (
|
{approvedInvite && approvedInviteCanBeCopied ? (
|
||||||
<div className="invite-link-cell">
|
<div className="invite-link-cell">
|
||||||
<code title={buildInviteUrl(approvedInvite.token)}>{buildInviteUrl(approvedInvite.token)}</code>
|
<code title={buildInviteUrl(approvedInvite.token)}>{buildInviteUrl(approvedInvite.token)}</code>
|
||||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||||
|
|
@ -3589,6 +3949,8 @@ function AccessRequestsPanel({
|
||||||
<Copy size={11} />
|
<Copy size={11} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
) : approvedInvite ? (
|
||||||
|
<span className="muted-text">{inviteTerminalLabel(approvedInvite)}</span>
|
||||||
) : accessRequest.status === "approved" ? (
|
) : accessRequest.status === "approved" ? (
|
||||||
<span className="muted-text">Активен</span>
|
<span className="muted-text">Активен</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -3784,7 +4146,7 @@ function formatAccessRequestName(accessRequest: AccessRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMembershipInviterMeta(data: LauncherData, membership: ClientMembership) {
|
function getMembershipInviterMeta(data: LauncherData, membership: ClientMembership) {
|
||||||
const inviter = membership.invitedByUserId ? getUser(data, membership.invitedByUserId) : null;
|
const inviter = membership.invitedByUserId ? data.users.find((user) => user.id === membership.invitedByUserId) ?? null : null;
|
||||||
const taskerRequest = membership.sourceTaskerInviteRequestId
|
const taskerRequest = membership.sourceTaskerInviteRequestId
|
||||||
? data.taskerInviteRequests.find((request) => request.id === membership.sourceTaskerInviteRequestId)
|
? data.taskerInviteRequests.find((request) => request.id === membership.sourceTaskerInviteRequestId)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -3828,6 +4190,74 @@ function getMembershipInviterMeta(data: LauncherData, membership: ClientMembersh
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlatformUserOrigin(data: LauncherData, user: LauncherUser, memberships: ClientMembership[]) {
|
||||||
|
const publicMembership = memberships.find((membership) => isPublicPoolClientId(membership.clientId));
|
||||||
|
|
||||||
|
if (publicMembership) {
|
||||||
|
const inviterMeta = getMembershipInviterMeta(data, publicMembership);
|
||||||
|
|
||||||
|
if (publicMembership.source === "tasker_workspace_invite") {
|
||||||
|
return {
|
||||||
|
label: "Открытый контур · self-host invite",
|
||||||
|
detail: `${inviterMeta.title} · ${inviterMeta.sourceLabel}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicMembership.source === "access_request") {
|
||||||
|
const request = data.accessRequests.find((item) => item.email.toLowerCase() === user.email.toLowerCase());
|
||||||
|
return {
|
||||||
|
label: "Открытый контур · прямой запрос",
|
||||||
|
detail: request?.company ? `Компания из заявки: ${request.company}` : inviterMeta.subtitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicMembership.inviteId) {
|
||||||
|
return {
|
||||||
|
label: "Открытый контур · ручной инвайт",
|
||||||
|
detail: inviterMeta.subtitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "Открытый контур",
|
||||||
|
detail: inviterMeta.subtitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberships.length > 0) {
|
||||||
|
const primaryMembership = memberships[0];
|
||||||
|
const client = getClient(data, primaryMembership.clientId);
|
||||||
|
const extraContexts = memberships.length > 1 ? `, ещё ${memberships.length - 1}` : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `Компания · ${client.name}`,
|
||||||
|
detail: `${client.legalName ?? client.name}${extraContexts}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingInvite = data.invites.find((invite) => invite.email.toLowerCase() === user.email.toLowerCase());
|
||||||
|
const pendingRequest = data.accessRequests.find((request) => request.email.toLowerCase() === user.email.toLowerCase());
|
||||||
|
|
||||||
|
if (pendingInvite) {
|
||||||
|
return {
|
||||||
|
label: "Инвайт · без контура",
|
||||||
|
detail: inviteSourceLabel(pendingInvite),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingRequest) {
|
||||||
|
return {
|
||||||
|
label: "Заявка · без контура",
|
||||||
|
detail: pendingRequest.company,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "Без контура",
|
||||||
|
detail: "Нет активных memberships",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function MembershipInviterCell({ data, membership }: { data: LauncherData; membership: ClientMembership }) {
|
function MembershipInviterCell({ data, membership }: { data: LauncherData; membership: ClientMembership }) {
|
||||||
const inviterMeta = getMembershipInviterMeta(data, membership);
|
const inviterMeta = getMembershipInviterMeta(data, membership);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue