diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 8674199..298e6a8 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -49,7 +49,7 @@ "avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg", "globalStatus": "active", "createdAt": "2026-05-04T00:00:00.000Z", - "updatedAt": "2026-05-04T15:26:08.500Z" + "updatedAt": "2026-05-07T11:04:47.398Z" }, { "id": "user_constr_dc_yahoo_com", @@ -62,7 +62,7 @@ "avatarUrl": "/storage/uploads/1777992885416-502c0a5d-94-944112_unicorn-clipart-mystical-unicorn-web-server.png", "globalStatus": "active", "createdAt": "2026-05-05T14:53:26.607Z", - "updatedAt": "2026-05-05T14:57:13.515Z" + "updatedAt": "2026-05-07T09:41:41.158Z" }, { "id": "user_support_dctouch_ru", @@ -88,7 +88,7 @@ "avatarUrl": null, "globalStatus": "active", "createdAt": "2026-05-05T17:26:50.184Z", - "updatedAt": "2026-05-05T17:27:40.754Z" + "updatedAt": "2026-05-07T11:39:41.562Z" }, { "id": "user_abramov_dcconstructions_ru", @@ -131,19 +131,19 @@ "id": "mem_silver_psih_dctouch", "clientId": "client_romashka", "userId": "user_silver_psih", - "role": "member", + "role": "client_admin", "status": "active", "createdAt": "2026-05-04T00:00:00.000Z", - "updatedAt": "2026-05-04T12:55:13.842Z" + "updatedAt": "2026-05-07T11:04:47.173Z" }, { "id": "mem_client_romashka_constr_dc_yahoo_com", "clientId": "client_romashka", "userId": "user_constr_dc_yahoo_com", - "role": "member", + "role": "client_admin", "status": "active", "createdAt": "2026-05-05T14:53:26.607Z", - "updatedAt": "2026-05-05T14:53:26.607Z" + "updatedAt": "2026-05-07T09:41:39.935Z" }, { "id": "mem_client_romashka_support_dctouch_ru", @@ -158,10 +158,10 @@ "id": "mem_client_romashka_silverpsih007_gmail_com", "clientId": "client_romashka", "userId": "user_silverpsih007_gmail_com", - "role": "member", + "role": "client_admin", "status": "active", "createdAt": "2026-05-05T17:26:50.184Z", - "updatedAt": "2026-05-05T17:26:50.184Z" + "updatedAt": "2026-05-07T11:39:39.407Z" }, { "id": "mem_client_romashka_abramov_dcconstructions_ru", @@ -521,9 +521,9 @@ "objectType": "user", "target": "authentik", "state": "synced", - "lastSyncAt": "2026-05-04T15:26:08.500Z", + "lastSyncAt": "2026-05-07T11:04:47.398Z", "error": null, - "updatedAt": "2026-05-04T15:26:08.500Z" + "updatedAt": "2026-05-07T11:04:47.398Z" }, { "id": "sync_dctouch_groups_authentik", @@ -587,9 +587,9 @@ "objectType": "user", "target": "authentik", "state": "synced", - "lastSyncAt": "2026-05-05T14:57:13.515Z", + "lastSyncAt": "2026-05-07T09:41:41.158Z", "error": null, - "updatedAt": "2026-05-05T14:57:13.515Z" + "updatedAt": "2026-05-07T09:41:41.158Z" }, { "id": "sync_grant_service_task_manager_user_constr_dc_yahoo_com", @@ -653,9 +653,9 @@ "objectType": "user", "target": "authentik", "state": "synced", - "lastSyncAt": "2026-05-05T17:27:40.754Z", + "lastSyncAt": "2026-05-07T11:39:41.562Z", "error": null, - "updatedAt": "2026-05-05T17:27:40.754Z" + "updatedAt": "2026-05-07T11:39:41.562Z" }, { "id": "sync_grant_service_task_manager_user_silverpsih007_gmail_com", @@ -2163,6 +2163,210 @@ "clientId": "client_romashka", "result": "success", "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_constr_dc_yahoo_com_11", + "at": "2026-05-07T09:41:39.942Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлено членство", + "objectType": "user", + "objectName": "constr_dc@yahoo.com", + "clientId": "client_romashka", + "result": "success", + "details": "Role: client_admin; status: active" + }, + { + "id": "audit_constr_dc_yahoo_com_12", + "at": "2026-05-07T09:41:41.158Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "constr_dc@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" + }, + { + "id": "audit_dc_constr_7", + "at": "2026-05-07T09:41:45.137Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC CONSTR", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: admin" + }, + { + "id": "audit_silver_psih_yahoo_com_43", + "at": "2026-05-07T11:04:26.162Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Обновлено членство", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": "client_romashka", + "result": "success", + "details": "Role: client_admin; status: active" + }, + { + "id": "audit_silver_psih_yahoo_com_44", + "at": "2026-05-07T11:04:26.890Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_dc_silver_3", + "at": "2026-05-07T11:04:32.210Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SILVER", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: admin" + }, + { + "id": "audit_dc_silver_4", + "at": "2026-05-07T11:04:34.414Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SILVER", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_dc_silver_5", + "at": "2026-05-07T11:04:40.683Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SILVER", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: admin" + }, + { + "id": "audit_dc_silver_6", + "at": "2026-05-07T11:04:42.616Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Назначен Tasker workspace", + "objectType": "task-manager-membership", + "objectName": "DC SILVER", + "clientId": "client_romashka", + "result": "success", + "details": "Workspace: NODE DC; Role: member" + }, + { + "id": "audit_silver_psih_yahoo_com_45", + "at": "2026-05-07T11:04:45.081Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Обновлено членство", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": "client_romashka", + "result": "success", + "details": "Role: client_admin; status: disabled" + }, + { + "id": "audit_silver_psih_yahoo_com_46", + "at": "2026-05-07T11:04:45.343Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user" + }, + { + "id": "audit_silver_psih_yahoo_com_47", + "at": "2026-05-07T11:04:47.173Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Обновлено членство", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": "client_romashka", + "result": "success", + "details": "Role: client_admin; status: active" + }, + { + "id": "audit_silver_psih_yahoo_com_48", + "at": "2026-05-07T11:04:47.399Z", + "actorUserId": "user_constr_dc_yahoo_com", + "actorName": "DC CONSTR", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silver_psih@yahoo.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin" + }, + { + "id": "audit_silverpsih007_gmail_com_6", + "at": "2026-05-07T11:39:37.305Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлено членство", + "objectType": "user", + "objectName": "silverpsih007@gmail.com", + "clientId": "client_romashka", + "result": "success", + "details": "Role: client_admin; status: active" + }, + { + "id": "audit_silverpsih007_gmail_com_7", + "at": "2026-05-07T11:39:39.407Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Обновлено членство", + "objectType": "user", + "objectName": "silverpsih007@gmail.com", + "clientId": "client_romashka", + "result": "success", + "details": "Role: client_admin; status: active" + }, + { + "id": "audit_silverpsih007_gmail_com_8", + "at": "2026-05-07T11:39:40.076Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silverpsih007@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" + }, + { + "id": "audit_silverpsih007_gmail_com_9", + "at": "2026-05-07T11:39:41.562Z", + "actorUserId": "user_root", + "actorName": "DC SUDO", + "action": "Пользователь синхронизирован в Authentik", + "objectType": "user", + "objectName": "silverpsih007@gmail.com", + "clientId": null, + "result": "success", + "details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user" } ], "settings": { @@ -2183,7 +2387,7 @@ "role": "member", "planeUserId": "7315d59a-50e1-4d26-8de8-ae632777b46e", "planeRole": 15, - "updatedAt": "2026-05-06T10:51:20.911Z" + "updatedAt": "2026-05-07T11:04:42.615Z" }, { "id": "tasker_mem_client_romashka_user_constr_dc_yahoo_com_nodedc", @@ -2191,10 +2395,10 @@ "userId": "user_constr_dc_yahoo_com", "workspaceSlug": "nodedc", "workspaceName": "NODE DC", - "role": "member", + "role": "admin", "planeUserId": "62aa744d-32ef-427f-8ad2-184d2ec2f5e5", - "planeRole": 15, - "updatedAt": "2026-05-06T09:46:41.427Z" + "planeRole": 20, + "updatedAt": "2026-05-07T09:41:45.137Z" }, { "id": "tasker_mem_client_romashka_user_support_dctouch_ru_nodedc", diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 69652b4..6cd985a 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -506,21 +506,26 @@ app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, re })); app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => { - res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user)); + res.json(scopeAdminSnapshot(req)); }); -app.patch("/api/admin/settings", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.patch("/api/admin/settings", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.settings.updated"); res.json(result); })); app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => { - const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const snapshot = scopeAdminSnapshot(req); res.json({ clients: snapshot.data.clients }); }); -app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (_req, res) => { +app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!req.nodedcAdminScope?.isRoot) { + res.json({ ok: true, workspaces: [] }); + return; + } + const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/"); res.json(taskManager); })); @@ -542,6 +547,10 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher return; } + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + return; + } + const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id); const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null; @@ -576,7 +585,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher ); publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]); - res.json({ ...result, taskManager }); + res.json({ ...scopeAdminMutationResult(req, result), taskManager }); })); app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncherAdmin, asyncRoute(async (req, res) => { @@ -596,6 +605,10 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher return; } + if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) { + return; + } + const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id); const workspaceSlug = client.integrations?.taskManager?.workspaceSlug ?? null; @@ -629,7 +642,7 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher ); publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]); - res.json({ ...result, taskManager, protected: true }); + res.json({ ...scopeAdminMutationResult(req, result), taskManager, protected: true }); return; } @@ -651,30 +664,34 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher ); publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]); - res.json({ ...result, taskManager }); + res.json({ ...scopeAdminMutationResult(req, result), taskManager }); })); -app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.post("/api/admin/clients", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user); res.status(201).json(result); })); -app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.patch("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.updateClient(req.params.clientId, req.body, req.nodedcSession.user); res.json(result); })); -app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.delete("/api/admin/clients/:clientId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.deleteClient(req.params.clientId, req.nodedcSession.user); res.json(result); })); app.get("/api/admin/users", requireLauncherAdmin, (req, res) => { - const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const snapshot = scopeAdminSnapshot(req); res.json({ users: snapshot.data.users, memberships: snapshot.data.memberships }); }); app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageClient(req, res, req.body?.clientId)) { + return; + } + const result = await controlPlaneStore.createUser(req.body, req.nodedcSession.user); let provisioning = null; @@ -691,17 +708,25 @@ app.post("/api/admin/users", requireLauncherAdmin, asyncRoute(async (req, res) = } publishControlPlaneEvent("admin.user.created", [result.user.id]); - res.status(201).json({ ...result, provisioning }); + res.status(201).json({ ...scopeAdminMutationResult(req, result), provisioning }); })); app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageUser(req, res, req.params.userId)) { + return; + } + const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user); publishControlPlaneEvent("admin.user.updated", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageUser(req, res, req.params.userId)) { + return; + } + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); const provisionedUser = await authentikSyncClient.provisionUser({ data: snapshot.data, @@ -712,51 +737,118 @@ app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, a const result = await controlPlaneStore.markUserAuthentikProvisioned(req.params.userId, provisionedUser, req.nodedcSession.user); publishControlPlaneEvent("admin.user.provisioned", [req.params.userId]); - res.json({ ...result, provisioning: toProvisioningResponse(provisionedUser) }); + res.json({ ...scopeAdminMutationResult(req, result), provisioning: toProvisioningResponse(provisionedUser) }); })); app.patch("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId); + + if (!membership) { + res.status(404).json({ error: "membership_not_found" }); + return; + } + + if (!assertAdminCanManageMembership(req, res, membership)) { + return; + } + const result = await controlPlaneStore.updateMembership(req.params.membershipId, req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user); publishControlPlaneEvent("admin.membership.updated", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.delete("/api/admin/memberships/:membershipId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const membership = snapshot.data.memberships.find((candidate) => candidate.id === req.params.membershipId); + + if (!membership) { + res.status(404).json({ error: "membership_not_found" }); + return; + } + + if (!assertAdminCanManageMembership(req, res, membership)) { + return; + } + const result = await controlPlaneStore.deleteMembership(req.params.membershipId, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [result.membership.userId], req.nodedcSession.user); publishControlPlaneEvent("admin.membership.deleted", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.post("/api/admin/invites", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageClient(req, res, req.body?.clientId)) { + return; + } + const result = await controlPlaneStore.createInvite(req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.invite.created"); - res.status(201).json(result); + res.status(201).json(scopeAdminMutationResult(req, result)); })); app.patch("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId); + + if (!invite) { + res.status(404).json({ error: "invite_not_found" }); + return; + } + + if (!assertAdminCanManageClient(req, res, invite.clientId)) { + return; + } + const result = await controlPlaneStore.updateInvite(req.params.inviteId, req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.invite.updated"); - res.json(result); + res.json(scopeAdminMutationResult(req, result)); })); app.delete("/api/admin/invites/:inviteId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const invite = snapshot.data.invites.find((candidate) => candidate.id === req.params.inviteId); + + if (!invite) { + res.status(404).json({ error: "invite_not_found" }); + return; + } + + if (!assertAdminCanManageClient(req, res, invite.clientId)) { + return; + } + const result = await controlPlaneStore.deleteInvite(req.params.inviteId, req.nodedcSession.user); publishControlPlaneEvent("admin.invite.deleted"); - res.json(result); + res.json(scopeAdminMutationResult(req, result)); })); app.post("/api/admin/groups", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageClient(req, res, req.body?.clientId)) { + return; + } + const result = await controlPlaneStore.createGroup(req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user); publishControlPlaneEvent("admin.group.created", syncResult.userIds); - res.status(201).json({ ...result, data: syncResult.data }); + res.status(201).json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => { const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); - const previousMemberIds = beforeSnapshot.data.groups.find((group) => group.id === req.params.groupId)?.memberIds ?? []; + const group = beforeSnapshot.data.groups.find((candidate) => candidate.id === req.params.groupId); + + if (!group) { + res.status(404).json({ error: "group_not_found" }); + return; + } + + if (!assertAdminCanManageClient(req, res, group.clientId)) { + return; + } + + const previousMemberIds = group.memberIds; const result = await controlPlaneStore.updateGroup(req.params.groupId, req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik( result.data, @@ -764,41 +856,59 @@ app.patch("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async ( req.nodedcSession.user ); publishControlPlaneEvent("admin.group.updated", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.delete("/api/admin/groups/:groupId", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const group = snapshot.data.groups.find((candidate) => candidate.id === req.params.groupId); + + if (!group) { + res.status(404).json({ error: "group_not_found" }); + return; + } + + if (!assertAdminCanManageClient(req, res, group.clientId)) { + return; + } + const result = await controlPlaneStore.deleteGroup(req.params.groupId, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, result.group.memberIds, req.nodedcSession.user); publishControlPlaneEvent("admin.group.deleted", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); -app.post("/api/admin/services", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.post("/api/admin/services", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.createService(req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.service.created"); res.status(201).json(result); })); -app.patch("/api/admin/services/reorder", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.patch("/api/admin/services/reorder", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.reorderServices(req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.service.reordered"); res.json(result); })); -app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.patch("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.updateService(req.params.serviceId, req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.service.updated"); res.json(result); })); -app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.delete("/api/admin/services/:serviceId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.deleteService(req.params.serviceId, req.nodedcSession.user); publishControlPlaneEvent("admin.service.deleted"); res.json(result); })); app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req, res) => { + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + + if (!assertAdminCanManageGrantTarget(req, res, snapshot.data, req.body?.targetType, req.body?.targetId)) { + return; + } + const result = await controlPlaneStore.upsertGrant(req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik( result.data, @@ -806,30 +916,38 @@ app.post("/api/admin/access/grants", requireLauncherAdmin, asyncRoute(async (req req.nodedcSession.user ); publishControlPlaneEvent("admin.access.grant.updated", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.post("/api/admin/access/exceptions", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageUser(req, res, req.body?.userId)) { + return; + } + const result = await controlPlaneStore.upsertException(req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [result.exception.userId], req.nodedcSession.user); publishControlPlaneEvent("admin.access.exception.updated", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageUser(req, res, req.body?.userId)) { + return; + } + const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user); publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds); - res.json({ ...result, data: syncResult.data }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); -app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user); publishControlPlaneEvent("admin.sync.retry"); res.json(result); })); -app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, (_req, res) => { +app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => { res.json(controlPlaneStore.buildAuthentikSyncPlan()); }); @@ -838,7 +956,7 @@ app.post("/api/storage/upload", asyncRoute(async (req, res) => { res.json(result); })); -app.post("/api/storage/data", requireLauncherAdmin, asyncRoute(async (req, res) => { +app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { await saveLauncherData(req.body); publishControlPlaneEvent("storage.data.updated"); res.json({ ok: true, url: "/storage/launcher-data.json" }); @@ -1844,13 +1962,24 @@ function requireLauncherAdmin(req, res, next) { } const runtimeContext = getRuntimeSessionContext(session); + const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups); - if (!isLauncherAdmin(runtimeContext.groups)) { + if (!adminScope.isRoot && adminScope.clientIds.size === 0) { res.status(403).json({ error: "Недостаточно прав Launcher admin" }); return; } req.nodedcSession = { ...session, user: runtimeContext.user }; + req.nodedcAdminScope = adminScope; + next(); +} + +function requireRootLauncherAdmin(req, res, next) { + if (!req.nodedcAdminScope?.isRoot) { + res.status(403).json({ error: "Действие доступно только суперпользователю NODE.DC" }); + return; + } + next(); } @@ -1935,6 +2064,158 @@ function isLauncherAdmin(groups) { return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin"); } +const clientAdminMembershipRoles = new Set(["client_owner", "client_admin"]); +const protectedLauncherUserIds = new Set(["user_root"]); + +function resolveAdminScope(identity, groups) { + const snapshot = controlPlaneStore.getSnapshot(identity); + const actorId = snapshot.actor.source === "launcher" ? snapshot.actor.id : null; + const isRoot = isLauncherAdmin(groups); + const clientIds = new Set( + isRoot + ? snapshot.data.clients.map((client) => client.id) + : snapshot.data.memberships + .filter((membership) => membership.userId === actorId && membership.status === "active" && clientAdminMembershipRoles.has(membership.role)) + .map((membership) => membership.clientId) + ); + + return { + actorId, + clientIds, + isRoot, + snapshot, + }; +} + +function canAdminManageClient(req, clientId) { + return Boolean(req.nodedcAdminScope?.isRoot || req.nodedcAdminScope?.clientIds.has(clientId)); +} + +function canAdminManageUser(req, userId) { + if (protectedLauncherUserIds.has(userId)) { + return false; + } + + if (req.nodedcAdminScope?.isRoot) { + return true; + } + + return req.nodedcAdminScope?.snapshot.data.memberships.some( + (membership) => membership.userId === userId && req.nodedcAdminScope.clientIds.has(membership.clientId) + ); +} + +function assertAdminCanManageClient(req, res, clientId) { + if (canAdminManageClient(req, clientId)) { + return true; + } + + res.status(403).json({ error: "Недостаточно прав для управления этим клиентом" }); + return false; +} + +function assertAdminCanManageUser(req, res, userId) { + if (canAdminManageUser(req, userId)) { + return true; + } + + res.status(403).json({ error: "Недостаточно прав для управления этим пользователем" }); + return false; +} + +function assertAdminCanManageMembership(req, res, membership) { + if (!assertAdminCanManageClient(req, res, membership.clientId)) { + return false; + } + + return assertAdminCanManageUser(req, res, membership.userId); +} + +function assertAdminCanManageGrantTarget(req, res, data, targetType, targetId) { + if (req.nodedcAdminScope?.isRoot) { + return true; + } + + if (targetType === "client") { + return assertAdminCanManageClient(req, res, targetId); + } + + if (targetType === "group") { + const group = data.groups.find((candidate) => candidate.id === targetId); + + if (!group) { + res.status(404).json({ error: "group_not_found" }); + return false; + } + + return assertAdminCanManageClient(req, res, group.clientId); + } + + if (targetType === "user") { + return assertAdminCanManageUser(req, res, targetId); + } + + res.status(403).json({ error: "Недостаточно прав для управления этим доступом" }); + return false; +} + +function scopeAdminSnapshot(req, snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user)) { + return { + ...snapshot, + data: scopeControlPlaneData(snapshot.data, req.nodedcAdminScope), + }; +} + +function scopeAdminMutationResult(req, result) { + if (!result?.data) { + return result; + } + + return { + ...result, + data: scopeControlPlaneData(result.data, req.nodedcAdminScope), + }; +} + +function scopeControlPlaneData(data, scope) { + if (!scope || scope.isRoot) { + return data; + } + + const clientIds = scope.clientIds; + const memberships = data.memberships.filter((membership) => clientIds.has(membership.clientId)); + const userIds = new Set(memberships.map((membership) => membership.userId)); + + if (scope.actorId) { + userIds.add(scope.actorId); + } + + const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id)); + + return { + ...data, + clients: data.clients.filter((client) => clientIds.has(client.id)), + users: data.users.filter((user) => userIds.has(user.id)), + memberships, + groups: data.groups.filter((group) => clientIds.has(group.clientId)), + invites: data.invites.filter((invite) => clientIds.has(invite.clientId)), + grants: data.grants.filter((grant) => { + if (grant.targetType === "client") return clientIds.has(grant.targetId); + if (grant.targetType === "group") return groupIds.has(grant.targetId); + if (grant.targetType === "user") return userIds.has(grant.targetId); + return false; + }), + exceptions: data.exceptions.filter((exception) => userIds.has(exception.userId)), + taskManagerMemberships: data.taskManagerMemberships.filter( + (membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId) + ), + syncStatuses: data.syncStatuses.filter( + (syncStatus) => clientIds.has(syncStatus.objectId) || userIds.has(syncStatus.objectId) || groupIds.has(syncStatus.objectId) + ), + auditEvents: data.auditEvents.filter((event) => !event.clientId || clientIds.has(event.clientId)), + }; +} + function cookieOptions(maxAgeMs) { const options = { httpOnly: true, diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index eed32b2..6cbcb50 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -117,6 +117,7 @@ export function LauncherApp() { }; }, [authSession, me]); const resolvedClientId = me.activeClientId; + const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin); const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]); const launcherServices = useMemo( () => { @@ -312,7 +313,7 @@ export function LauncherApp() { }, []); useEffect(() => { - if (!authSession?.authenticated || !canUseAdminApi(authSession)) return; + if (!canOpenAdminApi) return; let isMounted = true; @@ -329,12 +330,12 @@ export function LauncherApp() { return () => { isMounted = false; }; - }, [authSession]); + }, [canOpenAdminApi]); useEffect(() => { - if (!adminOpen || !authSession?.authenticated || !canUseAdminApi(authSession)) return; + if (!adminOpen || !canOpenAdminApi) return; void refreshTaskManagerWorkspaces(); - }, [adminOpen, authSession]); + }, [adminOpen, canOpenAdminApi]); useEffect(() => { if (!authSession?.authenticated) return; @@ -354,8 +355,10 @@ export function LauncherApp() { return; } + const nextContext = resolveAuthenticatedContext(data, nextSession, activeProfileId, activeClientId); + const nextMe = buildMe(data, nextContext.profileId, nextContext.clientId); const [persistedData, apps] = await Promise.all([ - canUseAdminApi(nextSession) + nextMe.permissions.canOpenAdmin ? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data) : loadPersistedLauncherData(), fetchAvailableApps(), @@ -779,13 +782,6 @@ function accessAssignmentKey(userId: string, serviceId: string) { return `${userId}:${serviceId}`; } -function canUseAdminApi(session: AuthSession): boolean { - return ( - session.authenticated && - (session.isSuperAdmin || session.groups.includes("nodedc:launcher:admin") || session.groups.includes("nodedc:superadmin")) - ); -} - function resolveAuthenticatedContext( data: LauncherData, session: AuthenticatedSession, diff --git a/src/styles/globals.css b/src/styles/globals.css index df2a706..a448694 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -651,6 +651,11 @@ code { display: none; } +.launcher-main:has(.admin-panel-layer--fullscreen) .service-rail { + opacity: 0; + pointer-events: none; +} + .service-stage { position: absolute; inset: 0; @@ -665,9 +670,15 @@ code { transition: padding-left 440ms cubic-bezier(0.22, 1, 0.36, 1), padding-right 440ms cubic-bezier(0.22, 1, 0.36, 1), + transform 440ms cubic-bezier(0.22, 1, 0.36, 1), background 220ms ease; } +.launcher-main:has(.admin-panel-layer--fullscreen) .service-stage { + transform: translateX(calc(100vw + var(--launcher-page-pad))); + pointer-events: none; +} + .service-stage--empty { background: #050506; } @@ -1388,6 +1399,11 @@ code { pointer-events: none; } +.admin-panel-layer--fullscreen { + right: var(--launcher-page-pad); + bottom: var(--launcher-page-pad); +} + .admin-panel-nav, .admin-panel-content { pointer-events: auto; @@ -1424,6 +1440,11 @@ code { animation: adminPanelSlide 460ms cubic-bezier(0.22, 1, 0.36, 1) both; } +.admin-panel-layer--fullscreen .admin-panel-content { + width: auto; + flex: 1 1 auto; +} + .profile-settings-layer { position: absolute; z-index: 9; @@ -1996,6 +2017,13 @@ code { color: rgb(var(--nodedc-on-accent-rgb)); } +.admin-circle-action--active, +.admin-circle-action--active:hover { + border-color: rgba(247, 248, 244, 0.98); + background: rgba(247, 248, 244, 0.98) !important; + color: rgb(var(--nodedc-on-accent-rgb)); +} + .admin-circle-action:disabled { cursor: default; opacity: 0.36; @@ -2070,8 +2098,36 @@ code { padding: 1rem; } +.access-matrix { + --access-matrix-table-bg: rgb(20, 20, 22); + background: var(--access-matrix-table-bg) !important; +} + +.table-shell--users { + position: relative; + --admin-users-table-bg: rgb(20, 20, 22); + margin-top: 1rem; + padding: 0 0 1rem; + background: var(--admin-users-table-bg) !important; +} + +.table-shell--users .table-toolbar { + position: sticky; + left: 0; + z-index: 6; + width: 16rem; + margin: 0; + padding: 1rem 1rem 0.7rem; + border-top-left-radius: var(--launcher-radius-card); + background: var(--admin-users-table-bg); +} + +.table-shell--users .admin-data-table { + margin: 0; +} + .admin-data-table--users { - min-width: 82rem; + min-width: 66rem; table-layout: fixed; } @@ -2082,7 +2138,16 @@ code { .admin-data-table--users th:nth-child(1), .admin-data-table--users td:nth-child(1) { - width: 17rem; + position: sticky; + left: 0; + z-index: 3; + width: 16rem; + min-width: 16rem; + background: var(--admin-users-table-bg); +} + +.admin-data-table--users th:nth-child(1) { + z-index: 4; } .admin-data-table--users th:nth-child(2), @@ -2092,29 +2157,50 @@ code { .admin-data-table--users th:nth-child(3), .admin-data-table--users td:nth-child(3) { - width: 8.5rem; + width: 12rem; } .admin-data-table--users th:nth-child(4), .admin-data-table--users td:nth-child(4) { - width: 13rem; + width: 12rem; } .admin-data-table--users th:nth-child(5), -.admin-data-table--users td:nth-child(5), +.admin-data-table--users td:nth-child(5) { + width: 18rem; +} + .admin-data-table--users th:nth-child(6), .admin-data-table--users td:nth-child(6) { - width: 10rem; + width: 10.2rem; } -.admin-data-table--users th:nth-child(7), -.admin-data-table--users td:nth-child(7) { - width: 9.6rem; +.admin-static-pill { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 2.08rem; + box-sizing: border-box; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.045); + color: var(--text-secondary); + padding: 0 0.72rem; + font-size: 0.74rem; + font-weight: 780; + line-height: 1; + white-space: nowrap; } -.admin-data-table--users th:nth-child(8), -.admin-data-table--users td:nth-child(8) { - width: 3.6rem; +.admin-table-action-placeholder { + display: inline-block; + width: var(--admin-control-ring); + height: var(--admin-control-ring); +} + +.admin-user-cell__fields { + display: grid; + gap: 0.2rem; } .table-toolbar { @@ -2975,6 +3061,81 @@ code { .matrix-scroll { overflow: auto; + border-radius: calc(var(--launcher-radius-card) - 0.85rem); + background: var(--access-matrix-table-bg); +} + +.access-matrix-grid { + display: grid; + min-width: max-content; + align-items: stretch; + background: var(--access-matrix-table-bg); +} + +.access-grid-head, +.access-grid-cell { + display: flex; + min-width: 0; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.075); + background: var(--access-matrix-table-bg); + padding: 0.48rem 0.75rem; +} + +.access-grid-head { + min-height: 2.1rem; + color: var(--text-muted); + font-size: 0.66rem; + font-weight: 850; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.access-grid-cell { + min-height: 4.25rem; +} + +.access-grid-sticky { + position: sticky; + left: 0; + z-index: 5; + background: var(--access-matrix-table-bg); + box-shadow: 1px 0 0 rgba(255, 255, 255, 0.035); +} + +.access-grid-head.access-grid-sticky { + z-index: 6; +} + +.access-user-cell { + display: grid; + align-content: center; + gap: 0.22rem; +} + +.access-user-cell strong, +.access-user-cell small { + display: block; + min-width: 0; +} + +.access-user-cell small { + color: var(--text-muted); + font-size: 0.72rem; +} + +.access-main-stack { + display: grid; + width: 10.8rem; + gap: 0.34rem; +} + +.access-cell--main { + min-width: 8.5rem; +} + +.access-cell--readonly { + cursor: default; } .access-cell { diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index a3eadc3..3701b77 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react"; import { closestCenter, DndContext, @@ -27,6 +27,8 @@ import { Link2, ListChecks, MailPlus, + Maximize2, + Minimize2, Plus, RefreshCw, Save, @@ -131,7 +133,6 @@ const clientSections: Array<{ id: AdminSection; label: string; icon: React.React { id: "access", label: "Доступы", icon: }, { id: "invites", label: "Инвайты", icon: }, { id: "company", label: "Профиль компании", icon: }, - { id: "sync", label: "Синхронизация", icon: }, ]; export function AdminOverlay({ @@ -202,6 +203,7 @@ export function AdminOverlay({ const isRoot = me.launcherRole === "root_admin"; const sections = isRoot ? rootSections : clientSections; const [activeSection, setActiveSection] = useState(null); + const [isContentFullscreen, setIsContentFullscreen] = useState(false); const [selectedClientId, setSelectedClientId] = useState(activeClientId); const [selectedCell, setSelectedCell] = useState<{ userId: string; serviceId: string } | null>(null); @@ -231,8 +233,18 @@ export function AdminOverlay({ return () => window.removeEventListener("keydown", handleKeyDown); }, [onClose]); + useEffect(() => { + if (!activeSection) setIsContentFullscreen(false); + }, [activeSection]); + return ( -
+