From 2b34cf9f1bbd927c69b41e0f22674e45af189922 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 11 May 2026 12:20:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20NODEDC=20LAUNCHER:=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/storage/launcher-data.json | 416 +++++++++------- server/authentik-sync.mjs | 26 + server/control-plane-store.mjs | 205 ++++++++ server/dev-server.mjs | 28 ++ src/app/LauncherApp.tsx | 6 + src/shared/api/adminApi.ts | 4 + src/shared/api/mockApi.ts | 18 + src/styles/globals.css | 124 +++++ src/widgets/admin-overlay/AdminOverlay.tsx | 548 ++++++++++++++++++--- 9 files changed, 1130 insertions(+), 245 deletions(-) diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 9902783..ceb11da 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -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" + } ] } diff --git a/server/authentik-sync.mjs b/server/authentik-sync.mjs index 5001536..05648da 100644 --- a/server/authentik-sync.mjs +++ b/server/authentik-sync.mjs @@ -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, }; diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 99b9778..50c7ed0 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -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; } diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 45ae5e5..c3c17b7 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -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; diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 47bb6f5..bbfc7e5 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -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) { 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} diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 8d0fe41..26ec739 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -181,6 +181,10 @@ export async function updateAdminUserProfile(userId: string, patch: Partial { + return requestJson(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" }); +} + export async function createAdminUser(payload: { clientId: string; email: string; diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index 2dd1bed..707fd98 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -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 | 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, diff --git a/src/styles/globals.css b/src/styles/globals.css index 724103d..b223763 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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); diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index d324730..38b4199 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -129,6 +129,7 @@ export interface EnsureTaskManagerProjectMemberCommand { const platformSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ { id: "overview", label: "Обзор", icon: }, { id: "clients", label: "Компании", icon: }, + { id: "users", label: "Пользователи", icon: }, { id: "services", label: "Каталог сервисов", icon: }, { id: "sync", label: "Синхронизация", icon: }, { id: "audit", label: "Аудит", icon: }, @@ -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) => void; + onDeleteUser: (userId: string) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; onDeleteMembership: (membershipId: string) => void; pendingAccessAssignments: Record; @@ -468,12 +471,16 @@ export function AdminOverlay({ /> ) : null} {activeSection === "users" ? ( - + isPlatformMode ? ( + + ) : ( + + ) ) : null} {activeSection === "groups" ? ( ) => void; + onDeleteUser: (userId: string) => void; +}) { + const [deleteUserId, setDeleteUserId] = useState(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 ( + <> + +
+
+

Пользователи платформы

+

+ Глобальный реестр аккаунтов. Доступы к сервисам остаются в матрицах; здесь — происхождение, статус аккаунта и полное удаление. +

+
+
+ + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
ПользовательПроисхождениеКонтурыСтатус аккаунтаСоздан +
+
+ onUpdateUser(user.id, { name: event.target.value })} + aria-label={`Имя пользователя ${user.name}`} + /> + onUpdateUser(user.id, { email: event.target.value })} + aria-label={`Email пользователя ${user.name}`} + /> +
+
+
+ {origin.label} + {origin.detail ? {origin.detail} : null} +
+
+ + {contextLabel} + + + {protectedUser ? ( + {statusOptionLabel(userStatusOptions, user.globalStatus)} + ) : ( + onUpdateUser(user.id, { globalStatus: status })} + /> + )} + {formatDate(user.createdAt)} + {protectedUser ? ( + + ) : ( + setDeleteUserId(user.id)} + > + + + )} +
+
+ + + Будут удалены профиль {deletingUser?.email}, членства, сервисные доступы, заявки, инвайты и sync-записи. + Действие необратимо. + + } + confirmLabel="Удалить везде" + onClose={() => setDeleteUserId(null)} + onConfirm={() => { + if (deletingUser) onDeleteUser(deletingUser.id); + setDeleteUserId(null); + }} + /> + + ); +} + function GroupsSection({ data, clientId, @@ -1156,6 +1307,9 @@ const inviteStatusOptions: Array> = [ { value: "expired", label: "Истёк", tone: "red" }, { value: "revoked", label: "Отозван", tone: "red" }, ]; +const editableInviteStatusOptions: Array> = inviteStatusOptions.filter( + (option) => option.value !== "accepted" +); const accessRequestStatusOptions: Array> = [ { 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(options: Array>, value: T): string { return options.find((option) => option.value === value)?.label ?? value; } @@ -2715,7 +2886,7 @@ function AccessSection({ if (!hasUsers) { return ( -
+

Матрица доступа · {matrix.client.name}

@@ -2733,7 +2904,7 @@ function AccessSection({ const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`; return ( -
+

Матрица доступа · {matrix.client.name}

@@ -2854,6 +3025,151 @@ function AccessSection({ ); } +function PublicAccessUsersPanel({ + data, + matrix, + selectedCell, + onSelectCell, + onSetUserServiceAccess, + pendingAccessAssignments, + onUpdateUser, + onUpdateMembership, +}: { + data: LauncherData; + matrix: ReturnType; + selectedCell: AccessMatrixCell | null; + onSelectCell: (cell: AccessMatrixCell) => void; + onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; + pendingAccessAssignments: Record; + onUpdateUser: (userId: string, patch: Partial) => void; + onUpdateMembership: (membershipId: string, patch: Partial) => void; +}) { + const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null; + + return ( + +
+
+

Пользователи открытого контура

+

+ Блокировка аккаунта отключает вход через Authentik и не удаляет историю заявок, инвайтов и аудит. +

+
+
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
ПользовательКто пригласилРоль в контуреСтатус в контуреСтатус аккаунтаOperational Core
+
+ onUpdateUser(user.id, { name: event.target.value })} + aria-label={`Имя пользователя ${user.name}`} + /> + onUpdateUser(user.id, { email: event.target.value })} + aria-label={`Email пользователя ${user.name}`} + /> +
+
+ {inviterMeta.showInAccessMatrix ? ( +
+ {inviterMeta.title} + {inviterMeta.sourceLabel} +
+ ) : ( + + )} +
+ {protectedUser ? ( + {membershipRoleLabel(membership.role)} + ) : ( + onUpdateMembership(membership.id, { role })} + /> + )} + + onUpdateMembership(membership.id, { status })} + /> + + {protectedUser ? ( + {statusOptionLabel(userStatusOptions, user.globalStatus)} + ) : ( + onUpdateUser(user.id, { globalStatus: status })} + /> + )} + + {operationalCoreCell ? ( + + onSetUserServiceAccess({ + userId: user.id, + serviceId: operationalCoreCell.serviceId, + value, + }) + } + /> + ) : ( + + )} +
+
+ ); +} + function MainStatusControl({ value, protectedUser, @@ -3245,6 +3561,8 @@ function InvitesSection({ const [copiedInviteId, setCopiedInviteId] = useState(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}

- Входящие — заявки из формы “Запросить доступ”. Исходящие — инвайты, которые root-admin выпускает руками без заявки. + {publicInviteTab === "incoming" + ? `Входящие — заявки доступа и workspace-инвайты из Operational Core. Новых к обработке: ${pendingIncomingRequests}.` + : "Исходящие — invite-ссылки, выпущенные вручную или созданные после approve. Принятые ссылки остаются историей."}

) : null} @@ -3354,12 +3674,20 @@ function InvitesSection({
-

Инвайты

+
+

{isPublicPoolContext ? "Исходящие инвайты" : "Инвайты"}

+ {isPublicPoolContext ? ( +

+ Отзыв блокирует только неиспользованную ссылку. Уже принятый инвайт управляется через Root Admin → Пользователи. +

+ ) : null} +
+ @@ -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 ( - - + + + @@ -3576,7 +3936,7 @@ function AccessRequestsPanel({
EmailИсточник Роль Статус Ссылка
- onUpdateInvite(invite.id, { email: event.target.value })} - aria-label={`Email инвайта ${invite.email}`} - /> + {isAccepted ? ( + {invite.email} + ) : ( + onUpdateInvite(invite.id, { email: event.target.value })} + aria-label={`Email инвайта ${invite.email}`} + /> + )} - onUpdateInvite(invite.id, { role: nextRole })} - /> - - onUpdateInvite(invite.id, { status })} - /> - -
- {inviteUrl} - {isCopied ? Скопировано : null} - void handleCopyInvite(invite)} - > - - +
+ {inviteSourceLabel(invite)} + {invite.sourceWorkspaceName || invite.sourceWorkspaceSlug ? ( + {invite.sourceWorkspaceName ?? invite.sourceWorkspaceSlug} + ) : null}
- { - if (value) onUpdateInvite(invite.id, { expiresAt: value }); - }} - /> + {isAccepted ? ( + {membershipRoleLabel(invite.role)} + ) : ( + onUpdateInvite(invite.id, { role: nextRole })} + /> + )} + + {isAccepted ? ( + + ) : ( + onUpdateInvite(invite.id, { status })} + /> + )} + + {canCopyInvite ? ( +
+ {inviteUrl} + {isCopied ? Скопировано : null} + void handleCopyInvite(invite)} + > + + +
+ ) : ( + {inviteTerminalLabel(invite)} + )} +
+ {isAccepted ? ( + {formatDate(invite.expiresAt)} + ) : ( + { + if (value) onUpdateInvite(invite.id, { expiresAt: value }); + }} + /> + )} setDeleteInviteId(invite.id)} @@ -3442,10 +3800,11 @@ function InvitesSection({ - Инвайт для {deletingInvite?.email} будет удален вместе с токеном приглашения. + Будет удалена только запись invite-ссылки для {deletingInvite?.email}. Пользователь, доступы и + история аккаунта не удаляются. } 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 (
- {approvedInvite ? ( + {approvedInvite && approvedInviteCanBeCopied ? (
{buildInviteUrl(approvedInvite.token)} {isCopied ? Скопировано : null} @@ -3589,6 +3949,8 @@ function AccessRequestsPanel({
+ ) : approvedInvite ? ( + {inviteTerminalLabel(approvedInvite)} ) : accessRequest.status === "approved" ? ( Активен ) : ( @@ -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);