FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: АДМИНКА ДОСТУПОВ И УЧАСТНИКОВ
This commit is contained in:
parent
5f461d57ea
commit
784b3ca5c3
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: <KeyRound size={16} /> },
|
||||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
||||
{ id: "company", label: "Профиль компании", icon: <Building2 size={16} /> },
|
||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||||
];
|
||||
|
||||
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<AdminSection | null>(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 (
|
||||
<div className={cn("admin-panel-layer", activeSection && "admin-panel-layer--content-open")}>
|
||||
<div
|
||||
className={cn(
|
||||
"admin-panel-layer",
|
||||
activeSection && "admin-panel-layer--content-open",
|
||||
isContentFullscreen && "admin-panel-layer--fullscreen"
|
||||
)}
|
||||
>
|
||||
<aside className="admin-panel-nav">
|
||||
<div className="admin-panel-nav__head">
|
||||
<div>
|
||||
|
|
@ -305,7 +317,11 @@ export function AdminOverlay({
|
|||
|
||||
{activeSection ? (
|
||||
<section className="admin-panel-content">
|
||||
<AdminHeader onCloseContent={() => setActiveSection(null)} />
|
||||
<AdminHeader
|
||||
isFullscreen={isContentFullscreen}
|
||||
onToggleFullscreen={() => setIsContentFullscreen((current) => !current)}
|
||||
onCloseContent={() => setActiveSection(null)}
|
||||
/>
|
||||
<div className="admin-panel-content__body">
|
||||
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
|
||||
{activeSection === "clients" && isRoot ? (
|
||||
|
|
@ -327,10 +343,6 @@ export function AdminOverlay({
|
|||
isRoot={isRoot}
|
||||
onCreateUser={onCreateUser}
|
||||
onUpdateUser={onUpdateUser}
|
||||
onUpdateMembership={onUpdateMembership}
|
||||
onDeleteMembership={onDeleteMembership}
|
||||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "groups" ? (
|
||||
|
|
@ -359,6 +371,10 @@ export function AdminOverlay({
|
|||
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
|
||||
onSetUserServiceAccess={onSetUserServiceAccess}
|
||||
pendingAccessAssignments={pendingAccessAssignments}
|
||||
onUpdateUser={onUpdateUser}
|
||||
onUpdateMembership={onUpdateMembership}
|
||||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "invites" ? (
|
||||
|
|
@ -382,15 +398,29 @@ export function AdminOverlay({
|
|||
);
|
||||
}
|
||||
|
||||
function AdminHeader({ onCloseContent }: { onCloseContent: () => void }) {
|
||||
function AdminHeader({
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
onCloseContent,
|
||||
}: {
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onCloseContent: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-header">
|
||||
<div className="admin-header__actions">
|
||||
<IconButton label="Проверить доступы" className="admin-circle-action" type="button">
|
||||
<SearchCheck size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Синхронизация" className="admin-circle-action admin-circle-action--solid" type="button">
|
||||
<RefreshCw size={16} />
|
||||
<IconButton
|
||||
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
|
||||
className={cn("admin-circle-action", isFullscreen && "admin-circle-action--active")}
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
aria-pressed={isFullscreen}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</IconButton>
|
||||
<IconButton label="Закрыть панель раздела" className="admin-circle-action admin-content-close" type="button" onClick={onCloseContent}>
|
||||
<X size={15} strokeWidth={1.45} />
|
||||
|
|
@ -568,22 +598,13 @@ function UsersSection({
|
|||
isRoot,
|
||||
onCreateUser,
|
||||
onUpdateUser,
|
||||
onUpdateMembership,
|
||||
onDeleteMembership,
|
||||
pendingTaskManagerMemberships,
|
||||
onSetTaskManagerWorkspaceMemberRole,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
clientId: string;
|
||||
isRoot: boolean;
|
||||
onCreateUser: (command: CreateUserCommand) => void;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
onDeleteMembership: (membershipId: string) => void;
|
||||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||
}) {
|
||||
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
|
||||
const [newUserEmail, setNewUserEmail] = useState("");
|
||||
const [newUserName, setNewUserName] = useState("");
|
||||
const [newUserRole, setNewUserRole] = useState<ClientMembershipRole>("member");
|
||||
|
|
@ -593,7 +614,6 @@ function UsersSection({
|
|||
: data.memberships
|
||||
.filter((membership) => membership.clientId === clientId)
|
||||
.map((membership) => ({ membership, user: getUser(data, membership.userId), client: getClient(data, membership.clientId) }));
|
||||
const editingRow = rows.find((row) => row.membership.id === editingMembershipId) ?? null;
|
||||
const clientGroups = data.groups.filter((group) => group.clientId === clientId);
|
||||
const groupOptions: Array<NodeDcSelectOption<string>> = [
|
||||
{ value: "none", label: "Без группы" },
|
||||
|
|
@ -670,33 +690,21 @@ function UsersSection({
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Пользователь</th>
|
||||
{isRoot ? <th>Клиент</th> : null}
|
||||
<th>Роль</th>
|
||||
<th>Группы</th>
|
||||
<th>Статус</th>
|
||||
<th>Доступ</th>
|
||||
<th>Tasker</th>
|
||||
<th aria-label="Редактирование" />
|
||||
<th>Клиент</th>
|
||||
<th>Телефон</th>
|
||||
<th>Должность</th>
|
||||
<th>Заметки</th>
|
||||
<th>Статус аккаунта</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ membership, user, client }) => {
|
||||
const taskManagerWorkspace = client.integrations?.taskManager?.workspaceSlug ?? null;
|
||||
const pendingKey = `${client.id}:${user.id}`;
|
||||
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
|
||||
const forcedTaskManagerAdmin = membership.role === "client_owner";
|
||||
const taskManagerAssignment = data.taskManagerMemberships.find(
|
||||
(candidate) => candidate.clientId === client.id && candidate.userId === user.id && candidate.workspaceSlug === taskManagerWorkspace
|
||||
);
|
||||
const taskManagerRole = taskManagerAssignment?.role ?? (forcedTaskManagerAdmin ? "admin" : "unset");
|
||||
const taskManagerRoleOptions = buildTaskManagerRoleOptions({
|
||||
hasWorkspace: Boolean(taskManagerWorkspace),
|
||||
disabled: pendingTaskerAssignment || forcedTaskManagerAdmin || membership.status !== "active" || user.globalStatus !== "active",
|
||||
});
|
||||
const protectedUser = user.id === "user_root";
|
||||
|
||||
return (
|
||||
<tr key={membership.id}>
|
||||
<td className="services-admin-table__service">
|
||||
<td className="admin-user-cell">
|
||||
<div className="admin-user-cell__fields">
|
||||
<input
|
||||
className="admin-table-input admin-table-input--strong"
|
||||
value={user.name}
|
||||
|
|
@ -709,71 +717,47 @@ function UsersSection({
|
|||
onChange={(event) => onUpdateUser(user.id, { email: event.target.value })}
|
||||
aria-label={`Email пользователя ${user.name}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
{isRoot ? <td>{client.name}</td> : null}
|
||||
<td>{client.name}</td>
|
||||
<td>
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={membership.role}
|
||||
options={membershipRoleOptions}
|
||||
label={`Роль ${user.name}`}
|
||||
minMenuWidth={198}
|
||||
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={user.phone ?? ""}
|
||||
onChange={(event) => onUpdateUser(user.id, { phone: event.target.value || null })}
|
||||
placeholder="—"
|
||||
aria-label={`Телефон пользователя ${user.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{data.groups
|
||||
.filter((group) => group.clientId === membership.clientId && group.memberIds.includes(user.id))
|
||||
.map((group) => group.name)
|
||||
.join(", ") || "—"}
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={user.position ?? ""}
|
||||
onChange={(event) => onUpdateUser(user.id, { position: event.target.value || null })}
|
||||
placeholder="—"
|
||||
aria-label={`Должность пользователя ${user.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={user.notes ?? ""}
|
||||
onChange={(event) => onUpdateUser(user.id, { notes: event.target.value || null })}
|
||||
placeholder="—"
|
||||
aria-label={`Заметки пользователя ${user.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{protectedUser ? (
|
||||
<AdminStaticPill>{statusOptionLabel(userStatusOptions, user.globalStatus)}</AdminStaticPill>
|
||||
) : (
|
||||
<AdminStatusDropdown
|
||||
value={user.globalStatus}
|
||||
options={userStatusOptions}
|
||||
label={`Статус пользователя ${user.name}`}
|
||||
label={`Статус аккаунта ${user.name}`}
|
||||
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusDropdown
|
||||
value={membership.status}
|
||||
options={membershipStatusOptions}
|
||||
label={`Доступ пользователя ${user.name}`}
|
||||
onChange={(status) => onUpdateMembership(membership.id, { status })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={pendingTaskerAssignment ? "pending" : taskManagerRole}
|
||||
options={taskManagerRoleOptions}
|
||||
label={`Роль Tasker ${user.name}`}
|
||||
minMenuWidth={180}
|
||||
disabled={
|
||||
!taskManagerWorkspace ||
|
||||
pendingTaskerAssignment ||
|
||||
forcedTaskManagerAdmin ||
|
||||
membership.status !== "active" ||
|
||||
user.globalStatus !== "active"
|
||||
}
|
||||
onChange={(role) => {
|
||||
if (role === "pending") return;
|
||||
onSetTaskManagerWorkspaceMemberRole({ clientId: client.id, userId: user.id, role });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Редактировать пользователя ${user.name}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => setEditingMembershipId(membership.id)}
|
||||
>
|
||||
<Edit3 size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -781,24 +765,6 @@ function UsersSection({
|
|||
</tbody>
|
||||
</table>
|
||||
</GlassSurface>
|
||||
|
||||
{editingRow ? (
|
||||
<UserEditorModal
|
||||
user={editingRow.user}
|
||||
membership={editingRow.membership}
|
||||
client={editingRow.client}
|
||||
onClose={() => setEditingMembershipId(null)}
|
||||
onSave={(userPatch, membershipPatch) => {
|
||||
onUpdateUser(editingRow.user.id, userPatch);
|
||||
onUpdateMembership(editingRow.membership.id, membershipPatch);
|
||||
setEditingMembershipId(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteMembership(editingRow.membership.id);
|
||||
setEditingMembershipId(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -936,6 +902,12 @@ const userStatusOptions: Array<AdminStatusOption<LauncherUserStatus>> = [
|
|||
{ value: "blocked", label: "Заблокирован", tone: "red" },
|
||||
];
|
||||
|
||||
const mainStatusOptions: Array<NodeDcSelectOption<LauncherUserStatus>> = [
|
||||
{ value: "active", label: "Активен", tone: "green" },
|
||||
{ value: "blocked", label: "Заблокирован", tone: "red" },
|
||||
{ value: "invited", label: "Приглашён", tone: "yellow", hidden: true },
|
||||
];
|
||||
|
||||
const membershipStatusOptions: Array<AdminStatusOption<ClientMembershipStatus>> = [
|
||||
{ value: "active", label: "Включён", tone: "green" },
|
||||
{ value: "disabled", label: "Отключён", tone: "red" },
|
||||
|
|
@ -986,6 +958,34 @@ function buildTaskManagerRoleOptions({
|
|||
];
|
||||
}
|
||||
|
||||
function membershipRoleLabel(role: ClientMembershipRole): string {
|
||||
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
||||
}
|
||||
|
||||
function statusOptionLabel<T extends string>(options: Array<AdminStatusOption<T>>, value: T): string {
|
||||
return options.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function mainStatusLabel(value: LauncherUserStatus): string {
|
||||
return mainStatusOptions.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function taskManagerRoleLabel(role: TaskManagerWorkspaceMemberRole | TaskManagerRoleSelectValue): string {
|
||||
const labels: Record<TaskManagerRoleSelectValue, string> = {
|
||||
unset: "—",
|
||||
guest: "Гость",
|
||||
member: "Участник",
|
||||
admin: "Админ",
|
||||
pending: "Сохраняем...",
|
||||
};
|
||||
|
||||
return labels[role];
|
||||
}
|
||||
|
||||
function AdminStaticPill({ children }: { children: ReactNode }) {
|
||||
return <span className="admin-static-pill">{children}</span>;
|
||||
}
|
||||
|
||||
const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWorkspaceCreationPolicy>> = [
|
||||
{
|
||||
value: "any_authorized_user",
|
||||
|
|
@ -2102,6 +2102,10 @@ function AccessSection({
|
|||
onSelectCell,
|
||||
onSetUserServiceAccess,
|
||||
pendingAccessAssignments,
|
||||
onUpdateUser,
|
||||
onUpdateMembership,
|
||||
pendingTaskManagerMemberships,
|
||||
onSetTaskManagerWorkspaceMemberRole,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
matrix: ReturnType<typeof buildAccessMatrix>;
|
||||
|
|
@ -2109,10 +2113,15 @@ function AccessSection({
|
|||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||
}) {
|
||||
const hasMatrixData = matrix.users.length > 0 && matrix.services.length > 0 && selectedCell !== null;
|
||||
const hasUsers = matrix.users.length > 0;
|
||||
const taskManagerWorkspace = matrix.client.integrations?.taskManager?.workspaceSlug ?? null;
|
||||
|
||||
if (!hasMatrixData) {
|
||||
if (!hasUsers) {
|
||||
return (
|
||||
<div className="access-layout">
|
||||
<GlassSurface className="access-matrix">
|
||||
|
|
@ -2138,8 +2147,9 @@ function AccessSection({
|
|||
);
|
||||
}
|
||||
|
||||
const selectedUser = getUser(data, selectedCell.userId);
|
||||
const selectedService = getService(data, selectedCell.serviceId);
|
||||
const selectedUser = selectedCell ? getUser(data, selectedCell.userId) : null;
|
||||
const selectedService = selectedCell ? getService(data, selectedCell.serviceId) : null;
|
||||
const accessGridTemplateColumns = `15rem repeat(${matrix.services.length + 2}, 9.7rem)`;
|
||||
|
||||
return (
|
||||
<div className="access-layout">
|
||||
|
|
@ -2149,46 +2159,90 @@ function AccessSection({
|
|||
<span className="muted-text">Клик по ячейке открывает назначение</span>
|
||||
</div>
|
||||
<div className="matrix-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Участник</th>
|
||||
<div className="access-matrix-grid" style={{ gridTemplateColumns: accessGridTemplateColumns }} role="table">
|
||||
<div className="access-grid-head access-grid-sticky" role="columnheader">
|
||||
Участник
|
||||
</div>
|
||||
<div className="access-grid-head" role="columnheader">
|
||||
MAIN
|
||||
</div>
|
||||
<div className="access-grid-head" role="columnheader">
|
||||
MAIN ROLE
|
||||
</div>
|
||||
{matrix.services.map((service) => (
|
||||
<th key={service.id}>{service.title}</th>
|
||||
<div key={service.id} className="access-grid-head" role="columnheader">
|
||||
{service.title}
|
||||
</div>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrix.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
|
||||
{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 pendingKey = `${matrix.client.id}:${user.id}`;
|
||||
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
|
||||
const forcedTaskManagerAdmin = membership.role === "client_owner";
|
||||
|
||||
return (
|
||||
<Fragment key={user.id}>
|
||||
<div className="access-grid-cell access-grid-sticky access-user-cell" role="rowheader">
|
||||
<strong>{user.name}</strong>
|
||||
<small>{user.email}</small>
|
||||
</td>
|
||||
</div>
|
||||
<div className="access-grid-cell" role="cell">
|
||||
<MainStatusControl
|
||||
value={user.globalStatus}
|
||||
protectedUser={protectedUser}
|
||||
onChange={(status) => onUpdateUser(user.id, { globalStatus: status })}
|
||||
/>
|
||||
</div>
|
||||
<div className="access-grid-cell" role="cell">
|
||||
<MainRoleControl
|
||||
value={membership.role}
|
||||
protectedUser={protectedUser}
|
||||
onChange={(role) => onUpdateMembership(membership.id, { role })}
|
||||
/>
|
||||
</div>
|
||||
{matrix.services.map((service) => {
|
||||
const cell = matrix.cells.find((item) => item.userId === user.id && item.serviceId === service.id)!;
|
||||
const active = selectedCell.userId === user.id && selectedCell.serviceId === service.id;
|
||||
const active = selectedCell?.userId === user.id && selectedCell.serviceId === service.id;
|
||||
const isTaskManagerService = isOperationalCoreService(service);
|
||||
|
||||
return (
|
||||
<td key={service.id}>
|
||||
<div key={service.id} className="access-grid-cell" role="cell">
|
||||
<AccessCellControl
|
||||
cell={cell}
|
||||
active={active}
|
||||
pendingValue={pendingAccessAssignments[accessCellKey(user.id, service.id)]}
|
||||
busy={isTaskManagerService && pendingTaskerAssignment}
|
||||
onSelectCell={onSelectCell}
|
||||
onSetAccess={(value) => onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value })}
|
||||
onSetAccess={(value) => {
|
||||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
|
||||
|
||||
if (!isTaskManagerService || !taskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
|
||||
|
||||
onSetTaskManagerWorkspaceMemberRole({
|
||||
clientId: matrix.client.id,
|
||||
userId: user.id,
|
||||
role: accessAssignmentToTaskManagerRole(value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="access-explanation">
|
||||
<p className="eyebrow">Explanation panel</p>
|
||||
{selectedCell && selectedUser && selectedService ? (
|
||||
<>
|
||||
<h3>
|
||||
{selectedUser.name} / {selectedService.title}
|
||||
</h3>
|
||||
|
|
@ -2199,25 +2253,120 @@ function AccessSection({
|
|||
<InfoLine label="Источник" value={sourceLabel(selectedCell.effectiveAccess.source)} />
|
||||
<InfoLine label="Роль" value={selectedCell.effectiveAccess.appRole ?? "—"} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>Сервис не выбран</h3>
|
||||
<div className="explanation-stack">
|
||||
<InfoLine label="Итог" value="Выберите сервисную ячейку" />
|
||||
<InfoLine label="MAIN" value="Базовые роли и статусы применяются сразу" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MainStatusControl({
|
||||
value,
|
||||
protectedUser,
|
||||
onChange,
|
||||
}: {
|
||||
value: LauncherUserStatus;
|
||||
protectedUser: boolean;
|
||||
onChange: (value: LauncherUserStatus) => void;
|
||||
}) {
|
||||
const label = mainStatusLabel(value);
|
||||
const allowed = value === "active";
|
||||
|
||||
if (protectedUser) {
|
||||
return (
|
||||
<span className={cn("access-cell access-cell--main access-cell--readonly", allowed ? "access-cell--allowed" : "access-cell--exception")}>
|
||||
<strong>{label}</strong>
|
||||
<span>MAIN</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeDcSelect
|
||||
value={value}
|
||||
options={mainStatusOptions}
|
||||
label="MAIN статус"
|
||||
minMenuWidth={172}
|
||||
menuClassName="access-cell-menu"
|
||||
onChange={onChange}
|
||||
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className={cn("access-cell access-cell--main", value === "active" ? "access-cell--allowed" : "access-cell--exception")}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<strong>{selectedOption.label}</strong>
|
||||
<span>MAIN</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MainRoleControl({
|
||||
value,
|
||||
protectedUser,
|
||||
onChange,
|
||||
}: {
|
||||
value: ClientMembershipRole;
|
||||
protectedUser: boolean;
|
||||
onChange: (value: ClientMembershipRole) => void;
|
||||
}) {
|
||||
const label = membershipRoleLabel(value);
|
||||
|
||||
if (protectedUser) {
|
||||
return (
|
||||
<span className="access-cell access-cell--main access-cell--allowed access-cell--readonly">
|
||||
<strong>{label}</strong>
|
||||
<span>MAIN роль</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeDcSelect
|
||||
value={value}
|
||||
options={membershipRoleOptions}
|
||||
label="MAIN роль"
|
||||
minMenuWidth={198}
|
||||
menuClassName="access-cell-menu"
|
||||
onChange={onChange}
|
||||
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
|
||||
<button ref={setTriggerRef} className="access-cell access-cell--main access-cell--allowed" type="button" aria-expanded={open} onClick={toggle}>
|
||||
<strong>{selectedOption.label}</strong>
|
||||
<span>MAIN роль</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessCellControl({
|
||||
cell,
|
||||
active,
|
||||
pendingValue,
|
||||
busy = false,
|
||||
onSelectCell,
|
||||
onSetAccess,
|
||||
}: {
|
||||
cell: AccessMatrixCell;
|
||||
active: boolean;
|
||||
pendingValue?: AccessAssignmentValue;
|
||||
busy?: boolean;
|
||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onSetAccess: (value: AccessAssignmentValue) => void;
|
||||
}) {
|
||||
const isPending = pendingValue !== undefined;
|
||||
const isPending = pendingValue !== undefined || busy;
|
||||
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
|
||||
|
||||
return (
|
||||
|
|
@ -2738,6 +2887,16 @@ function accessCellKey(userId: string, serviceId: string): string {
|
|||
return `${userId}:${serviceId}`;
|
||||
}
|
||||
|
||||
function isOperationalCoreService(service: Service): boolean {
|
||||
return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager";
|
||||
}
|
||||
|
||||
function accessAssignmentToTaskManagerRole(value: AccessAssignmentValue): TaskManagerWorkspaceMemberRole {
|
||||
if (value === "admin" || value === "member") return value;
|
||||
if (value === "viewer") return "guest";
|
||||
return "unset";
|
||||
}
|
||||
|
||||
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
|
||||
if (!source) return "—";
|
||||
const labels = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue