FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: АДМИНКА РОЛЕЙ TASKER

This commit is contained in:
DCCONSTRUCTIONS 2026-05-06 15:30:56 +03:00
parent d4eba0ff3a
commit 5f461d57ea
9 changed files with 604 additions and 58 deletions

View File

@ -14,7 +14,14 @@
"contactEmail": "dcctouch@gmail.com",
"notes": "Live-клиент NODE.DC для первичной проверки control-plane, SSO и доступа к сервисам.",
"createdAt": "2026-05-04T00:00:00.000Z",
"updatedAt": "2026-05-04T12:55:13.842Z"
"updatedAt": "2026-05-06T08:44:44.882Z",
"integrations": {
"taskManager": {
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC"
}
},
"inn": null
}
],
"users": [
@ -491,10 +498,10 @@
"objectName": "DCTOUCH",
"objectType": "client",
"target": "authentik",
"state": "synced",
"state": "pending",
"lastSyncAt": "2026-05-04T12:55:13.842Z",
"error": null,
"updatedAt": "2026-05-04T12:55:13.842Z"
"updatedAt": "2026-05-06T08:44:44.887Z"
},
{
"id": "sync_dc_touch_authentik",
@ -1964,6 +1971,198 @@
"clientId": null,
"result": "success",
"details": "Logo link: http://launcher.local.nodedc/; Tasker workspace policy: task_admins_only"
},
{
"id": "audit_dctouch",
"at": "2026-05-06T08:44:44.887Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Обновлён клиент",
"objectType": "client",
"objectName": "DCTOUCH",
"clientId": "client_romashka",
"result": "success",
"details": null
},
{
"id": "audit_dc_constr",
"at": "2026-05-06T09:02:42.183Z",
"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: 15"
},
{
"id": "audit_dc_constr_2",
"at": "2026-05-06T09:02:45.197Z",
"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: 15"
},
{
"id": "audit_dc_constr_3",
"at": "2026-05-06T09:02:57.971Z",
"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: 15"
},
{
"id": "audit_dc_constr_4",
"at": "2026-05-06T09:02:59.293Z",
"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: 15"
},
{
"id": "audit_dc_constr_5",
"at": "2026-05-06T09:09:09.389Z",
"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: 15"
},
{
"id": "audit_dc_silver",
"at": "2026-05-06T09:46:34.612Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC SILVER",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: admin"
},
{
"id": "audit_dc_constr_6",
"at": "2026-05-06T09:46:41.427Z",
"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: member"
},
{
"id": "audit_dc_sudo",
"at": "2026-05-06T10:17:58.710Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC SUDO",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: admin"
},
{
"id": "audit_dc_sudo_2",
"at": "2026-05-06T10:21:44.717Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Снят Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC SUDO",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: nodedc"
},
{
"id": "audit_dc_support",
"at": "2026-05-06T10:38:27.410Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC SUPPORT",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: member"
},
{
"id": "audit_dc_silver_2",
"at": "2026-05-06T10:51:20.914Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC SILVER",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: member"
},
{
"id": "audit_dc_sudo_3",
"at": "2026-05-06T10:54:33.543Z",
"actorUserId": "system",
"actorName": "NODE.DC System",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC SUDO",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: admin"
},
{
"id": "audit_dc_silver007",
"at": "2026-05-06T11:20:45.826Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC SILVER007",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: member"
},
{
"id": "audit_dc_abramov",
"at": "2026-05-06T11:20:47.255Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC ABRAMOV",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: member"
},
{
"id": "audit_dc_constrictions",
"at": "2026-05-06T11:20:48.841Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Назначен Tasker workspace",
"objectType": "task-manager-membership",
"objectName": "DC CONSTRICTIONS",
"clientId": "client_romashka",
"result": "success",
"details": "Workspace: NODE DC; Role: member"
}
],
"settings": {
@ -1973,5 +2172,84 @@
"taskManager": {
"workspaceCreationPolicy": "task_admins_only"
}
},
"taskManagerMemberships": [
{
"id": "tasker_mem_client_romashka_user_silver_psih_nodedc",
"clientId": "client_romashka",
"userId": "user_silver_psih",
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC",
"role": "member",
"planeUserId": "7315d59a-50e1-4d26-8de8-ae632777b46e",
"planeRole": 15,
"updatedAt": "2026-05-06T10:51:20.911Z"
},
{
"id": "tasker_mem_client_romashka_user_constr_dc_yahoo_com_nodedc",
"clientId": "client_romashka",
"userId": "user_constr_dc_yahoo_com",
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC",
"role": "member",
"planeUserId": "62aa744d-32ef-427f-8ad2-184d2ec2f5e5",
"planeRole": 15,
"updatedAt": "2026-05-06T09:46:41.427Z"
},
{
"id": "tasker_mem_client_romashka_user_support_dctouch_ru_nodedc",
"clientId": "client_romashka",
"userId": "user_support_dctouch_ru",
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC",
"role": "member",
"planeUserId": "53e56870-8772-4dfd-9c8c-2eeacfb53caa",
"planeRole": 15,
"updatedAt": "2026-05-06T10:38:27.409Z"
},
{
"id": "tasker_mem_client_romashka_user_root_nodedc",
"clientId": "client_romashka",
"userId": "user_root",
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC",
"role": "admin",
"planeUserId": "844d7f18-285d-4671-8371-8ca9ca5ffa39",
"planeRole": 20,
"updatedAt": "2026-05-06T10:54:33.542Z"
},
{
"id": "tasker_mem_client_romashka_user_silverpsih007_gmail_com_nodedc",
"clientId": "client_romashka",
"userId": "user_silverpsih007_gmail_com",
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC",
"role": "member",
"planeUserId": "52817493-1ff4-44f9-aae4-463ecd512d51",
"planeRole": 15,
"updatedAt": "2026-05-06T11:20:45.826Z"
},
{
"id": "tasker_mem_client_romashka_user_abramov_dcconstructions_ru_nodedc",
"clientId": "client_romashka",
"userId": "user_abramov_dcconstructions_ru",
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC",
"role": "member",
"planeUserId": "d28a2d28-da56-4625-a211-d9bb3d06b0d3",
"planeRole": 15,
"updatedAt": "2026-05-06T11:20:47.255Z"
},
{
"id": "tasker_mem_client_romashka_user_support_dcconstructions_ru_nodedc",
"clientId": "client_romashka",
"userId": "user_support_dcconstructions_ru",
"workspaceSlug": "nodedc",
"workspaceName": "NODE DC",
"role": "member",
"planeUserId": "1cc7ae3a-1f42-41ac-8cc2-1ff0fce59554",
"planeRole": 15,
"updatedAt": "2026-05-06T11:20:48.841Z"
}
]
}

View File

@ -14,6 +14,7 @@ const collectionKeys = [
"invites",
"syncStatuses",
"auditEvents",
"taskManagerMemberships",
];
const clientTypes = new Set(["company", "person"]);
@ -167,13 +168,47 @@ export function createControlPlaneStore({ projectRoot }) {
const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {};
const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {};
upsertTaskManagerMembership(data, {
clientId: client.id,
userId: user.id,
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
role: normalizeTaskManagerMembershipRole(payload?.role),
planeUserId: membership.member?.id ?? null,
planeRole: typeof membership.role === "number" ? membership.role : null,
});
addAuditEvent(data, actor, {
action: "Назначен Tasker workspace",
objectType: "task-manager-membership",
objectName: user.name,
clientId: client.id,
result: "success",
details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${membership.role ?? payload?.role ?? "member"}`,
details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${payload?.role ?? "member"}`,
});
await writeData(data);
return { data };
}
async function removeTaskManagerWorkspaceMembership(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");
data.taskManagerMemberships = data.taskManagerMemberships.filter(
(membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug)
);
addAuditEvent(data, actor, {
action: "Снят Tasker workspace",
objectType: "task-manager-membership",
objectName: user.name,
clientId: client.id,
result: "success",
details: `Workspace: ${workspaceSlug}`,
});
await writeData(data);
@ -1055,6 +1090,7 @@ export function createControlPlaneStore({ projectRoot }) {
retrySync,
markUserAuthentikProvisioned,
recordTaskManagerWorkspaceMembership,
removeTaskManagerWorkspaceMembership,
setUserServiceAccess,
updateClient,
updateGroup,
@ -1120,6 +1156,36 @@ function normalizeClientIntegrations(payload, fallback = {}) {
};
}
function normalizeTaskManagerMembershipRole(value) {
return value === "guest" || value === "admin" || value === "member" ? value : "member";
}
function upsertTaskManagerMembership(data, payload) {
const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug");
const existingMembership = data.taskManagerMemberships.find(
(membership) => membership.clientId === payload.clientId && membership.userId === payload.userId && membership.workspaceSlug === workspaceSlug
);
const nextMembership = {
id: existingMembership?.id ?? uniqueId(data.taskManagerMemberships, "tasker_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}`),
clientId: payload.clientId,
userId: payload.userId,
workspaceSlug,
workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? 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.taskManagerMemberships.push(nextMembership);
return nextMembership;
}
function resolveActor(data, identity) {
const user = data.users.find(
(item) =>

View File

@ -301,7 +301,7 @@ app.post("/api/internal/handoff/consume", (req, res) => {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? null,
avatarUrl: resolveUserAvatarPublicUrl(user),
subject: user.authentikUserId || handoff.user.sub,
authentikUserId: user.authentikUserId ?? null,
groups,
@ -557,6 +557,7 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
workspaceSlug,
email: user.email,
subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user),
role,
companyRole: membership?.role ?? null,
setLastWorkspace: req.body?.setLastWorkspace !== false,
@ -578,6 +579,81 @@ app.post("/api/admin/task-manager/workspace-memberships/ensure", requireLauncher
res.json({ ...result, taskManager });
}));
app.post("/api/admin/task-manager/workspace-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;
}
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = client.integrations?.taskManager?.workspaceSlug ?? null;
if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
return;
}
if (membership?.role === "client_owner") {
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", {
method: "POST",
body: {
workspaceSlug,
email: user.email,
subject: user.authentikUserId ?? undefined,
avatarUrl: resolveUserAvatarPublicUrl(user),
role: "admin",
companyRole: membership.role,
setLastWorkspace: false,
},
});
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
{
clientId: client.id,
userId: user.id,
workspaceSlug,
role: "admin",
taskManager,
},
req.nodedcSession.user
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...result, taskManager, protected: true });
return;
}
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/remove/", {
method: "POST",
body: {
workspaceSlug,
email: user.email,
subject: user.authentikUserId ?? undefined,
},
});
const result = await controlPlaneStore.removeTaskManagerWorkspaceMembership(
{
clientId: client.id,
userId: user.id,
workspaceSlug,
},
req.nodedcSession.user
);
publishControlPlaneEvent("admin.task-manager.workspace-membership.updated", [user.id]);
res.json({ ...result, taskManager });
}));
app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user);
res.status(201).json(result);
@ -1289,7 +1365,7 @@ function normalizeOptionalText(value) {
}
function normalizeTaskManagerRole(value) {
return value === "admin" || value === "member" ? value : null;
return value === "guest" || value === "admin" || value === "member" ? value : null;
}
function resolveTaskManagerRoleForMembership(role) {
@ -1598,6 +1674,11 @@ function resolvePublicUrl(value, baseUrl) {
}
}
function resolveUserAvatarPublicUrl(user) {
if (!user?.avatarUrl) return null;
return resolvePublicUrl(user.avatarUrl, config.appBaseUrl);
}
async function saveUploadedFile(payload) {
if (!isUploadPayload(payload)) {
throw new Error("Некорректный payload загрузки");

View File

@ -20,6 +20,7 @@ import {
fetchControlPlaneSnapshot,
reorderAdminServices,
retryAdminSync,
removeAdminTaskManagerWorkspaceMembership,
setAdminUserServiceAccess,
updateAdminClient,
updateAdminGroup,
@ -29,6 +30,7 @@ import {
updateAdminSettings,
updateAdminUserProfile,
type ControlPlaneMutationResult,
type TaskManagerWorkspaceMemberRole,
type TaskManagerWorkspaceSummary,
} from "../shared/api/adminApi";
import {
@ -467,7 +469,7 @@ export function LauncherApp() {
});
}
function handleEnsureTaskManagerWorkspaceMember(command: { clientId: string; userId: string; role?: "member" | "admin" }) {
function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole }) {
const membershipKey = `${command.clientId}:${command.userId}`;
if (pendingTaskManagerMemberships[membershipKey]) {
@ -475,12 +477,22 @@ export function LauncherApp() {
}
setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true }));
ensureAdminTaskManagerWorkspaceMembership({ ...command, setLastWorkspace: true })
const request =
command.role === "unset"
? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId })
: ensureAdminTaskManagerWorkspaceMembership({
clientId: command.clientId,
userId: command.userId,
role: command.role,
setLastWorkspace: true,
});
request
.then((result) => {
setData(syncLauncherServiceLinks(result.data));
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось назначить workspace Operational Core");
console.warn(error instanceof Error ? error.message : "Не удалось синхронизировать Tasker");
})
.finally(() => {
setPendingTaskManagerMemberships((current) => {
@ -737,7 +749,7 @@ export function LauncherApp() {
taskManagerWorkspacesError={taskManagerWorkspacesError}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
onEnsureTaskManagerWorkspaceMember={handleEnsureTaskManagerWorkspaceMember}
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
/>
) : null}
{profileSettingsOpen && activeProfileUser ? (

View File

@ -52,6 +52,8 @@ export interface TaskManagerWorkspaceMembershipResult {
isBanned: boolean;
}
export type TaskManagerWorkspaceMemberRole = "unset" | "guest" | "member" | "admin";
export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult {
taskManager: {
ok: boolean;
@ -59,6 +61,19 @@ export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPla
};
}
export interface TaskManagerWorkspaceMembershipRemoveMutationResult extends ControlPlaneMutationResult {
taskManager: {
ok: boolean;
removed: boolean;
workspace: TaskManagerWorkspaceSummary;
member: {
id: string;
email: string;
displayName: string;
};
};
}
export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> {
return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane");
}
@ -135,7 +150,7 @@ export async function deleteAdminMembership(membershipId: string): Promise<Contr
export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
clientId: string;
userId: string;
role?: "member" | "admin";
role?: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
setLastWorkspace?: boolean;
}): Promise<TaskManagerWorkspaceMembershipMutationResult> {
return requestJson<TaskManagerWorkspaceMembershipMutationResult>("/api/admin/task-manager/workspace-memberships/ensure", {
@ -144,6 +159,16 @@ export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
});
}
export async function removeAdminTaskManagerWorkspaceMembership(payload: {
clientId: string;
userId: string;
}): Promise<TaskManagerWorkspaceMembershipRemoveMutationResult> {
return requestJson<TaskManagerWorkspaceMembershipRemoveMutationResult>("/api/admin/task-manager/workspace-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",

View File

@ -60,9 +60,22 @@ export interface LauncherData {
invites: Invite[];
syncStatuses: SyncStatus[];
auditEvents: typeof mockAuditEvents;
taskManagerMemberships: TaskManagerMembershipAssignment[];
settings: LauncherSettings;
}
export interface TaskManagerMembershipAssignment {
id: string;
clientId: string;
userId: string;
workspaceSlug: string;
workspaceName?: string | null;
role: "guest" | "member" | "admin";
planeUserId?: string | null;
planeRole?: number | null;
updatedAt: string;
}
export interface LauncherSettings {
brand: {
logoLinkUrl: string;
@ -160,6 +173,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
taskManagerMemberships: Array.isArray(payload.taskManagerMemberships) ? payload.taskManagerMemberships : [],
settings: normalizeLauncherSettings(payload.settings),
};
}

View File

@ -10,6 +10,7 @@ export interface NodeDcSelectOption<T extends string> {
icon?: ReactNode;
tone?: string;
disabled?: boolean;
hidden?: boolean;
}
interface NodeDcSelectTriggerApi<T extends string> {
@ -59,14 +60,15 @@ export function NodeDcSelect<T extends string>({
const [query, setQuery] = useState("");
const selectedOption = options.find((option) => option.value === value) ?? options[0];
const normalizedQuery = query.trim().toLowerCase();
const menuOptions = useMemo(() => options.filter((option) => !option.hidden), [options]);
const visibleOptions = useMemo(() => {
if (!normalizedQuery) return options;
if (!normalizedQuery) return menuOptions;
return options.filter((option) => {
return menuOptions.filter((option) => {
const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [normalizedQuery, options]);
}, [menuOptions, normalizedQuery]);
return (
<NodeDcDropdown

View File

@ -2070,6 +2070,53 @@ code {
padding: 1rem;
}
.admin-data-table--users {
min-width: 82rem;
table-layout: fixed;
}
.admin-data-table--users th,
.admin-data-table--users td {
white-space: nowrap;
}
.admin-data-table--users th:nth-child(1),
.admin-data-table--users td:nth-child(1) {
width: 17rem;
}
.admin-data-table--users th:nth-child(2),
.admin-data-table--users td:nth-child(2) {
width: 8.2rem;
}
.admin-data-table--users th:nth-child(3),
.admin-data-table--users td:nth-child(3) {
width: 8.5rem;
}
.admin-data-table--users th:nth-child(4),
.admin-data-table--users td:nth-child(4) {
width: 13rem;
}
.admin-data-table--users th:nth-child(5),
.admin-data-table--users td:nth-child(5),
.admin-data-table--users th:nth-child(6),
.admin-data-table--users td:nth-child(6) {
width: 10rem;
}
.admin-data-table--users th:nth-child(7),
.admin-data-table--users td:nth-child(7) {
width: 9.6rem;
}
.admin-data-table--users th:nth-child(8),
.admin-data-table--users td:nth-child(8) {
width: 3.6rem;
}
.table-toolbar {
margin-bottom: 0.7rem;
}
@ -2194,29 +2241,9 @@ code {
cursor: pointer;
}
.admin-inline-action {
display: inline-flex;
min-height: 2.1rem;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--launcher-radius-circle);
background: rgba(181, 255, 90, 0.92);
color: #050805;
padding: 0 0.85rem;
font-size: 0.72rem;
font-weight: 850;
cursor: pointer;
}
.admin-inline-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.admin-field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) 2.85rem;
gap: 0.6rem;
align-items: center;
}

View File

@ -64,7 +64,7 @@ import {
type MeResponse,
type TaskManagerWorkspaceCreationPolicy,
} from "../../shared/api/mockApi";
import type { TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
import type { 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";
@ -87,6 +87,7 @@ type AdminSection =
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
type TaskManagerRoleSelectValue = TaskManagerWorkspaceMemberRole | "pending";
export interface SetUserServiceAccessCommand {
userId: string;
@ -107,7 +108,7 @@ export interface CreateUserCommand {
export interface EnsureTaskManagerWorkspaceMemberCommand {
clientId: string;
userId: string;
role?: "member" | "admin";
role: TaskManagerWorkspaceMemberRole;
}
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
@ -164,7 +165,7 @@ export function AdminOverlay({
taskManagerWorkspacesError,
pendingTaskManagerMemberships,
onRefreshTaskManagerWorkspaces,
onEnsureTaskManagerWorkspaceMember,
onSetTaskManagerWorkspaceMemberRole,
}: {
data: LauncherData;
me: MeResponse;
@ -196,7 +197,7 @@ export function AdminOverlay({
taskManagerWorkspacesError: string | null;
pendingTaskManagerMemberships: Record<string, boolean>;
onRefreshTaskManagerWorkspaces: () => void;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const isRoot = me.launcherRole === "root_admin";
const sections = isRoot ? rootSections : clientSections;
@ -329,7 +330,7 @@ export function AdminOverlay({
onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onEnsureTaskManagerWorkspaceMember={onEnsureTaskManagerWorkspaceMember}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
/>
) : null}
{activeSection === "groups" ? (
@ -570,7 +571,7 @@ function UsersSection({
onUpdateMembership,
onDeleteMembership,
pendingTaskManagerMemberships,
onEnsureTaskManagerWorkspaceMember,
onSetTaskManagerWorkspaceMemberRole,
}: {
data: LauncherData;
clientId: string;
@ -580,7 +581,7 @@ function UsersSection({
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void;
pendingTaskManagerMemberships: Record<string, boolean>;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const [newUserEmail, setNewUserEmail] = useState("");
@ -661,11 +662,11 @@ function UsersSection({
</div>
</GlassSurface>
<GlassSurface className="table-shell">
<GlassSurface className="table-shell table-shell--users">
<div className="table-toolbar">
<h3>Участники</h3>
</div>
<table className="admin-data-table">
<table className="admin-data-table admin-data-table--users">
<thead>
<tr>
<th>Пользователь</th>
@ -683,7 +684,15 @@ function UsersSection({
const taskManagerWorkspace = client.integrations?.taskManager?.workspaceSlug ?? null;
const pendingKey = `${client.id}:${user.id}`;
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
const taskManagerRole = membership.role === "client_owner" || membership.role === "client_admin" ? "admin" : "member";
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",
});
return (
<tr key={membership.id}>
@ -736,15 +745,25 @@ function UsersSection({
/>
</td>
<td>
<button
className="admin-inline-action"
type="button"
disabled={!taskManagerWorkspace || pendingTaskerAssignment || membership.status !== "active" || user.globalStatus !== "active"}
title={taskManagerWorkspace ? `Workspace: ${taskManagerWorkspace}` : "У клиента не выбран workspace Operational Core"}
onClick={() => onEnsureTaskManagerWorkspaceMember({ clientId: client.id, userId: user.id, role: taskManagerRole })}
>
{pendingTaskerAssignment ? "Назначаем" : taskManagerWorkspace ? "Назначить" : "Нет workspace"}
</button>
<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
@ -951,6 +970,22 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
];
function buildTaskManagerRoleOptions({
hasWorkspace,
disabled,
}: {
hasWorkspace: boolean;
disabled: boolean;
}): Array<NodeDcSelectOption<TaskManagerRoleSelectValue>> {
return [
{ value: "unset", label: hasWorkspace ? "—" : "Workspace не выбран" },
{ value: "guest", label: "Гость", disabled: !hasWorkspace || disabled },
{ value: "member", label: "Участник", disabled: !hasWorkspace || disabled },
{ value: "admin", label: "Админ", disabled: !hasWorkspace || disabled },
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
];
}
const taskManagerWorkspacePolicyOptions: Array<NodeDcSelectOption<TaskManagerWorkspaceCreationPolicy>> = [
{
value: "any_authorized_user",
@ -1651,9 +1686,15 @@ function ClientEditorModal({
minMenuWidth={280}
onChange={updateTaskManagerWorkspace}
/>
<button className="admin-inline-action" type="button" disabled={taskManagerWorkspacesLoading} onClick={onRefreshTaskManagerWorkspaces}>
{taskManagerWorkspacesLoading ? "Обновляем" : "Обновить"}
</button>
<IconButton
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
className="admin-circle-action admin-circle-action--solid"
type="button"
disabled={taskManagerWorkspacesLoading}
onClick={onRefreshTaskManagerWorkspaces}
>
<RefreshCw size={16} />
</IconButton>
</div>
<small>
{taskManagerWorkspacesError