ФУНКЦИИ - NODEDC LAUNCHER: управление пользователями платформы

This commit is contained in:
DCCONSTRUCTIONS 2026-05-11 12:20:46 +03:00
parent 8be33c53da
commit 2b34cf9f1b
9 changed files with 1130 additions and 245 deletions

View File

@ -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"
}
] ]
} }

View File

@ -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,
}; };

View File

@ -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;
} }

View File

@ -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;

View File

@ -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}

View File

@ -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;

View File

@ -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,

View File

@ -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);

View File

@ -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);