ФУНКЦИИ - NODEDC LAUNCHER: управление пользователями платформы
This commit is contained in:
parent
8be33c53da
commit
2b34cf9f1b
|
|
@ -144,19 +144,6 @@
|
|||
"createdAt": "2026-05-09T11:33:58.892Z",
|
||||
"updatedAt": "2026-05-09T12:34:39.358Z"
|
||||
},
|
||||
{
|
||||
"id": "user_alah_gmail_com",
|
||||
"authentikUserId": "1a3e1273-8d77-4747-947a-295f8ac89418",
|
||||
"name": "ALAH",
|
||||
"email": "alah@gmail.com",
|
||||
"phone": null,
|
||||
"position": null,
|
||||
"notes": "Создан через публичную регистрацию по инвайту клиента Открытый контур.",
|
||||
"avatarUrl": null,
|
||||
"globalStatus": "active",
|
||||
"createdAt": "2026-05-09T17:26:58.823Z",
|
||||
"updatedAt": "2026-05-09T18:58:28.494Z"
|
||||
},
|
||||
{
|
||||
"id": "user_pupa_mail_ru",
|
||||
"authentikUserId": "a2a1b489-f492-45a0-a5bd-04f6c1ede80d",
|
||||
|
|
@ -171,17 +158,17 @@
|
|||
"updatedAt": "2026-05-09T19:37:43.533Z"
|
||||
},
|
||||
{
|
||||
"id": "user_alla_mail_ru",
|
||||
"authentikUserId": "216bb9c1-112e-4c5b-bcc3-a6a516955759",
|
||||
"email": "alla@mail.ru",
|
||||
"name": "Абрамова Алла В",
|
||||
"phone": "+79856118477",
|
||||
"id": "user_realla_mail_ru",
|
||||
"authentikUserId": "95b13198-08d8-4674-afb9-03bf45b5f6da",
|
||||
"name": "АВ",
|
||||
"email": "realla@mail.ru",
|
||||
"phone": null,
|
||||
"position": null,
|
||||
"notes": "Public access request: СВК",
|
||||
"notes": "Создан через публичную регистрацию по инвайту клиента Открытый контур.",
|
||||
"avatarUrl": null,
|
||||
"globalStatus": "active",
|
||||
"createdAt": "2026-05-10T14:10:54.544Z",
|
||||
"updatedAt": "2026-05-10T14:10:55.837Z"
|
||||
"createdAt": "2026-05-11T09:16:35.282Z",
|
||||
"updatedAt": "2026-05-11T09:16:35.291Z"
|
||||
}
|
||||
],
|
||||
"memberships": [
|
||||
|
|
@ -257,19 +244,6 @@
|
|||
"createdAt": "2026-05-09T11:33:58.892Z",
|
||||
"updatedAt": "2026-05-09T11:33:58.892Z"
|
||||
},
|
||||
{
|
||||
"id": "mem_client_public_pool_alah_gmail_com",
|
||||
"clientId": "client_public_pool",
|
||||
"userId": "user_alah_gmail_com",
|
||||
"role": "member",
|
||||
"status": "active",
|
||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
||||
"inviteId": "invite_ayoyoyo_alah_gmail_com_2",
|
||||
"source": "tasker_workspace_invite",
|
||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com_2",
|
||||
"createdAt": "2026-05-09T17:26:58.823Z",
|
||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
||||
},
|
||||
{
|
||||
"id": "mem_client_public_pool_pupa_mail_ru",
|
||||
"clientId": "client_public_pool",
|
||||
|
|
@ -284,17 +258,17 @@
|
|||
"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",
|
||||
"userId": "user_alla_mail_ru",
|
||||
"userId": "user_realla_mail_ru",
|
||||
"role": "member",
|
||||
"status": "disabled",
|
||||
"invitedByUserId": null,
|
||||
"inviteId": null,
|
||||
"status": "active",
|
||||
"invitedByUserId": "user_root",
|
||||
"inviteId": "invite_realla_mail_ru",
|
||||
"source": "access_request",
|
||||
"sourceTaskerInviteRequestId": null,
|
||||
"createdAt": "2026-05-10T14:10:54.544Z",
|
||||
"updatedAt": "2026-05-10T14:10:54.544Z"
|
||||
"createdAt": "2026-05-11T09:16:35.282Z",
|
||||
"updatedAt": "2026-05-11T09:16:35.282Z"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
|
|
@ -542,16 +516,6 @@
|
|||
"createdAt": "2026-05-09T12:34:38.766Z",
|
||||
"updatedAt": "2026-05-09T12:34:38.766Z"
|
||||
},
|
||||
{
|
||||
"id": "grant_task_manager_user_alah_gmail_com",
|
||||
"serviceId": "service_task_manager",
|
||||
"targetType": "user",
|
||||
"targetId": "user_alah_gmail_com",
|
||||
"appRole": "member",
|
||||
"status": "active",
|
||||
"createdAt": "2026-05-09T18:58:27.834Z",
|
||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
||||
},
|
||||
{
|
||||
"id": "grant_task_manager_user_pupa_mail_ru",
|
||||
"serviceId": "service_task_manager",
|
||||
|
|
@ -647,60 +611,6 @@
|
|||
"createdAt": "2026-05-09T11:32:40.370Z",
|
||||
"updatedAt": "2026-05-09T11:33:58.892Z"
|
||||
},
|
||||
{
|
||||
"id": "invite_ayoyoyo_alah_mail_ru",
|
||||
"clientId": "client_public_pool",
|
||||
"email": "alah@mail.ru",
|
||||
"role": "member",
|
||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
||||
"source": "tasker_workspace_invite",
|
||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_mail_ru_2",
|
||||
"sourceTaskerInviteId": "bcb82953-f277-4f0c-a61a-1b0791a8a381",
|
||||
"sourceWorkspaceSlug": "ayoyoyo",
|
||||
"sourceWorkspaceName": "AYOYOYO",
|
||||
"sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=bcb82953-f277-4f0c-a61a-1b0791a8a381&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAbWFpbC5ydSIsInJvbGUiOjE1fSwidGltZXN0YW1wIjoxNzc4MzM2NjcxLjIzNTc3fQ.wyKAbnfPd2NasrAEbmg8KaYo-nOGFKHgC0nERaekuys",
|
||||
"token": "e0726f74-82c0-49d8-bd08-e6df097bde44",
|
||||
"expiresAt": "2026-05-16T17:08:10.886Z",
|
||||
"status": "revoked",
|
||||
"createdAt": "2026-05-09T17:08:10.886Z",
|
||||
"updatedAt": "2026-05-09T17:25:17.778Z"
|
||||
},
|
||||
{
|
||||
"id": "invite_ayoyoyo_alah_gmail_com",
|
||||
"clientId": "client_public_pool",
|
||||
"email": "alah@gmail.com",
|
||||
"role": "member",
|
||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
||||
"source": "tasker_workspace_invite",
|
||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com",
|
||||
"sourceTaskerInviteId": "359913f5-80e6-4772-82f9-19e653cf1147",
|
||||
"sourceWorkspaceSlug": "ayoyoyo",
|
||||
"sourceWorkspaceName": "AYOYOYO",
|
||||
"sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=359913f5-80e6-4772-82f9-19e653cf1147&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNDc1NTQuMzg3MTV9.ECLpdg1RAaCll_GfNZEx1OTI3sf3bfuRxmJShpEZruM",
|
||||
"token": "410184c9-926b-4e6a-8649-3c0178c7d248",
|
||||
"expiresAt": "2026-05-16T17:26:04.673Z",
|
||||
"status": "accepted",
|
||||
"createdAt": "2026-05-09T17:26:04.673Z",
|
||||
"updatedAt": "2026-05-09T17:26:58.823Z"
|
||||
},
|
||||
{
|
||||
"id": "invite_ayoyoyo_alah_gmail_com_2",
|
||||
"clientId": "client_public_pool",
|
||||
"email": "alah@gmail.com",
|
||||
"role": "member",
|
||||
"invitedByUserId": "user_ayo_ayo_ae_gmail_com",
|
||||
"source": "tasker_workspace_invite",
|
||||
"sourceTaskerInviteRequestId": "tasker_invite_request_ayoyoyo_alah_gmail_com_2",
|
||||
"sourceTaskerInviteId": "c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c",
|
||||
"sourceWorkspaceSlug": "ayoyoyo",
|
||||
"sourceWorkspaceName": "AYOYOYO",
|
||||
"sourceTaskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNTMwNTIuMzYxODc5fQ.NtsQ5-48bXpOCgU7Z4u4QxLHZZvgiWVXtJ6e_yUrpfg",
|
||||
"token": "c8ca208c-7a8b-4d32-8559-81bf6fd360d9",
|
||||
"expiresAt": "2026-05-16T18:58:00.118Z",
|
||||
"status": "accepted",
|
||||
"createdAt": "2026-05-09T18:58:00.118Z",
|
||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
||||
},
|
||||
{
|
||||
"id": "invite_ayoyoyo_pupa_mail_ru",
|
||||
"clientId": "client_public_pool",
|
||||
|
|
@ -732,9 +642,9 @@
|
|||
"sourceWorkspaceName": null,
|
||||
"token": "02bbfc59-0bc4-4eb0-8128-46eabee23a46",
|
||||
"expiresAt": "2026-05-17T13:18:41.837Z",
|
||||
"status": "created",
|
||||
"status": "accepted",
|
||||
"createdAt": "2026-05-10T13:18:41.837Z",
|
||||
"updatedAt": "2026-05-10T13:18:41.837Z"
|
||||
"updatedAt": "2026-05-11T09:16:35.282Z"
|
||||
}
|
||||
],
|
||||
"syncStatuses": [
|
||||
|
|
@ -1024,17 +934,6 @@
|
|||
"error": null,
|
||||
"updatedAt": "2026-05-09T18:58:27.834Z"
|
||||
},
|
||||
{
|
||||
"id": "sync_user_user_alah_gmail_com",
|
||||
"objectId": "user_alah_gmail_com",
|
||||
"objectName": "alah@gmail.com",
|
||||
"objectType": "user",
|
||||
"target": "authentik",
|
||||
"state": "synced",
|
||||
"lastSyncAt": "2026-05-09T18:58:28.494Z",
|
||||
"error": null,
|
||||
"updatedAt": "2026-05-09T18:58:28.494Z"
|
||||
},
|
||||
{
|
||||
"id": "sync_grant_service_task_manager_user_pupa_mail_ru",
|
||||
"objectId": "service_task_manager:user_pupa_mail_ru",
|
||||
|
|
@ -1066,18 +965,29 @@
|
|||
"state": "pending",
|
||||
"lastSyncAt": null,
|
||||
"error": null,
|
||||
"updatedAt": "2026-05-10T13:18:41.837Z"
|
||||
"updatedAt": "2026-05-11T09:11:46.244Z"
|
||||
},
|
||||
{
|
||||
"id": "sync_user_user_alla_mail_ru",
|
||||
"objectId": "user_alla_mail_ru",
|
||||
"objectName": "alla@mail.ru",
|
||||
"id": "sync_grant_service_task_manager_user_alla_mail_ru",
|
||||
"objectId": "service_task_manager:user_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",
|
||||
"target": "authentik",
|
||||
"state": "synced",
|
||||
"lastSyncAt": "2026-05-10T14:10:55.837Z",
|
||||
"lastSyncAt": "2026-05-11T09:16:35.291Z",
|
||||
"error": null,
|
||||
"updatedAt": "2026-05-10T14:10:55.837Z"
|
||||
"updatedAt": "2026-05-11T09:16:35.291Z"
|
||||
}
|
||||
],
|
||||
"auditEvents": [
|
||||
|
|
@ -3851,6 +3761,186 @@
|
|||
"clientId": "client_2",
|
||||
"result": "warning",
|
||||
"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": {
|
||||
|
|
@ -4062,24 +4152,6 @@
|
|||
"comment": null,
|
||||
"createdAt": "2026-05-10T13:18:08.047Z",
|
||||
"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": [
|
||||
|
|
@ -4127,50 +4199,6 @@
|
|||
"createdAt": "2026-05-09T14:24:31.256Z",
|
||||
"updatedAt": "2026-05-09T17:25:17.778Z"
|
||||
},
|
||||
{
|
||||
"id": "tasker_invite_request_ayoyoyo_alah_gmail_com",
|
||||
"taskerInviteId": "359913f5-80e6-4772-82f9-19e653cf1147",
|
||||
"workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2",
|
||||
"workspaceSlug": "ayoyoyo",
|
||||
"workspaceName": "AYOYOYO",
|
||||
"inviteeEmail": "alah@gmail.com",
|
||||
"role": "member",
|
||||
"inviterUserId": "user_ayo_ayo_ae_gmail_com",
|
||||
"inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39",
|
||||
"inviterEmail": "ayo.ayo.ae@gmail.com",
|
||||
"inviterName": "Anna Ayo",
|
||||
"status": "cancelled",
|
||||
"taskerInviteLink": null,
|
||||
"platformInviteId": "invite_ayoyoyo_alah_gmail_com",
|
||||
"platformInviteToken": "410184c9-926b-4e6a-8649-3c0178c7d248",
|
||||
"reviewedByUserId": "user_root",
|
||||
"reviewedAt": "2026-05-09T17:26:04.726Z",
|
||||
"comment": "Пользователь удалён из workspace Operational Core.",
|
||||
"createdAt": "2026-05-09T17:25:54.405Z",
|
||||
"updatedAt": "2026-05-09T18:22:48.238Z"
|
||||
},
|
||||
{
|
||||
"id": "tasker_invite_request_ayoyoyo_alah_gmail_com_2",
|
||||
"taskerInviteId": "c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c",
|
||||
"workspaceId": "9554ef6f-afa0-424b-b27f-16cca437f2d2",
|
||||
"workspaceSlug": "ayoyoyo",
|
||||
"workspaceName": "AYOYOYO",
|
||||
"inviteeEmail": "alah@gmail.com",
|
||||
"role": "member",
|
||||
"inviterUserId": "user_ayo_ayo_ae_gmail_com",
|
||||
"inviterPlaneUserId": "b91bd701-9e50-41e7-a8de-255041772a39",
|
||||
"inviterEmail": "ayo.ayo.ae@gmail.com",
|
||||
"inviterName": "Anna Ayo",
|
||||
"status": "approved",
|
||||
"taskerInviteLink": "http://task.local.nodedc/workspace-invitations/?invitation_id=c6cc5c82-641a-4cf8-aa3d-c28fb76ee83c&slug=ayoyoyo&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6eyJlbWFpbCI6ImFsYWhAZ21haWwuY29tIiwicm9sZSI6MTV9LCJ0aW1lc3RhbXAiOjE3NzgzNTMwNTIuMzYxODc5fQ.NtsQ5-48bXpOCgU7Z4u4QxLHZZvgiWVXtJ6e_yUrpfg",
|
||||
"platformInviteId": "invite_ayoyoyo_alah_gmail_com_2",
|
||||
"platformInviteToken": "c8ca208c-7a8b-4d32-8559-81bf6fd360d9",
|
||||
"reviewedByUserId": "user_root",
|
||||
"reviewedAt": "2026-05-09T18:58:00.166Z",
|
||||
"comment": null,
|
||||
"createdAt": "2026-05-09T18:57:32.379Z",
|
||||
"updatedAt": "2026-05-09T18:58:00.166Z"
|
||||
},
|
||||
{
|
||||
"id": "tasker_invite_request_ayoyoyo_pupa_mail_ru",
|
||||
"taskerInviteId": "9b470198-5685-4ebf-b330-75b357363e97",
|
||||
|
|
@ -4193,5 +4221,21 @@
|
|||
"createdAt": "2026-05-09T19:36:19.353Z",
|
||||
"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) {
|
||||
if (authentikUserId) {
|
||||
const payload = await requestJson(`/api/v3/core/users/?search=${encodeURIComponent(authentikUserId)}`);
|
||||
|
|
@ -155,6 +180,7 @@ export function createAuthentikSyncClient({ baseUrl, token }) {
|
|||
}
|
||||
|
||||
return {
|
||||
deleteUser,
|
||||
isConfigured,
|
||||
provisionUser,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const collectionKeys = [
|
|||
"exceptions",
|
||||
"invites",
|
||||
"accessRequests",
|
||||
"revokedAccounts",
|
||||
"taskerInviteRequests",
|
||||
"syncStatuses",
|
||||
"auditEvents",
|
||||
|
|
@ -473,6 +474,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
}
|
||||
}
|
||||
|
||||
clearRevokedAccount(data, email);
|
||||
addAuditEvent(data, actor, {
|
||||
action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь",
|
||||
objectType: "user",
|
||||
|
|
@ -541,6 +543,93 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
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) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
|
|
@ -590,6 +679,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
const email = requestPayload.email.toLowerCase();
|
||||
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
|
||||
|
||||
if (existingUser?.globalStatus === "blocked") {
|
||||
throw new Error("Аккаунт с этой почтой заблокирован. Обратитесь к администратору NODE.DC.");
|
||||
}
|
||||
|
||||
if (existingUser?.globalStatus === "active") {
|
||||
const hasOnlyPendingAccessRequest =
|
||||
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);
|
||||
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
|
||||
clearRevokedAccount(data, email);
|
||||
markPendingSync(data, user, "user", user.email);
|
||||
const existingRequest = data.accessRequests.find(
|
||||
(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.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 client = findClientById(data, accessRequest.targetClientId);
|
||||
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
|
||||
|
|
@ -1108,6 +1207,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
const actor = resolveActor(data, identity);
|
||||
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||||
|
||||
if (user?.globalStatus === "blocked") {
|
||||
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
|
||||
}
|
||||
|
||||
if (user) {
|
||||
user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null);
|
||||
user.name = optionalString(identity?.name, user.name);
|
||||
|
|
@ -1159,6 +1262,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
}
|
||||
|
||||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||||
clearRevokedAccount(data, email);
|
||||
invite.status = "accepted";
|
||||
invite.updatedAt = now;
|
||||
|
||||
|
|
@ -1536,6 +1640,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
user.email = optionalString(provisioning.email, user.email).toLowerCase();
|
||||
user.name = optionalString(provisioning.name, user.name);
|
||||
user.updatedAt = now;
|
||||
clearRevokedAccount(data, user.email);
|
||||
|
||||
const syncStatus = data.syncStatuses.find(
|
||||
(status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id
|
||||
|
|
@ -1615,6 +1720,34 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
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 {
|
||||
approveAccessRequest,
|
||||
approveTaskerInviteRequest,
|
||||
|
|
@ -1632,11 +1765,13 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
deleteInvite,
|
||||
deleteMembership,
|
||||
deleteService,
|
||||
deleteUser,
|
||||
rejectAccessRequest,
|
||||
rejectTaskerInviteRequest,
|
||||
acceptInvite,
|
||||
commitInviteRegistration,
|
||||
getInviteByToken,
|
||||
getLoginAccountStatus,
|
||||
getSnapshot,
|
||||
ensureTaskerInvitePlatformInvite,
|
||||
prepareInviteRegistration,
|
||||
|
|
@ -1679,10 +1814,33 @@ function normalizeData(payload) {
|
|||
integrations: normalizeClientIntegrations(client.integrations),
|
||||
}));
|
||||
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
|
||||
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean);
|
||||
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
|
||||
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) {
|
||||
if (typeof payload !== "object" || payload === null) return null;
|
||||
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) {
|
||||
const now = isoNow();
|
||||
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);
|
||||
|
||||
if (user?.globalStatus === "blocked") {
|
||||
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
|
||||
}
|
||||
|
||||
if (user?.authentikUserId && !provisioning) {
|
||||
throw new Error("Аккаунт уже существует. Войдите под почтой инвайта.");
|
||||
}
|
||||
|
|
@ -2236,6 +2436,7 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
|
|||
}
|
||||
|
||||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||||
clearRevokedAccount(data, email);
|
||||
invite.status = "accepted";
|
||||
invite.updatedAt = now;
|
||||
markPendingSync(data, user, "user", email);
|
||||
|
|
@ -2441,6 +2642,10 @@ function nullableStringWithFallback(value, fallback) {
|
|||
return value === undefined ? fallback : nullableString(value);
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function pickEnum(value, allowedValues, 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));
|
||||
});
|
||||
|
||||
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) => {
|
||||
try {
|
||||
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 }));
|
||||
}));
|
||||
|
||||
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) => {
|
||||
if (!assertAdminCanManageUser(req, res, req.params.userId)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
deleteAdminInvite,
|
||||
deleteAdminMembership,
|
||||
deleteAdminService,
|
||||
deleteAdminUser,
|
||||
ensureAdminTaskManagerProjectMembership,
|
||||
ensureAdminTaskManagerWorkspaceMembership,
|
||||
fetchAdminTaskManagerWorkspaces,
|
||||
|
|
@ -732,6 +733,10 @@ export function LauncherApp() {
|
|||
applyControlPlaneMutation(updateAdminUserProfile(userId, patch));
|
||||
}
|
||||
|
||||
function handleDeleteUser(userId: string) {
|
||||
applyControlPlaneMutation(deleteAdminUser(userId));
|
||||
}
|
||||
|
||||
async function handleUpdateOwnProfile(patch: Partial<LauncherUser>) {
|
||||
const result = await updateOwnProfile(patch);
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
|
|
@ -892,6 +897,7 @@ export function LauncherApp() {
|
|||
onDeleteClient={handleDeleteClient}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onUpdateMembership={handleUpdateMembership}
|
||||
onDeleteMembership={handleDeleteMembership}
|
||||
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: {
|
||||
clientId: string;
|
||||
email: string;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export interface LauncherData {
|
|||
exceptions: ServiceAccessException[];
|
||||
invites: Invite[];
|
||||
accessRequests: AccessRequest[];
|
||||
revokedAccounts: RevokedAccount[];
|
||||
taskerInviteRequests: TaskerInviteRequest[];
|
||||
syncStatuses: SyncStatus[];
|
||||
auditEvents: typeof mockAuditEvents;
|
||||
|
|
@ -72,6 +73,21 @@ export interface LauncherData {
|
|||
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 {
|
||||
id: string;
|
||||
clientId: string;
|
||||
|
|
@ -152,6 +168,7 @@ export const initialLauncherData: LauncherData = normalizeLauncherData({
|
|||
exceptions: mockExceptions,
|
||||
invites: mockInvites,
|
||||
accessRequests: mockAccessRequests,
|
||||
revokedAccounts: [],
|
||||
taskerInviteRequests: mockTaskerInviteRequests,
|
||||
syncStatuses: mockSyncStatuses,
|
||||
auditEvents: mockAuditEvents,
|
||||
|
|
@ -199,6 +216,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
|||
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
||||
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
||||
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
|
||||
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],
|
||||
taskerInviteRequests: Array.isArray(payload.taskerInviteRequests) ? payload.taskerInviteRequests : mockTaskerInviteRequests,
|
||||
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||
|
|
|
|||
|
|
@ -2336,6 +2336,108 @@ code {
|
|||
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 {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
|
|
@ -2510,6 +2612,20 @@ code {
|
|||
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 {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
|
|
@ -3415,6 +3531,14 @@ code {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.access-layout--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.access-tabs-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.matrix-scroll {
|
||||
overflow: auto;
|
||||
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 }> = [
|
||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||
{ id: "clients", label: "Компании", icon: <Building2 size={16} /> },
|
||||
{ id: "users", label: "Пользователи", icon: <UsersRound size={16} /> },
|
||||
{ id: "services", label: "Каталог сервисов", icon: <DatabaseZap size={16} /> },
|
||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||||
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
||||
|
|
@ -174,6 +175,7 @@ export function AdminOverlay({
|
|||
onDeleteClient,
|
||||
onCreateUser,
|
||||
onUpdateUser,
|
||||
onDeleteUser,
|
||||
onUpdateMembership,
|
||||
onDeleteMembership,
|
||||
pendingAccessAssignments,
|
||||
|
|
@ -220,6 +222,7 @@ export function AdminOverlay({
|
|||
onDeleteClient: (clientId: string) => void;
|
||||
onCreateUser: (command: CreateUserCommand) => void;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onDeleteUser: (userId: string) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
onDeleteMembership: (membershipId: string) => void;
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
|
|
@ -468,12 +471,16 @@ export function AdminOverlay({
|
|||
/>
|
||||
) : null}
|
||||
{activeSection === "users" ? (
|
||||
<UsersSection
|
||||
data={data}
|
||||
clientId={scopedClientId}
|
||||
onCreateUser={onCreateUser}
|
||||
onUpdateUser={onUpdateUser}
|
||||
/>
|
||||
isPlatformMode ? (
|
||||
<PlatformUsersSection data={data} onUpdateUser={onUpdateUser} onDeleteUser={onDeleteUser} />
|
||||
) : (
|
||||
<UsersSection
|
||||
data={data}
|
||||
clientId={scopedClientId}
|
||||
onCreateUser={onCreateUser}
|
||||
onUpdateUser={onUpdateUser}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{activeSection === "groups" ? (
|
||||
<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({
|
||||
data,
|
||||
clientId,
|
||||
|
|
@ -1156,6 +1307,9 @@ const inviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = [
|
|||
{ value: "expired", label: "Истёк", tone: "red" },
|
||||
{ value: "revoked", label: "Отозван", tone: "red" },
|
||||
];
|
||||
const editableInviteStatusOptions: Array<AdminStatusOption<InviteStatus>> = inviteStatusOptions.filter(
|
||||
(option) => option.value !== "accepted"
|
||||
);
|
||||
|
||||
const accessRequestStatusOptions: Array<AdminStatusOption<AccessRequestStatus>> = [
|
||||
{ value: "new", label: "Входящая", tone: "yellow" },
|
||||
|
|
@ -1216,6 +1370,23 @@ function membershipRoleLabel(role: ClientMembershipRole): string {
|
|||
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 {
|
||||
return options.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
|
@ -2715,7 +2886,7 @@ function AccessSection({
|
|||
|
||||
if (!hasUsers) {
|
||||
return (
|
||||
<div className="access-layout">
|
||||
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||
<GlassSurface className="access-matrix">
|
||||
<div className="table-toolbar">
|
||||
<h3>Матрица доступа · {matrix.client.name}</h3>
|
||||
|
|
@ -2733,7 +2904,7 @@ function AccessSection({
|
|||
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
|
||||
|
||||
return (
|
||||
<div className="access-layout">
|
||||
<div className={cn("access-layout", isPublicPoolContext && "access-layout--single")}>
|
||||
<GlassSurface className="access-matrix">
|
||||
<div className="table-toolbar">
|
||||
<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({
|
||||
value,
|
||||
protectedUser,
|
||||
|
|
@ -3245,6 +3561,8 @@ function InvitesSection({
|
|||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
const [publicInviteTab, setPublicInviteTab] = useState<"incoming" | "outgoing">("incoming");
|
||||
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
||||
const incomingRequestsTotal =
|
||||
data.accessRequests.length + data.taskerInviteRequests.filter((request) => request.status !== "cancelled").length;
|
||||
const pendingIncomingRequests =
|
||||
data.accessRequests.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")}
|
||||
onClick={() => setPublicInviteTab("incoming")}
|
||||
>
|
||||
Входящие · {pendingIncomingRequests}
|
||||
Входящие · {incomingRequestsTotal}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -3304,7 +3622,9 @@ function InvitesSection({
|
|||
</button>
|
||||
</div>
|
||||
<p className="admin-helper-note">
|
||||
Входящие — заявки из формы “Запросить доступ”. Исходящие — инвайты, которые root-admin выпускает руками без заявки.
|
||||
{publicInviteTab === "incoming"
|
||||
? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.`
|
||||
: "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}
|
||||
</p>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
|
@ -3354,12 +3674,20 @@ function InvitesSection({
|
|||
|
||||
<GlassSurface className="table-shell">
|
||||
<div className="table-toolbar">
|
||||
<h3>Инвайты</h3>
|
||||
<div>
|
||||
<h3>{isPublicPoolContext ? "Исходящие инвайты" : "Инвайты"}</h3>
|
||||
{isPublicPoolContext ? (
|
||||
<p className="admin-helper-note">
|
||||
Отзыв блокирует только неиспользованную ссылку. Уже принятый инвайт управляется через Root Admin → Пользователи.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<table className="admin-data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Источник</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Ссылка</th>
|
||||
|
|
@ -3371,62 +3699,92 @@ function InvitesSection({
|
|||
{invites.map((invite) => {
|
||||
const inviteUrl = buildInviteUrl(invite.token);
|
||||
const isCopied = copiedInviteId === invite.id;
|
||||
const isAccepted = invite.status === "accepted";
|
||||
const canCopyInvite = inviteCanBeCopied(invite);
|
||||
|
||||
return (
|
||||
<tr key={invite.id}>
|
||||
<td>
|
||||
<input
|
||||
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}`}
|
||||
/>
|
||||
{isAccepted ? (
|
||||
<span className="admin-table-text admin-table-text--strong">{invite.email}</span>
|
||||
) : (
|
||||
<input
|
||||
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>
|
||||
<NodeDcSelect
|
||||
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>
|
||||
<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 className="membership-inviter-cell">
|
||||
<span>{inviteSourceLabel(invite)}</span>
|
||||
{invite.sourceWorkspaceName || invite.sourceWorkspaceSlug ? (
|
||||
<small>{invite.sourceWorkspaceName ?? invite.sourceWorkspaceSlug}</small>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcDateField
|
||||
value={invite.expiresAt}
|
||||
label={`Инвайт истекает ${invite.email}`}
|
||||
onChange={(value) => {
|
||||
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
||||
}}
|
||||
/>
|
||||
{isAccepted ? (
|
||||
<AdminStaticPill>{membershipRoleLabel(invite.role)}</AdminStaticPill>
|
||||
) : (
|
||||
<NodeDcSelect
|
||||
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 className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Удалить инвайт ${invite.email}`}
|
||||
label={`Удалить запись инвайта ${invite.email}`}
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => setDeleteInviteId(invite.id)}
|
||||
|
|
@ -3442,10 +3800,11 @@ function InvitesSection({
|
|||
</GlassSurface>
|
||||
<NodeDcDeleteModal
|
||||
isOpen={Boolean(deletingInvite)}
|
||||
title="Удалить инвайт"
|
||||
title="Удалить запись инвайта"
|
||||
description={
|
||||
<>
|
||||
Инвайт для <strong>{deletingInvite?.email}</strong> будет удален вместе с токеном приглашения.
|
||||
Будет удалена только запись invite-ссылки для <strong>{deletingInvite?.email}</strong>. Пользователь, доступы и
|
||||
история аккаунта не удаляются.
|
||||
</>
|
||||
}
|
||||
onClose={() => setDeleteInviteId(null)}
|
||||
|
|
@ -3532,6 +3891,7 @@ function AccessRequestsPanel({
|
|||
: null;
|
||||
const isTerminal = accessRequest.status !== "new";
|
||||
const isCopied = Boolean(approvedInvite && copiedInviteId === approvedInvite.id);
|
||||
const approvedInviteCanBeCopied = Boolean(approvedInvite && inviteCanBeCopied(approvedInvite));
|
||||
|
||||
return (
|
||||
<tr key={accessRequest.id}>
|
||||
|
|
@ -3576,7 +3936,7 @@ function AccessRequestsPanel({
|
|||
<AdminStatusPill value={accessRequest.status} options={accessRequestStatusOptions} />
|
||||
</td>
|
||||
<td>
|
||||
{approvedInvite ? (
|
||||
{approvedInvite && approvedInviteCanBeCopied ? (
|
||||
<div className="invite-link-cell">
|
||||
<code title={buildInviteUrl(approvedInvite.token)}>{buildInviteUrl(approvedInvite.token)}</code>
|
||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||
|
|
@ -3589,6 +3949,8 @@ function AccessRequestsPanel({
|
|||
<Copy size={11} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : approvedInvite ? (
|
||||
<span className="muted-text">{inviteTerminalLabel(approvedInvite)}</span>
|
||||
) : accessRequest.status === "approved" ? (
|
||||
<span className="muted-text">Активен</span>
|
||||
) : (
|
||||
|
|
@ -3784,7 +4146,7 @@ function formatAccessRequestName(accessRequest: AccessRequest) {
|
|||
}
|
||||
|
||||
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
|
||||
? data.taskerInviteRequests.find((request) => request.id === membership.sourceTaskerInviteRequestId)
|
||||
: 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 }) {
|
||||
const inviterMeta = getMembershipInviterMeta(data, membership);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue