Compare commits
15 Commits
652a6ef0c5
...
0a3243c9e8
| Author | SHA1 | Date |
|---|---|---|
|
|
0a3243c9e8 | |
|
|
b0878b4d02 | |
|
|
a4cb3da325 | |
|
|
b4bea3597e | |
|
|
d1b6755147 | |
|
|
1d0e4a2f4e | |
|
|
a3f2d2e6a0 | |
|
|
2129ffe336 | |
|
|
a53f286860 | |
|
|
e8ae3b08f8 | |
|
|
f9a590dca7 | |
|
|
caa1250bf7 | |
|
|
4a519d1439 | |
|
|
795f369947 | |
|
|
11dd8d1043 |
|
|
@ -10,18 +10,31 @@
|
|||
"contractEndsAt": null,
|
||||
"paidUntil": null,
|
||||
"demoEndsAt": null,
|
||||
"contactName": "DC Touch",
|
||||
"contactName": "DC",
|
||||
"contactEmail": "dcctouch@gmail.com",
|
||||
"notes": "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.",
|
||||
"createdAt": "2026-05-04T00:00:00.000Z",
|
||||
"updatedAt": "2026-05-06T08:44:44.882Z",
|
||||
"updatedAt": "2026-05-08T16:19:24.425Z",
|
||||
"integrations": {
|
||||
"taskManager": {
|
||||
"workspaceSlug": "nodedc",
|
||||
"workspaceName": "NODE DC"
|
||||
"workspaceName": "NODE DC",
|
||||
"workspaces": [
|
||||
{
|
||||
"slug": "nodedc",
|
||||
"name": "NODE DC",
|
||||
"isPrimary": true
|
||||
},
|
||||
{
|
||||
"slug": "dcabramov",
|
||||
"name": "DCABRAMOV",
|
||||
"isPrimary": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"inn": null
|
||||
"inn": null,
|
||||
"avatarUrl": "/storage/uploads/1778257160251-eba1370a-2025-05-14-23.46.35.jpg"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
|
|
@ -75,7 +88,7 @@
|
|||
"avatarUrl": null,
|
||||
"globalStatus": "active",
|
||||
"createdAt": "2026-05-05T16:02:43.235Z",
|
||||
"updatedAt": "2026-05-05T16:04:53.004Z"
|
||||
"updatedAt": "2026-05-08T11:44:25.215Z"
|
||||
},
|
||||
{
|
||||
"id": "user_silverpsih007_gmail_com",
|
||||
|
|
@ -149,10 +162,10 @@
|
|||
"id": "mem_client_romashka_support_dctouch_ru",
|
||||
"clientId": "client_romashka",
|
||||
"userId": "user_support_dctouch_ru",
|
||||
"role": "member",
|
||||
"role": "client_admin",
|
||||
"status": "active",
|
||||
"createdAt": "2026-05-05T16:02:43.235Z",
|
||||
"updatedAt": "2026-05-05T16:02:43.235Z"
|
||||
"updatedAt": "2026-05-08T11:44:24.773Z"
|
||||
},
|
||||
{
|
||||
"id": "mem_client_romashka_silverpsih007_gmail_com",
|
||||
|
|
@ -387,16 +400,6 @@
|
|||
"createdAt": "2026-05-05T14:57:13.249Z",
|
||||
"updatedAt": "2026-05-05T14:57:13.249Z"
|
||||
},
|
||||
{
|
||||
"id": "grant_task_manager_user_support_dctouch_ru",
|
||||
"serviceId": "service_task_manager",
|
||||
"targetType": "user",
|
||||
"targetId": "user_support_dctouch_ru",
|
||||
"appRole": "member",
|
||||
"status": "active",
|
||||
"createdAt": "2026-05-05T16:04:52.709Z",
|
||||
"updatedAt": "2026-05-05T16:04:52.709Z"
|
||||
},
|
||||
{
|
||||
"id": "grant_task_manager_user_silverpsih007_gmail_com",
|
||||
"serviceId": "service_task_manager",
|
||||
|
|
@ -426,6 +429,16 @@
|
|||
"status": "active",
|
||||
"createdAt": "2026-05-06T01:28:06.515Z",
|
||||
"updatedAt": "2026-05-06T01:28:06.515Z"
|
||||
},
|
||||
{
|
||||
"id": "grant_task_manager_user_support_dctouch_ru",
|
||||
"serviceId": "service_task_manager",
|
||||
"targetType": "user",
|
||||
"targetId": "user_support_dctouch_ru",
|
||||
"appRole": "member",
|
||||
"status": "active",
|
||||
"createdAt": "2026-05-05T16:04:52.709Z",
|
||||
"updatedAt": "2026-05-08T10:14:37.303Z"
|
||||
}
|
||||
],
|
||||
"exceptions": [],
|
||||
|
|
@ -501,7 +514,7 @@
|
|||
"state": "pending",
|
||||
"lastSyncAt": "2026-05-04T12:55:13.842Z",
|
||||
"error": null,
|
||||
"updatedAt": "2026-05-06T08:44:44.887Z"
|
||||
"updatedAt": "2026-05-08T16:19:24.425Z"
|
||||
},
|
||||
{
|
||||
"id": "sync_dc_touch_authentik",
|
||||
|
|
@ -620,9 +633,9 @@
|
|||
"objectType": "user",
|
||||
"target": "authentik",
|
||||
"state": "synced",
|
||||
"lastSyncAt": "2026-05-05T16:04:53.004Z",
|
||||
"lastSyncAt": "2026-05-08T11:44:25.215Z",
|
||||
"error": null,
|
||||
"updatedAt": "2026-05-05T16:04:53.004Z"
|
||||
"updatedAt": "2026-05-08T11:44:25.215Z"
|
||||
},
|
||||
{
|
||||
"id": "sync_grant_service_task_manager_user_support_dctouch_ru",
|
||||
|
|
@ -633,7 +646,7 @@
|
|||
"state": "pending",
|
||||
"lastSyncAt": null,
|
||||
"error": null,
|
||||
"updatedAt": "2026-05-05T16:04:52.709Z"
|
||||
"updatedAt": "2026-05-08T10:14:37.305Z"
|
||||
},
|
||||
{
|
||||
"id": "sync_invite_invite_silverpsih007_gmail_com",
|
||||
|
|
@ -2367,6 +2380,222 @@
|
|||
"clientId": null,
|
||||
"result": "success",
|
||||
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||
},
|
||||
{
|
||||
"id": "audit_dc_support_2",
|
||||
"at": "2026-05-08T10:08:56.801Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Назначен Tasker project",
|
||||
"objectType": "task-manager-project-membership",
|
||||
"objectName": "DC SUPPORT",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": "Workspace: NODE DC; Project: DCTM-WT-CODEX; Role: member"
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_2",
|
||||
"at": "2026-05-08T10:10:17.084Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_3",
|
||||
"at": "2026-05-08T10:11:08.178Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_4",
|
||||
"at": "2026-05-08T10:14:10.729Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_support_dctouch_ru_task_manager_2",
|
||||
"at": "2026-05-08T10:14:37.304Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён доступ пользователя к сервису",
|
||||
"objectType": "grant",
|
||||
"objectName": "support@dctouch.ru / task-manager",
|
||||
"clientId": null,
|
||||
"result": "success",
|
||||
"details": "Value: member"
|
||||
},
|
||||
{
|
||||
"id": "audit_dc_support_3",
|
||||
"at": "2026-05-08T10:14:37.448Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Назначен Tasker workspace",
|
||||
"objectType": "task-manager-membership",
|
||||
"objectName": "DC SUPPORT",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": "Workspace: DCABRAMOV; Role: member"
|
||||
},
|
||||
{
|
||||
"id": "audit_support_dctouch_ru_6",
|
||||
"at": "2026-05-08T10:14:38.013Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Пользователь синхронизирован в Authentik",
|
||||
"objectType": "user",
|
||||
"objectName": "support@dctouch.ru",
|
||||
"clientId": null,
|
||||
"result": "success",
|
||||
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||
},
|
||||
{
|
||||
"id": "audit_support_dctouch_ru_7",
|
||||
"at": "2026-05-08T11:44:24.774Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлено членство",
|
||||
"objectType": "user",
|
||||
"objectName": "support@dctouch.ru",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": "Role: client_admin; status: active"
|
||||
},
|
||||
{
|
||||
"id": "audit_support_dctouch_ru_8",
|
||||
"at": "2026-05-08T11:44:25.215Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Пользователь синхронизирован в Authentik",
|
||||
"objectType": "user",
|
||||
"objectName": "support@dctouch.ru",
|
||||
"clientId": null,
|
||||
"result": "success",
|
||||
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user"
|
||||
},
|
||||
{
|
||||
"id": "audit_dc_constrictions_2",
|
||||
"at": "2026-05-08T11:52:50.107Z",
|
||||
"actorUserId": "user_support_dctouch_ru",
|
||||
"actorName": "DC SUPPORT",
|
||||
"action": "Назначен Tasker project",
|
||||
"objectType": "task-manager-project-membership",
|
||||
"objectName": "DC CONSTRICTIONS",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": "Workspace: NODE DC; Project: Менеджмент; Role: member"
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_5",
|
||||
"at": "2026-05-08T15:25:23.301Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_6",
|
||||
"at": "2026-05-08T15:33:22.866Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_7",
|
||||
"at": "2026-05-08T15:37:09.487Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_8",
|
||||
"at": "2026-05-08T15:37:18.628Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_9",
|
||||
"at": "2026-05-08T15:47:50.379Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_10",
|
||||
"at": "2026-05-08T15:54:49.291Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_11",
|
||||
"at": "2026-05-08T16:05:21.237Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"id": "audit_dctouch_12",
|
||||
"at": "2026-05-08T16:19:24.425Z",
|
||||
"actorUserId": "user_root",
|
||||
"actorName": "DC SUDO",
|
||||
"action": "Обновлён клиент",
|
||||
"objectType": "client",
|
||||
"objectName": "DCTOUCH",
|
||||
"clientId": "client_romashka",
|
||||
"result": "success",
|
||||
"details": null
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
|
@ -2454,6 +2683,47 @@
|
|||
"planeUserId": "1cc7ae3a-1f42-41ac-8cc2-1ff0fce59554",
|
||||
"planeRole": 15,
|
||||
"updatedAt": "2026-05-06T11:20:48.841Z"
|
||||
},
|
||||
{
|
||||
"id": "tasker_mem_client_romashka_user_support_dctouch_ru_dcabramov",
|
||||
"clientId": "client_romashka",
|
||||
"userId": "user_support_dctouch_ru",
|
||||
"workspaceSlug": "dcabramov",
|
||||
"workspaceName": "DCABRAMOV",
|
||||
"role": "member",
|
||||
"planeUserId": "53e56870-8772-4dfd-9c8c-2eeacfb53caa",
|
||||
"planeRole": 15,
|
||||
"updatedAt": "2026-05-08T10:14:37.448Z"
|
||||
}
|
||||
],
|
||||
"taskManagerProjectMemberships": [
|
||||
{
|
||||
"id": "tasker_project_mem_client_romashka_user_support_dctouch_ru_nodedc_823af409_bcfd_498c_b2fb_31119ff23",
|
||||
"clientId": "client_romashka",
|
||||
"userId": "user_support_dctouch_ru",
|
||||
"workspaceSlug": "nodedc",
|
||||
"workspaceName": "NODE DC",
|
||||
"projectId": "823af409-bcfd-498c-b2fb-31119ff238a7",
|
||||
"projectIdentifier": "CODEX",
|
||||
"projectName": "DCTM-WT-CODEX",
|
||||
"role": "member",
|
||||
"planeUserId": "53e56870-8772-4dfd-9c8c-2eeacfb53caa",
|
||||
"planeRole": 15,
|
||||
"updatedAt": "2026-05-08T10:08:56.800Z"
|
||||
},
|
||||
{
|
||||
"id": "tasker_project_mem_client_romashka_user_support_dcconstructions_ru_nodedc_a3de175a_2df5_4604_aadf_6",
|
||||
"clientId": "client_romashka",
|
||||
"userId": "user_support_dcconstructions_ru",
|
||||
"workspaceSlug": "nodedc",
|
||||
"workspaceName": "NODE DC",
|
||||
"projectId": "a3de175a-2df5-4604-aadf-618877445135",
|
||||
"projectIdentifier": "MGR",
|
||||
"projectName": "Менеджмент",
|
||||
"role": "member",
|
||||
"planeUserId": "1cc7ae3a-1f42-41ac-8cc2-1ff0fce59554",
|
||||
"planeRole": 15,
|
||||
"updatedAt": "2026-05-08T11:52:50.104Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -15,6 +15,7 @@ const collectionKeys = [
|
|||
"syncStatuses",
|
||||
"auditEvents",
|
||||
"taskManagerMemberships",
|
||||
"taskManagerProjectMemberships",
|
||||
];
|
||||
|
||||
const clientTypes = new Set(["company", "person"]);
|
||||
|
|
@ -104,6 +105,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
demoEndsAt: nullableString(payload?.demoEndsAt),
|
||||
contactName: nullableString(payload?.contactName),
|
||||
contactEmail: nullableString(payload?.contactEmail),
|
||||
avatarUrl: nullableString(payload?.avatarUrl),
|
||||
integrations: normalizeClientIntegrations(payload?.integrations),
|
||||
notes: nullableString(payload?.notes),
|
||||
createdAt: now,
|
||||
|
|
@ -140,6 +142,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null);
|
||||
client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null);
|
||||
client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null);
|
||||
client.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, client.avatarUrl ?? null);
|
||||
if ("integrations" in (payload ?? {})) {
|
||||
client.integrations = normalizeClientIntegrations(payload.integrations, client.integrations);
|
||||
}
|
||||
|
|
@ -201,6 +204,9 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
data.taskManagerMemberships = data.taskManagerMemberships.filter(
|
||||
(membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug)
|
||||
);
|
||||
data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter(
|
||||
(membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug)
|
||||
);
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Снят Tasker workspace",
|
||||
|
|
@ -215,6 +221,73 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
return { data };
|
||||
}
|
||||
|
||||
async function recordTaskManagerProjectMembership(payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const client = findById(data.clients, payload?.clientId, "client");
|
||||
const user = findById(data.users, payload?.userId, "user");
|
||||
const taskManager = typeof payload?.taskManager === "object" && payload.taskManager !== null ? payload.taskManager : {};
|
||||
const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {};
|
||||
const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {};
|
||||
const project = typeof membership.project === "object" && membership.project !== null ? membership.project : {};
|
||||
|
||||
upsertTaskManagerProjectMembership(data, {
|
||||
clientId: client.id,
|
||||
userId: user.id,
|
||||
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
|
||||
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
|
||||
projectId: project.id ?? payload?.projectId,
|
||||
projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null,
|
||||
projectName: project.name ?? payload?.projectName ?? null,
|
||||
role: normalizeTaskManagerMembershipRole(payload?.role),
|
||||
planeUserId: membership.member?.id ?? null,
|
||||
planeRole: typeof membership.role === "number" ? membership.role : null,
|
||||
});
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Назначен Tasker project",
|
||||
objectType: "task-manager-project-membership",
|
||||
objectName: user.name,
|
||||
clientId: client.id,
|
||||
result: "success",
|
||||
details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Project: ${project.name ?? project.identifier ?? payload?.projectId}; Role: ${payload?.role ?? "member"}`,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async function removeTaskManagerProjectMembership(payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const client = findById(data.clients, payload?.clientId, "client");
|
||||
const user = findById(data.users, payload?.userId, "user");
|
||||
const workspaceSlug = requireString(payload?.workspaceSlug ?? client.integrations?.taskManager?.workspaceSlug, "workspaceSlug");
|
||||
const projectId = requireString(payload?.projectId, "projectId");
|
||||
|
||||
data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter(
|
||||
(membership) =>
|
||||
!(
|
||||
membership.clientId === client.id &&
|
||||
membership.userId === user.id &&
|
||||
membership.workspaceSlug === workspaceSlug &&
|
||||
membership.projectId === projectId
|
||||
)
|
||||
);
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Снят Tasker project",
|
||||
objectType: "task-manager-project-membership",
|
||||
objectName: user.name,
|
||||
clientId: client.id,
|
||||
result: "success",
|
||||
details: `Workspace: ${workspaceSlug}; Project: ${projectId}`,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async function deleteClient(clientId, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
|
|
@ -1089,7 +1162,9 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
reorderServices,
|
||||
retrySync,
|
||||
markUserAuthentikProvisioned,
|
||||
recordTaskManagerProjectMembership,
|
||||
recordTaskManagerWorkspaceMembership,
|
||||
removeTaskManagerProjectMembership,
|
||||
removeTaskManagerWorkspaceMembership,
|
||||
setUserServiceAccess,
|
||||
updateClient,
|
||||
|
|
@ -1233,6 +1308,42 @@ function upsertTaskManagerMembership(data, payload) {
|
|||
return nextMembership;
|
||||
}
|
||||
|
||||
function upsertTaskManagerProjectMembership(data, payload) {
|
||||
const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug");
|
||||
const projectId = requireString(payload.projectId, "projectId");
|
||||
const existingMembership = data.taskManagerProjectMemberships.find(
|
||||
(membership) =>
|
||||
membership.clientId === payload.clientId &&
|
||||
membership.userId === payload.userId &&
|
||||
membership.workspaceSlug === workspaceSlug &&
|
||||
membership.projectId === projectId
|
||||
);
|
||||
const nextMembership = {
|
||||
id:
|
||||
existingMembership?.id ??
|
||||
uniqueId(data.taskManagerProjectMemberships, "tasker_project_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}-${projectId}`),
|
||||
clientId: payload.clientId,
|
||||
userId: payload.userId,
|
||||
workspaceSlug,
|
||||
workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null),
|
||||
projectId,
|
||||
projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null),
|
||||
projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null),
|
||||
role: normalizeTaskManagerMembershipRole(payload.role),
|
||||
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
|
||||
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
|
||||
updatedAt: isoNow(),
|
||||
};
|
||||
|
||||
if (existingMembership) {
|
||||
Object.assign(existingMembership, nextMembership);
|
||||
return existingMembership;
|
||||
}
|
||||
|
||||
data.taskManagerProjectMemberships.push(nextMembership);
|
||||
return nextMembership;
|
||||
}
|
||||
|
||||
function resolveActor(data, identity) {
|
||||
const user = data.users.find(
|
||||
(item) =>
|
||||
|
|
|
|||
|
|
@ -521,13 +521,29 @@ app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
|
|||
});
|
||||
|
||||
app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
if (!req.nodedcAdminScope?.isRoot) {
|
||||
res.json({ ok: true, workspaces: [] });
|
||||
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/");
|
||||
|
||||
if (req.nodedcAdminScope?.isRoot) {
|
||||
res.json(taskManager);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/");
|
||||
res.json(taskManager);
|
||||
const allowedWorkspaceSlugs = new Set(
|
||||
req.nodedcAdminScope.snapshot.data.clients
|
||||
.filter((client) => req.nodedcAdminScope.clientIds.has(client.id))
|
||||
.flatMap((client) => {
|
||||
const workspaces = Array.isArray(client.integrations?.taskManager?.workspaces)
|
||||
? client.integrations.taskManager.workspaces
|
||||
: [];
|
||||
const slugs = workspaces.map((workspace) => workspace?.slug).filter((slug) => typeof slug === "string" && slug.trim());
|
||||
const legacySlug = client.integrations?.taskManager?.workspaceSlug;
|
||||
return legacySlug ? [...slugs, legacySlug] : slugs;
|
||||
})
|
||||
);
|
||||
res.json({
|
||||
...taskManager,
|
||||
workspaces: (taskManager.workspaces ?? []).filter((workspace) => allowedWorkspaceSlugs.has(workspace.slug)),
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
|
|
@ -667,6 +683,132 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
|
|||
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
|
||||
}));
|
||||
|
||||
app.post("/api/admin/task-manager/project-memberships/ensure", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||
const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : "";
|
||||
const userId = typeof req.body?.userId === "string" ? req.body.userId : "";
|
||||
const client = snapshot.data.clients.find((candidate) => candidate.id === clientId);
|
||||
const user = snapshot.data.users.find((candidate) => candidate.id === userId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({ ok: false, error: "client_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ ok: false, error: "user_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug);
|
||||
const projectId = normalizeOptionalText(req.body?.projectId);
|
||||
const role = normalizeTaskManagerRole(req.body?.role);
|
||||
|
||||
if (!workspaceSlug) {
|
||||
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
res.status(400).json({ ok: false, error: "task_manager_project_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
res.status(400).json({ ok: false, error: "task_manager_role_invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/ensure/", {
|
||||
method: "POST",
|
||||
body: {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
email: user.email,
|
||||
subject: user.authentikUserId ?? undefined,
|
||||
avatarUrl: resolveUserAvatarPublicUrl(user),
|
||||
role,
|
||||
setLastWorkspace: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await controlPlaneStore.recordTaskManagerProjectMembership(
|
||||
{
|
||||
clientId: client.id,
|
||||
userId: user.id,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
role,
|
||||
taskManager,
|
||||
},
|
||||
req.nodedcSession.user
|
||||
);
|
||||
|
||||
publishControlPlaneEvent("admin.task-manager.project-membership.updated", [user.id]);
|
||||
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
|
||||
}));
|
||||
|
||||
app.post("/api/admin/task-manager/project-memberships/remove", requireLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||
const clientId = typeof req.body?.clientId === "string" ? req.body.clientId : "";
|
||||
const userId = typeof req.body?.userId === "string" ? req.body.userId : "";
|
||||
const client = snapshot.data.clients.find((candidate) => candidate.id === clientId);
|
||||
const user = snapshot.data.users.find((candidate) => candidate.id === userId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({ ok: false, error: "client_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ ok: false, error: "user_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assertAdminCanManageClient(req, res, client.id) || !assertAdminCanManageUser(req, res, user.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug);
|
||||
const projectId = normalizeOptionalText(req.body?.projectId);
|
||||
|
||||
if (!workspaceSlug) {
|
||||
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
res.status(400).json({ ok: false, error: "task_manager_project_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/project-memberships/remove/", {
|
||||
method: "POST",
|
||||
body: {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
email: user.email,
|
||||
subject: user.authentikUserId ?? undefined,
|
||||
},
|
||||
});
|
||||
const result = await controlPlaneStore.removeTaskManagerProjectMembership(
|
||||
{
|
||||
clientId: client.id,
|
||||
userId: user.id,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
},
|
||||
req.nodedcSession.user
|
||||
);
|
||||
|
||||
publishControlPlaneEvent("admin.task-manager.project-membership.updated", [user.id]);
|
||||
res.json({ ...scopeAdminMutationResult(req, result), taskManager });
|
||||
}));
|
||||
|
||||
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);
|
||||
|
|
@ -2209,6 +2351,9 @@ function scopeControlPlaneData(data, scope) {
|
|||
taskManagerMemberships: data.taskManagerMemberships.filter(
|
||||
(membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId)
|
||||
),
|
||||
taskManagerProjectMemberships: data.taskManagerProjectMemberships.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)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ import {
|
|||
deleteAdminInvite,
|
||||
deleteAdminMembership,
|
||||
deleteAdminService,
|
||||
ensureAdminTaskManagerProjectMembership,
|
||||
ensureAdminTaskManagerWorkspaceMembership,
|
||||
fetchAdminTaskManagerWorkspaces,
|
||||
fetchControlPlaneSnapshot,
|
||||
reorderAdminServices,
|
||||
retryAdminSync,
|
||||
removeAdminTaskManagerProjectMembership,
|
||||
removeAdminTaskManagerWorkspaceMembership,
|
||||
setAdminUserServiceAccess,
|
||||
updateAdminClient,
|
||||
|
|
@ -57,6 +59,7 @@ import {
|
|||
AdminOverlay,
|
||||
type AccessAssignmentValue,
|
||||
type CreateUserCommand,
|
||||
type EnsureTaskManagerProjectMemberCommand,
|
||||
type SetUserServiceAccessCommand,
|
||||
} from "../widgets/admin-overlay/AdminOverlay";
|
||||
import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel";
|
||||
|
|
@ -87,6 +90,7 @@ export function LauncherApp() {
|
|||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||||
const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState<Record<string, boolean>>({});
|
||||
const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState<Record<string, boolean>>({});
|
||||
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
|
||||
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
|
||||
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
|
||||
|
|
@ -506,6 +510,45 @@ export function LauncherApp() {
|
|||
});
|
||||
}
|
||||
|
||||
function handleSetTaskManagerProjectMemberRole(command: EnsureTaskManagerProjectMemberCommand) {
|
||||
const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug}:${command.projectId}`;
|
||||
|
||||
if (pendingTaskManagerProjectMemberships[membershipKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTaskManagerProjectMemberships((current) => ({ ...current, [membershipKey]: true }));
|
||||
const request =
|
||||
command.role === "unset"
|
||||
? removeAdminTaskManagerProjectMembership({
|
||||
clientId: command.clientId,
|
||||
userId: command.userId,
|
||||
workspaceSlug: command.workspaceSlug,
|
||||
projectId: command.projectId,
|
||||
})
|
||||
: ensureAdminTaskManagerProjectMembership({
|
||||
clientId: command.clientId,
|
||||
userId: command.userId,
|
||||
workspaceSlug: command.workspaceSlug,
|
||||
projectId: command.projectId,
|
||||
role: command.role,
|
||||
});
|
||||
|
||||
request
|
||||
.then((result) => {
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать проект Tasker");
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingTaskManagerProjectMemberships((current) => {
|
||||
const { [membershipKey]: _completed, ...rest } = current;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
||||
applyControlPlaneMutation(createAdminInvite(invite));
|
||||
}
|
||||
|
|
@ -752,8 +795,10 @@ export function LauncherApp() {
|
|||
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||||
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
|
||||
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
|
||||
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
|
||||
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
|
||||
/>
|
||||
) : null}
|
||||
{profileSettingsOpen && activeProfileUser ? (
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface Client {
|
|||
demoEndsAt?: string | null;
|
||||
contactName?: string | null;
|
||||
contactEmail?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
integrations?: {
|
||||
taskManager?: {
|
||||
workspaceSlug?: string | null;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ export interface TaskManagerWorkspaceSummary {
|
|||
name: string;
|
||||
ownerEmail: string | null;
|
||||
memberCount: number;
|
||||
projects?: TaskManagerProjectSummary[];
|
||||
}
|
||||
|
||||
export interface TaskManagerProjectSummary {
|
||||
id: string;
|
||||
workspaceSlug: string;
|
||||
name: string;
|
||||
identifier: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export interface TaskManagerWorkspaceMembershipResult {
|
||||
|
|
@ -52,6 +61,20 @@ export interface TaskManagerWorkspaceMembershipResult {
|
|||
isBanned: boolean;
|
||||
}
|
||||
|
||||
export interface TaskManagerProjectMembershipResult {
|
||||
created: boolean;
|
||||
workspace: TaskManagerWorkspaceSummary;
|
||||
project: TaskManagerProjectSummary;
|
||||
member: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
};
|
||||
role: number;
|
||||
roleSlug: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type TaskManagerWorkspaceMemberRole = "unset" | "guest" | "member" | "admin";
|
||||
|
||||
export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult {
|
||||
|
|
@ -74,6 +97,27 @@ export interface TaskManagerWorkspaceMembershipRemoveMutationResult extends Cont
|
|||
};
|
||||
}
|
||||
|
||||
export interface TaskManagerProjectMembershipMutationResult extends ControlPlaneMutationResult {
|
||||
taskManager: {
|
||||
ok: boolean;
|
||||
membership: TaskManagerProjectMembershipResult;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskManagerProjectMembershipRemoveMutationResult extends ControlPlaneMutationResult {
|
||||
taskManager: {
|
||||
ok: boolean;
|
||||
removed: boolean;
|
||||
workspace: TaskManagerWorkspaceSummary;
|
||||
project: TaskManagerProjectSummary;
|
||||
member: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> {
|
||||
return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane");
|
||||
}
|
||||
|
|
@ -171,6 +215,31 @@ export async function removeAdminTaskManagerWorkspaceMembership(payload: {
|
|||
});
|
||||
}
|
||||
|
||||
export async function ensureAdminTaskManagerProjectMembership(payload: {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
role: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
|
||||
}): Promise<TaskManagerProjectMembershipMutationResult> {
|
||||
return requestJson<TaskManagerProjectMembershipMutationResult>("/api/admin/task-manager/project-memberships/ensure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeAdminTaskManagerProjectMembership(payload: {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
}): Promise<TaskManagerProjectMembershipRemoveMutationResult> {
|
||||
return requestJson<TaskManagerProjectMembershipRemoveMutationResult>("/api/admin/task-manager/project-memberships/remove", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAdminGroup(payload: Pick<ClientGroup, "clientId"> & Partial<ClientGroup>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/groups", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export interface LauncherData {
|
|||
syncStatuses: SyncStatus[];
|
||||
auditEvents: typeof mockAuditEvents;
|
||||
taskManagerMemberships: TaskManagerMembershipAssignment[];
|
||||
taskManagerProjectMemberships: TaskManagerProjectMembershipAssignment[];
|
||||
settings: LauncherSettings;
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +77,21 @@ export interface TaskManagerMembershipAssignment {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskManagerProjectMembershipAssignment {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
workspaceSlug: string;
|
||||
workspaceName?: string | null;
|
||||
projectId: string;
|
||||
projectIdentifier?: string | null;
|
||||
projectName?: string | null;
|
||||
role: "guest" | "member" | "admin";
|
||||
planeUserId?: string | null;
|
||||
planeRole?: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LauncherSettings {
|
||||
brand: {
|
||||
logoLinkUrl: string;
|
||||
|
|
@ -174,6 +190,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
|
|||
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
|
||||
taskManagerProjectMemberships: Array.isArray(payload.taskManagerProjectMemberships) ? payload.taskManagerProjectMemberships : [],
|
||||
settings: normalizeLauncherSettings(payload.settings),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const mockClients: Client[] = [
|
|||
demoEndsAt: null,
|
||||
contactName: "DC Touch",
|
||||
contactEmail: "dcctouch@gmail.com",
|
||||
avatarUrl: null,
|
||||
notes: "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.",
|
||||
createdAt: "2026-05-04T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -411,20 +411,25 @@ code {
|
|||
position: relative;
|
||||
display: flex;
|
||||
width: 3rem;
|
||||
min-width: 3rem;
|
||||
max-width: 3rem;
|
||||
height: 3rem;
|
||||
min-height: 3rem;
|
||||
max-height: 3rem;
|
||||
flex: 0 0 3rem;
|
||||
aspect-ratio: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
|
||||
.nodedc-expanded-workspace-button:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodedc-expanded-workspace-button select,
|
||||
|
|
@ -449,6 +454,14 @@ code {
|
|||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nodedc-expanded-workspace-avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.nodedc-expanded-user-group {
|
||||
display: inline-flex;
|
||||
height: var(--nodedc-shell-pill-height);
|
||||
|
|
@ -654,6 +667,8 @@ code {
|
|||
.launcher-main:has(.admin-panel-layer--fullscreen) .service-rail {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(calc(var(--launcher-rail-height) + var(--launcher-rail-bottom) + var(--launcher-stage-rail-gap)));
|
||||
transition-delay: 160ms, 120ms;
|
||||
}
|
||||
|
||||
.service-stage {
|
||||
|
|
@ -1078,6 +1093,10 @@ code {
|
|||
border-radius: var(--launcher-radius-card);
|
||||
background: rgba(8, 8, 11, 0.72);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36);
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
transform 440ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 240ms ease;
|
||||
}
|
||||
|
||||
.service-rail__backdrop-media {
|
||||
|
|
@ -1396,12 +1415,17 @@ code {
|
|||
left: var(--launcher-page-pad);
|
||||
display: flex;
|
||||
gap: var(--admin-panel-gap);
|
||||
width: calc(var(--admin-nav-width) + var(--admin-panel-gap) + var(--admin-content-width));
|
||||
pointer-events: none;
|
||||
transition:
|
||||
width 500ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
bottom 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.admin-panel-layer--fullscreen {
|
||||
right: var(--launcher-page-pad);
|
||||
width: calc(100% - (var(--launcher-page-pad) * 2));
|
||||
bottom: var(--launcher-page-pad);
|
||||
transition-delay: 0ms, 150ms;
|
||||
}
|
||||
|
||||
.admin-panel-nav,
|
||||
|
|
@ -1420,6 +1444,7 @@ code {
|
|||
.admin-panel-nav {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 var(--admin-nav-width);
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 1.05rem;
|
||||
width: var(--admin-nav-width);
|
||||
|
|
@ -1429,7 +1454,9 @@ code {
|
|||
}
|
||||
|
||||
.admin-panel-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 var(--admin-content-width);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
width: var(--admin-content-width);
|
||||
|
|
@ -1437,12 +1464,16 @@ code {
|
|||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
flex-basis 500ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
width 500ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
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;
|
||||
width: calc(100% - var(--admin-nav-width) - var(--admin-panel-gap));
|
||||
flex-basis: calc(100% - var(--admin-nav-width) - var(--admin-panel-gap));
|
||||
}
|
||||
|
||||
.profile-settings-layer {
|
||||
|
|
@ -1813,6 +1844,19 @@ code {
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-panel-content__body:has(.service-content-modal-layer) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.admin-panel-content__body:has(> .service-content-modal-layer) > :not(.service-content-modal-layer) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.admin-panel-content:has(.service-content-modal-layer) > .admin-header {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-panel-content .admin-section-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
|
@ -2238,11 +2282,11 @@ code {
|
|||
}
|
||||
|
||||
.services-admin-table th:nth-child(1) {
|
||||
width: 24%;
|
||||
width: 23%;
|
||||
}
|
||||
|
||||
.services-admin-table th:nth-child(2) {
|
||||
width: 13%;
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.services-admin-table th:nth-child(3) {
|
||||
|
|
@ -2250,7 +2294,7 @@ code {
|
|||
}
|
||||
|
||||
.services-admin-table th:nth-child(4) {
|
||||
width: 25%;
|
||||
width: 27%;
|
||||
}
|
||||
|
||||
.services-admin-table th:nth-child(5) {
|
||||
|
|
@ -2303,6 +2347,9 @@ code {
|
|||
padding: 0.18rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table-input:hover,
|
||||
|
|
@ -2633,13 +2680,44 @@ code {
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
.services-admin-table th:nth-child(4),
|
||||
.services-admin-table td.services-admin-table__launch {
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
.services-admin-table__edit {
|
||||
width: 2.35rem;
|
||||
min-width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
width: 1.9rem;
|
||||
min-width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-icon-action {
|
||||
border: 0;
|
||||
background: transparent !important;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-icon-action:hover,
|
||||
.admin-icon-action:focus-visible {
|
||||
border: 0;
|
||||
background: transparent !important;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-icon-action svg {
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
}
|
||||
|
||||
.invite-icon-action {
|
||||
width: 1.9rem;
|
||||
min-width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
}
|
||||
|
||||
.services-admin-table__drag-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
|
@ -2669,15 +2747,20 @@ code {
|
|||
}
|
||||
|
||||
.service-content-modal-layer {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
inset: 0;
|
||||
inset: var(--admin-panel-gap);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.4rem;
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
place-items: stretch;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-modal);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)),
|
||||
rgba(10, 10, 13, 0.9);
|
||||
box-shadow: 0 34px 120px rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(34px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(34px) saturate(1.12);
|
||||
}
|
||||
|
||||
.nodedc-delete-modal-layer {
|
||||
|
|
@ -2808,19 +2891,26 @@ code {
|
|||
.service-content-modal {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(58rem, calc(100vw - 2.8rem));
|
||||
max-height: min(44rem, calc(100vh - 2.8rem));
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
border-radius: var(--launcher-radius-modal);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)),
|
||||
rgba(10, 10, 13, 0.9);
|
||||
box-shadow: 0 34px 120px rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(34px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(34px) saturate(1.12);
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.client-editor-modal {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.service-content-modal__head,
|
||||
|
|
@ -2829,6 +2919,9 @@ code {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.service-content-modal__foot-actions {
|
||||
|
|
@ -2850,6 +2943,8 @@ code {
|
|||
|
||||
.service-content-modal__grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
|
|
@ -2899,9 +2994,10 @@ code {
|
|||
|
||||
.service-media-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
min-height: 3.35rem;
|
||||
gap: 0.22rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
|
|
@ -2965,6 +3061,34 @@ code {
|
|||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.service-media-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 2.78rem;
|
||||
min-width: 2.78rem;
|
||||
height: 2.78rem;
|
||||
min-height: 2.78rem;
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.service-media-preview img,
|
||||
.service-media-preview video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.service-media-source-button {
|
||||
display: grid;
|
||||
width: 2.78rem;
|
||||
|
|
@ -2987,6 +3111,90 @@ code {
|
|||
color: rgba(8, 8, 10, 0.96);
|
||||
}
|
||||
|
||||
.client-avatar-field {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.client-avatar-control {
|
||||
display: grid;
|
||||
min-height: 4rem;
|
||||
grid-template-columns: 3.25rem minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-control);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.38rem;
|
||||
}
|
||||
|
||||
.client-avatar-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 3.1rem;
|
||||
min-width: 3.1rem;
|
||||
height: 3.1rem;
|
||||
min-height: 3.1rem;
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.client-avatar-preview__image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.client-avatar-control__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.client-avatar-control__copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-avatar-control__copy small,
|
||||
.client-avatar-error {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.client-avatar-upload-button {
|
||||
position: relative;
|
||||
min-height: 2.65rem;
|
||||
}
|
||||
|
||||
.client-avatar-upload-button input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-avatar-clear-action {
|
||||
width: 2.35rem;
|
||||
min-width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
}
|
||||
|
||||
.service-content-field textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
|
|
@ -3214,18 +3422,39 @@ code {
|
|||
}
|
||||
|
||||
.task-access-modal {
|
||||
width: min(44rem, calc(100vw - 2rem));
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.task-access-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.task-access-summary .info-line {
|
||||
min-width: 0;
|
||||
min-height: 3.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-access-summary .info-line strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-workspace-access-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.task-workspace-access-card {
|
||||
|
|
@ -3240,13 +3469,21 @@ code {
|
|||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 10.8rem;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.task-workspace-access-card__head > div {
|
||||
min-width: 0;
|
||||
padding-top: 0.08rem;
|
||||
}
|
||||
|
||||
.task-workspace-access-card__head strong,
|
||||
.task-workspace-access-card__head small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-workspace-access-card__head small {
|
||||
|
|
@ -3255,25 +3492,66 @@ code {
|
|||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.task-project-access-note {
|
||||
.task-project-access-list {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.task-project-access-note strong {
|
||||
.task-project-access-list__head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-project-access-list__head strong {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.task-project-access-note span {
|
||||
.task-project-access-list__head span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-project-access-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 10.8rem;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
min-height: 3.4rem;
|
||||
padding: 0.55rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.task-project-access-row__meta {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.task-project-access-row__meta strong,
|
||||
.task-project-access-row__meta small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-project-access-row__meta strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.task-project-access-row__meta small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.access-explanation {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
|
|
@ -3548,7 +3826,7 @@ code {
|
|||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.02)),
|
||||
rgba(11, 11, 14, 0.94);
|
||||
box-shadow: 0 26px 92px rgba(0, 0, 0, 0.62);
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(var(--nodedc-dropdown-blur, 44px)) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(var(--nodedc-dropdown-blur, 44px)) saturate(1.12);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import {
|
|||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
|
|
@ -66,7 +65,7 @@ import {
|
|||
type MeResponse,
|
||||
type TaskManagerWorkspaceCreationPolicy,
|
||||
} from "../../shared/api/mockApi";
|
||||
import type { TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
|
||||
import type { TaskManagerProjectSummary, TaskManagerWorkspaceMemberRole, TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
|
||||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||
import { cn } from "../../shared/lib/cn";
|
||||
import { formatDate, formatDateTime } from "../../shared/lib/format";
|
||||
|
|
@ -114,6 +113,14 @@ export interface EnsureTaskManagerWorkspaceMemberCommand {
|
|||
role: TaskManagerWorkspaceMemberRole;
|
||||
}
|
||||
|
||||
export interface EnsureTaskManagerProjectMemberCommand {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
role: TaskManagerWorkspaceMemberRole;
|
||||
}
|
||||
|
||||
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
|
||||
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
|
||||
|
|
@ -166,8 +173,10 @@ export function AdminOverlay({
|
|||
taskManagerWorkspacesLoading,
|
||||
taskManagerWorkspacesError,
|
||||
pendingTaskManagerMemberships,
|
||||
pendingTaskManagerProjectMemberships,
|
||||
onRefreshTaskManagerWorkspaces,
|
||||
onSetTaskManagerWorkspaceMemberRole,
|
||||
onSetTaskManagerProjectMemberRole,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
me: MeResponse;
|
||||
|
|
@ -198,8 +207,10 @@ export function AdminOverlay({
|
|||
taskManagerWorkspacesLoading: boolean;
|
||||
taskManagerWorkspacesError: string | null;
|
||||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||
pendingTaskManagerProjectMemberships: Record<string, boolean>;
|
||||
onRefreshTaskManagerWorkspaces: () => void;
|
||||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||||
}) {
|
||||
const isRoot = me.launcherRole === "root_admin";
|
||||
const sections = isRoot ? rootSections : clientSections;
|
||||
|
|
@ -375,7 +386,10 @@ export function AdminOverlay({
|
|||
onUpdateUser={onUpdateUser}
|
||||
onUpdateMembership={onUpdateMembership}
|
||||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
|
||||
taskManagerWorkspaceCatalog={taskManagerWorkspaces}
|
||||
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
|
||||
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
|
||||
/>
|
||||
) : null}
|
||||
{activeSection === "invites" ? (
|
||||
|
|
@ -411,9 +425,6 @@ function AdminHeader({
|
|||
return (
|
||||
<div className="admin-header">
|
||||
<div className="admin-header__actions">
|
||||
<IconButton label="Проверить доступы" className="admin-circle-action" type="button">
|
||||
<SearchCheck size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={isFullscreen ? "Свернуть панель" : "Открыть панель на весь экран"}
|
||||
className={cn("admin-circle-action", isFullscreen && "admin-circle-action--active")}
|
||||
|
|
@ -557,11 +568,11 @@ function ClientsSection({
|
|||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Редактировать клиента ${client.name}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
className="admin-icon-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => setEditingClientId(client.id)}
|
||||
>
|
||||
<Edit3 size={15} />
|
||||
<Edit3 size={12} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -830,11 +841,11 @@ function GroupsSection({
|
|||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Редактировать группу ${group.name}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
className="admin-icon-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => setEditingGroupId(group.id)}
|
||||
>
|
||||
<Edit3 size={15} />
|
||||
<Edit3 size={12} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -948,6 +959,14 @@ const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSe
|
|||
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
||||
];
|
||||
|
||||
const taskManagerProjectRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
|
||||
{ value: "unset", label: "—", description: "Не назначен" },
|
||||
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
|
||||
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
|
||||
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" },
|
||||
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
||||
];
|
||||
|
||||
function membershipRoleLabel(role: ClientMembershipRole): string {
|
||||
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
||||
}
|
||||
|
|
@ -1005,6 +1024,28 @@ function getTaskManagerMembershipRole(data: LauncherData, clientId: string, user
|
|||
return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset";
|
||||
}
|
||||
|
||||
function getTaskManagerProjectMembershipRole(
|
||||
data: LauncherData,
|
||||
clientId: string,
|
||||
userId: string,
|
||||
workspaceSlug: string,
|
||||
projectId: string
|
||||
): TaskManagerWorkspaceMemberRole {
|
||||
return (
|
||||
data.taskManagerProjectMemberships.find(
|
||||
(membership) =>
|
||||
membership.clientId === clientId &&
|
||||
membership.userId === userId &&
|
||||
membership.workspaceSlug === workspaceSlug &&
|
||||
membership.projectId === projectId
|
||||
)?.role ?? "unset"
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkspaceCatalogProjects(workspace: ClientTaskManagerWorkspaceBinding, catalog: TaskManagerWorkspaceSummary[]): TaskManagerProjectSummary[] {
|
||||
return catalog.find((item) => item.slug === workspace.slug)?.projects ?? [];
|
||||
}
|
||||
|
||||
function normalizeClientTaskManagerWorkspaceDraft(workspaces: ClientTaskManagerWorkspaceBinding[]): ClientTaskManagerWorkspaceBinding[] {
|
||||
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
|
||||
|
||||
|
|
@ -1185,10 +1226,10 @@ function ServicesSection({
|
|||
function ServiceTableColGroup() {
|
||||
return (
|
||||
<colgroup>
|
||||
<col style={{ width: "24%" }} />
|
||||
<col style={{ width: "13%" }} />
|
||||
<col style={{ width: "23%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "25%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "27%" }} />
|
||||
<col style={{ width: "15%" }} />
|
||||
<col style={{ width: "3.4rem" }} />
|
||||
<col style={{ width: "3.1rem" }} />
|
||||
|
|
@ -1271,7 +1312,7 @@ function ServiceTableCells({
|
|||
onChange={(status) => onUpdateService(service.id, { status })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td className="services-admin-table__launch">
|
||||
<input
|
||||
className="admin-table-input"
|
||||
value={getServiceLaunchLink(service)}
|
||||
|
|
@ -1288,8 +1329,13 @@ function ServiceTableCells({
|
|||
/>
|
||||
</td>
|
||||
<td className="services-admin-table__actions">
|
||||
<IconButton label={`Контент витрины ${service.title}`} className="admin-circle-action services-admin-table__edit" type="button" onClick={onOpenContent}>
|
||||
<Edit3 size={15} />
|
||||
<IconButton
|
||||
label={`Контент витрины ${service.title}`}
|
||||
className="admin-icon-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={onOpenContent}
|
||||
>
|
||||
<Edit3 size={12} />
|
||||
</IconButton>
|
||||
</td>
|
||||
<td className="services-admin-table__drag-cell">
|
||||
|
|
@ -1441,20 +1487,42 @@ function ServiceContentModal({
|
|||
onDelete: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Service>(service);
|
||||
const [mediaPreviewUrls, setMediaPreviewUrls] = useState<{ cover: string | null; ambient: string | null }>({
|
||||
cover: service.coverImageUrl ?? null,
|
||||
ambient: service.ambientVideoUrl ?? null,
|
||||
});
|
||||
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
|
||||
const [storageError, setStorageError] = useState<string | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(service);
|
||||
setMediaPreviewUrls({
|
||||
cover: service.coverImageUrl ?? null,
|
||||
ambient: service.ambientVideoUrl ?? null,
|
||||
});
|
||||
setStorageError(null);
|
||||
setUploadingSlot(null);
|
||||
}, [service]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(mediaPreviewUrls).forEach((previewUrl) => {
|
||||
if (previewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [mediaPreviewUrls]);
|
||||
|
||||
function update<K extends keyof Service>(key: K, value: Service[K]) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateMediaPreview(slot: "cover" | "ambient", previewUrl: string | null) {
|
||||
setMediaPreviewUrls((current) => ({ ...current, [slot]: previewUrl }));
|
||||
}
|
||||
|
||||
async function handleCoverUpload(file?: File) {
|
||||
if (!file) return;
|
||||
await uploadServiceMedia(file, "cover");
|
||||
|
|
@ -1466,6 +1534,8 @@ function ServiceContentModal({
|
|||
}
|
||||
|
||||
async function uploadServiceMedia(file: File, slot: "cover" | "ambient") {
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
updateMediaPreview(slot, localPreviewUrl);
|
||||
setStorageError(null);
|
||||
setUploadingSlot(slot);
|
||||
|
||||
|
|
@ -1486,6 +1556,7 @@ function ServiceContentModal({
|
|||
}
|
||||
} catch (error) {
|
||||
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage");
|
||||
updateMediaPreview(slot, slot === "cover" ? (draft.coverImageUrl ?? null) : (draft.ambientVideoUrl ?? null));
|
||||
} finally {
|
||||
setUploadingSlot(null);
|
||||
}
|
||||
|
|
@ -1542,12 +1613,15 @@ function ServiceContentModal({
|
|||
value={draft.coverImageUrl ?? ""}
|
||||
fileName={draft.coverMediaFileName ?? null}
|
||||
isUploading={uploadingSlot === "cover"}
|
||||
previewSrc={mediaPreviewUrls.cover}
|
||||
previewKind={draft.coverMediaKind}
|
||||
onSourceChange={(source) => update("coverMediaSource", source)}
|
||||
onUrlChange={(value) => {
|
||||
update("coverImageUrl", value || null);
|
||||
update("coverMediaSource", "url");
|
||||
update("coverMediaKind", mediaKindFromUrl(value));
|
||||
update("coverMediaFileName", null);
|
||||
updateMediaPreview("cover", value || null);
|
||||
}}
|
||||
onFileChange={handleCoverUpload}
|
||||
/>
|
||||
|
|
@ -1559,24 +1633,19 @@ function ServiceContentModal({
|
|||
value={draft.ambientVideoUrl ?? ""}
|
||||
fileName={draft.ambientMediaFileName ?? null}
|
||||
isUploading={uploadingSlot === "ambient"}
|
||||
previewSrc={mediaPreviewUrls.ambient}
|
||||
previewKind={draft.ambientMediaKind}
|
||||
onSourceChange={(source) => update("ambientMediaSource", source)}
|
||||
onUrlChange={(value) => {
|
||||
update("ambientVideoUrl", value || null);
|
||||
update("ambientMediaSource", "url");
|
||||
update("ambientMediaKind", mediaKindFromUrl(value));
|
||||
update("ambientMediaFileName", null);
|
||||
updateMediaPreview("ambient", value || null);
|
||||
}}
|
||||
onFileChange={handleAmbientUpload}
|
||||
/>
|
||||
|
||||
<div className="service-content-preview service-content-preview--image">
|
||||
{draft.coverImageUrl ? <MediaPreview src={draft.coverImageUrl} kind={draft.coverMediaKind} /> : <ImageIcon size={30} />}
|
||||
</div>
|
||||
|
||||
<div className="service-content-preview service-content-preview--video">
|
||||
{draft.ambientVideoUrl ? <MediaPreview src={draft.ambientVideoUrl} kind={draft.ambientMediaKind} /> : <Video size={30} />}
|
||||
</div>
|
||||
|
||||
{storageError ? <p className="service-content-storage-error">{storageError}</p> : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -1659,11 +1728,27 @@ function ClientEditorModal({
|
|||
canDelete: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Client>(client);
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(client.avatarUrl ?? null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [storageError, setStorageError] = useState<string | null>(null);
|
||||
const taskManagerWorkspaceBindings = getClientTaskManagerWorkspaces(draft);
|
||||
const selectedTaskManagerWorkspaceSlugs = new Set(taskManagerWorkspaceBindings.map((workspace) => workspace.slug));
|
||||
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(draft);
|
||||
|
||||
useEffect(() => setDraft(client), [client]);
|
||||
useEffect(() => {
|
||||
setDraft(client);
|
||||
setAvatarPreviewUrl(client.avatarUrl ?? null);
|
||||
setUploadingAvatar(false);
|
||||
setStorageError(null);
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarPreviewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(avatarPreviewUrl);
|
||||
}
|
||||
};
|
||||
}, [avatarPreviewUrl]);
|
||||
|
||||
function update<K extends keyof Client>(key: K, value: Client[K]) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
|
|
@ -1710,9 +1795,28 @@ function ClientEditorModal({
|
|||
updateTaskManagerWorkspaces(normalizeClientTaskManagerWorkspaceDraft(nextWorkspaces));
|
||||
}
|
||||
|
||||
async function handleAvatarUpload(file?: File) {
|
||||
if (!file) return;
|
||||
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
setAvatarPreviewUrl(localPreviewUrl);
|
||||
setUploadingAvatar(true);
|
||||
setStorageError(null);
|
||||
|
||||
try {
|
||||
const storedFile = await uploadStorageFile(file);
|
||||
update("avatarUrl", storedFile.url);
|
||||
} catch (error) {
|
||||
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить аватар компании");
|
||||
setAvatarPreviewUrl(draft.avatarUrl ?? null);
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
|
||||
<article className="service-content-modal admin-entity-modal">
|
||||
<article className="service-content-modal admin-entity-modal client-editor-modal">
|
||||
<EntityModalHead
|
||||
eyebrow="Клиент"
|
||||
title={client.name}
|
||||
|
|
@ -1757,6 +1861,44 @@ function ClientEditorModal({
|
|||
<span>Статус</span>
|
||||
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
|
||||
</div>
|
||||
<div className="service-content-field service-content-field--wide client-avatar-field">
|
||||
<span>Аватар компании</span>
|
||||
<div className="client-avatar-control">
|
||||
<div className="client-avatar-preview" aria-hidden="true">
|
||||
{avatarPreviewUrl ? <img className="client-avatar-preview__image" src={avatarPreviewUrl} alt="" /> : null}
|
||||
</div>
|
||||
<div className="client-avatar-control__copy">
|
||||
<strong>{avatarPreviewUrl ? "Аватар подключён" : "Аватар не задан"}</strong>
|
||||
<small>Показывается в верхнем переключателе компании.</small>
|
||||
</div>
|
||||
<label className="service-media-file-button client-avatar-upload-button">
|
||||
{uploadingAvatar ? "Загрузка..." : "Выберите файл"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={uploadingAvatar}
|
||||
onChange={(event) => {
|
||||
void handleAvatarUpload(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{avatarPreviewUrl ? (
|
||||
<button
|
||||
className="admin-icon-action client-avatar-clear-action"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
update("avatarUrl", null);
|
||||
setAvatarPreviewUrl(null);
|
||||
}}
|
||||
aria-label="Убрать аватар"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{storageError ? <small className="client-avatar-error">{storageError}</small> : null}
|
||||
</div>
|
||||
<div className="service-content-field service-content-field--wide">
|
||||
<span>Operational Core workspaces</span>
|
||||
<div className="task-workspace-picker-card">
|
||||
|
|
@ -1820,10 +1962,6 @@ function ClientEditorModal({
|
|||
<span>Email</span>
|
||||
<input value={draft.contactEmail ?? ""} onChange={(event) => update("contactEmail", event.target.value || null)} />
|
||||
</label>
|
||||
<div className="service-content-field">
|
||||
<span>Демо до</span>
|
||||
<NodeDcDateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
|
||||
</div>
|
||||
<div className="service-content-field">
|
||||
<span>Договор с</span>
|
||||
<NodeDcDateField
|
||||
|
|
@ -1848,6 +1986,10 @@ function ClientEditorModal({
|
|||
<span>Оплачено до</span>
|
||||
<NodeDcDateField value={draft.paidUntil ?? null} label="Оплачено до" onChange={(value) => update("paidUntil", value)} />
|
||||
</div>
|
||||
<div className="service-content-field">
|
||||
<span>Демо до</span>
|
||||
<NodeDcDateField value={draft.demoEndsAt ?? null} label="Демо до" onChange={(value) => update("demoEndsAt", value)} />
|
||||
</div>
|
||||
<label className="service-content-field service-content-field--wide">
|
||||
<span>Заметки</span>
|
||||
<textarea value={draft.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} rows={4} />
|
||||
|
|
@ -2138,6 +2280,8 @@ function MediaSourceField({
|
|||
value,
|
||||
fileName,
|
||||
isUploading = false,
|
||||
previewSrc,
|
||||
previewKind,
|
||||
onSourceChange,
|
||||
onUrlChange,
|
||||
onFileChange,
|
||||
|
|
@ -2148,14 +2292,18 @@ function MediaSourceField({
|
|||
value: string;
|
||||
fileName?: string | null;
|
||||
isUploading?: boolean;
|
||||
previewSrc?: string | null;
|
||||
previewKind?: MediaKind | null;
|
||||
onSourceChange: (source: ServiceMediaSource) => void;
|
||||
onUrlChange: (value: string) => void;
|
||||
onFileChange: (file?: File) => void | Promise<void>;
|
||||
}) {
|
||||
const inputId = `${label.replace(/\s+/g, "-").toLowerCase()}-${source}`;
|
||||
const displayFileName = isUploading ? "Сохраняем в storage..." : truncateText(fileName ?? "Файл не выбран", 15);
|
||||
const fileTitle = isUploading ? undefined : (fileName ?? "Файл не выбран");
|
||||
|
||||
return (
|
||||
<div className="service-content-field service-media-field">
|
||||
<div className="service-content-field service-content-field--wide service-media-field">
|
||||
<span>
|
||||
{icon} {label}
|
||||
</span>
|
||||
|
|
@ -2165,7 +2313,9 @@ function MediaSourceField({
|
|||
<label className="service-media-file-button" htmlFor={inputId}>
|
||||
Выберите файл
|
||||
</label>
|
||||
<span className="service-media-file-name">{isUploading ? "Сохраняем в storage..." : fileName ?? "Файл не выбран"}</span>
|
||||
<span className="service-media-file-name" title={fileTitle}>
|
||||
{displayFileName}
|
||||
</span>
|
||||
<input id={inputId} type="file" accept={mediaAccept} onChange={(event) => onFileChange(event.currentTarget.files?.[0])} />
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -2192,11 +2342,18 @@ function MediaSourceField({
|
|||
<Globe2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="service-media-preview" aria-hidden="true">
|
||||
{previewSrc ? <MediaPreview src={previewSrc} kind={previewKind} /> : icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number) {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
||||
}
|
||||
|
||||
function MediaPreview({ src, kind }: { src: string; kind?: MediaKind | null }) {
|
||||
if (kind === "video" || mediaKindFromUrl(src) === "video") {
|
||||
return <video src={src} autoPlay loop muted playsInline />;
|
||||
|
|
@ -2228,7 +2385,10 @@ function AccessSection({
|
|||
onUpdateUser,
|
||||
onUpdateMembership,
|
||||
pendingTaskManagerMemberships,
|
||||
pendingTaskManagerProjectMemberships,
|
||||
taskManagerWorkspaceCatalog,
|
||||
onSetTaskManagerWorkspaceMemberRole,
|
||||
onSetTaskManagerProjectMemberRole,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
matrix: ReturnType<typeof buildAccessMatrix>;
|
||||
|
|
@ -2239,10 +2399,13 @@ function AccessSection({
|
|||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||
pendingTaskManagerProjectMemberships: Record<string, boolean>;
|
||||
taskManagerWorkspaceCatalog: TaskManagerWorkspaceSummary[];
|
||||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||||
}) {
|
||||
const hasUsers = matrix.users.length > 0;
|
||||
const taskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client);
|
||||
const clientTaskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client);
|
||||
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(matrix.client);
|
||||
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
|
||||
|
||||
|
|
@ -2259,21 +2422,10 @@ function AccessSection({
|
|||
<span>Добавьте участника или инвайт, после этого здесь появятся ячейки доступа.</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="access-explanation access-explanation--empty">
|
||||
<p className="eyebrow">Explanation panel</p>
|
||||
<h3>Ячейка не выбрана</h3>
|
||||
<div className="explanation-stack">
|
||||
<InfoLine label="Итог" value="Нет данных" />
|
||||
<InfoLine label="Причина" value="У выбранного клиента нет участников в текущем наборе данных" />
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedUser = selectedCell ? getUser(data, selectedCell.userId) : null;
|
||||
const selectedService = selectedCell ? getService(data, selectedCell.serviceId) : null;
|
||||
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
|
||||
|
||||
return (
|
||||
|
|
@ -2366,32 +2518,6 @@ function AccessSection({
|
|||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="access-explanation">
|
||||
<p className="eyebrow">Explanation panel</p>
|
||||
{selectedCell && selectedUser && selectedService ? (
|
||||
<>
|
||||
<h3>
|
||||
{selectedUser.name} / {selectedService.title}
|
||||
</h3>
|
||||
<div className="explanation-stack">
|
||||
<InfoLine label="Итог" value={selectedCell.effectiveAccess.allowed ? "Есть доступ" : "Нет доступа"} />
|
||||
<InfoLine label="Можно открыть" value={selectedCell.effectiveAccess.openEnabled ? "Да" : "Нет"} />
|
||||
<InfoLine label="Причина" value={selectedCell.effectiveAccess.reason} />
|
||||
<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>
|
||||
|
||||
{detailsCell ? (
|
||||
<OperationalCoreAccessModal
|
||||
data={data}
|
||||
|
|
@ -2399,12 +2525,15 @@ function AccessSection({
|
|||
user={getUser(data, detailsCell.userId)}
|
||||
service={getService(data, detailsCell.serviceId)}
|
||||
cell={detailsCell}
|
||||
workspaces={taskManagerWorkspaces}
|
||||
workspaces={clientTaskManagerWorkspaces}
|
||||
workspaceCatalog={taskManagerWorkspaceCatalog}
|
||||
pendingAccessAssignments={pendingAccessAssignments}
|
||||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
|
||||
onClose={() => setDetailsCell(null)}
|
||||
onSetUserServiceAccess={onSetUserServiceAccess}
|
||||
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
|
||||
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -2501,11 +2630,14 @@ function OperationalCoreAccessModal({
|
|||
service,
|
||||
cell,
|
||||
workspaces,
|
||||
workspaceCatalog,
|
||||
pendingAccessAssignments,
|
||||
pendingTaskManagerMemberships,
|
||||
pendingTaskManagerProjectMemberships,
|
||||
onClose,
|
||||
onSetUserServiceAccess,
|
||||
onSetTaskManagerWorkspaceMemberRole,
|
||||
onSetTaskManagerProjectMemberRole,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
client: Client;
|
||||
|
|
@ -2513,11 +2645,14 @@ function OperationalCoreAccessModal({
|
|||
service: Service;
|
||||
cell: AccessMatrixCell;
|
||||
workspaces: ClientTaskManagerWorkspaceBinding[];
|
||||
workspaceCatalog: TaskManagerWorkspaceSummary[];
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||
pendingTaskManagerProjectMemberships: Record<string, boolean>;
|
||||
onClose: () => void;
|
||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
|
||||
}) {
|
||||
const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id);
|
||||
const protectedUser = user.id === "user_root" || membership?.role === "client_owner";
|
||||
|
|
@ -2538,6 +2673,7 @@ function OperationalCoreAccessModal({
|
|||
{workspaces.length ? (
|
||||
workspaces.map((workspace) => {
|
||||
const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug);
|
||||
const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog);
|
||||
const pendingKey = `${client.id}:${user.id}:${workspace.slug}`;
|
||||
const pending = Boolean(pendingTaskManagerMemberships[pendingKey]) || basePendingValue !== undefined;
|
||||
const value: OperationalCoreRoleSelectValue = pending
|
||||
|
|
@ -2580,9 +2716,68 @@ function OperationalCoreAccessModal({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="task-project-access-note">
|
||||
<strong>Проекты</strong>
|
||||
<span>Сейчас наследуют роль workspace. Точечные роли проектов подключаются следующим Project API-адаптером.</span>
|
||||
<div className="task-project-access-list">
|
||||
<div className="task-project-access-list__head">
|
||||
<strong>Проекты</strong>
|
||||
<span>{projects.length ? "Точечные роли внутри выбранного workspace" : "В этом workspace пока нет проектов"}</span>
|
||||
</div>
|
||||
{projects.map((project) => {
|
||||
const projectRole = getTaskManagerProjectMembershipRole(data, client.id, user.id, workspace.slug, project.id);
|
||||
const projectPendingKey = `${client.id}:${user.id}:${workspace.slug}:${project.id}`;
|
||||
const projectPending = Boolean(pendingTaskManagerProjectMemberships[projectPendingKey]);
|
||||
const projectValue: OperationalCoreRoleSelectValue = projectPending ? "pending" : taskManagerRoleToAccessAssignment(projectRole);
|
||||
|
||||
return (
|
||||
<div key={project.id} className="task-project-access-row">
|
||||
<div className="task-project-access-row__meta">
|
||||
<strong>{project.name}</strong>
|
||||
<small>
|
||||
{project.identifier}
|
||||
{project.memberCount ? ` · ${project.memberCount} участников` : ""}
|
||||
</small>
|
||||
</div>
|
||||
{protectedUser ? (
|
||||
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill>
|
||||
) : (
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={projectValue}
|
||||
options={taskManagerProjectRoleOptions}
|
||||
label={`Роль ${user.name} в проекте ${project.name}`}
|
||||
minMenuWidth={180}
|
||||
disabled={projectPending}
|
||||
onChange={(nextValue) => {
|
||||
if (nextValue === "pending") return;
|
||||
|
||||
const nextTaskManagerRole = accessAssignmentToTaskManagerRole(nextValue);
|
||||
if (nextTaskManagerRole !== "unset") {
|
||||
if (baseAssignmentValue === "unset" || baseAssignmentValue === "deny") {
|
||||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
|
||||
}
|
||||
if (role === "unset") {
|
||||
onSetTaskManagerWorkspaceMemberRole({
|
||||
clientId: client.id,
|
||||
userId: user.id,
|
||||
workspaceSlug: workspace.slug,
|
||||
role: nextTaskManagerRole,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSetTaskManagerProjectMemberRole({
|
||||
clientId: client.id,
|
||||
userId: user.id,
|
||||
workspaceSlug: workspace.slug,
|
||||
projectId: project.id,
|
||||
role: nextTaskManagerRole,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -2818,11 +3013,11 @@ function InvitesSection({
|
|||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||
<IconButton
|
||||
label={isCopied ? `Инвайт ${invite.email} скопирован` : `Скопировать инвайт ${invite.email}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => void handleCopyInvite(invite)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<Copy size={11} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -2838,11 +3033,11 @@ function InvitesSection({
|
|||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Удалить инвайт ${invite.email}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
className="admin-icon-action invite-icon-action"
|
||||
type="button"
|
||||
onClick={() => setDeleteInviteId(invite.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -2948,11 +3143,11 @@ function SyncSection({
|
|||
<td>
|
||||
<IconButton
|
||||
label={`Повторить синхронизацию ${sync.objectName}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
className="admin-icon-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => onRetrySync(sync.id)}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
<RefreshCw size={12} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -36,17 +36,11 @@ export function TopBar({
|
|||
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
||||
const activeClient = availableClients.find((client) => client.id === activeClientId);
|
||||
const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
|
||||
const clientOptions = availableClients.map((client) => ({
|
||||
value: client.id,
|
||||
label: client.name,
|
||||
description: client.legalName ?? undefined,
|
||||
}));
|
||||
const profileSelectOptions = profileOptions.map((profile) => ({
|
||||
value: profile.userId,
|
||||
label: profile.label,
|
||||
description: profile.description,
|
||||
}));
|
||||
|
||||
return (
|
||||
<header className="nodedc-expanded-toolbar-shell">
|
||||
|
|
@ -76,7 +70,7 @@ export function TopBar({
|
|||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
|
||||
{activeClient?.avatarUrl ? <img src={activeClient.avatarUrl} alt="" className="nodedc-expanded-workspace-avatar" /> : null}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -86,27 +80,6 @@ export function TopBar({
|
|||
<span>Витрина</span>
|
||||
</button>
|
||||
|
||||
<NodeDcSelect
|
||||
value={activeProfileId}
|
||||
options={profileSelectOptions}
|
||||
label="Выбрать профиль доступа"
|
||||
minMenuWidth={236}
|
||||
onChange={(userId) => onProfileChange(userId)}
|
||||
trigger={({ open, selectedOption, toggle, setTriggerRef }) => (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
className="nodedc-expanded-nav-button nodedc-expanded-select-button"
|
||||
type="button"
|
||||
data-active="false"
|
||||
aria-label="Выбрать профиль доступа"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{selectedOption?.label ?? activeProfile?.label ?? me.user.name}</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{me.permissions.canOpenAdmin ? (
|
||||
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onToggleAdmin}>
|
||||
<span>Администрирование</span>
|
||||
|
|
|
|||