ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Tasker workspace adapter в Launcher

This commit is contained in:
DCCONSTRUCTIONS 2026-05-06 10:36:20 +03:00
parent 897c7145f0
commit d4eba0ff3a
7 changed files with 474 additions and 63 deletions

View File

@ -103,6 +103,7 @@ export function createControlPlaneStore({ projectRoot }) {
demoEndsAt: nullableString(payload?.demoEndsAt), demoEndsAt: nullableString(payload?.demoEndsAt),
contactName: nullableString(payload?.contactName), contactName: nullableString(payload?.contactName),
contactEmail: nullableString(payload?.contactEmail), contactEmail: nullableString(payload?.contactEmail),
integrations: normalizeClientIntegrations(payload?.integrations),
notes: nullableString(payload?.notes), notes: nullableString(payload?.notes),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -138,6 +139,9 @@ export function createControlPlaneStore({ projectRoot }) {
client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null); client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null);
client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null); client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null);
client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null); client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null);
if ("integrations" in (payload ?? {})) {
client.integrations = normalizeClientIntegrations(payload.integrations, client.integrations);
}
client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null); client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null);
client.updatedAt = isoNow(); client.updatedAt = isoNow();
@ -154,6 +158,28 @@ export function createControlPlaneStore({ projectRoot }) {
return { client, data }; return { client, data };
} }
async function recordTaskManagerWorkspaceMembership(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 : {};
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"}`,
});
await writeData(data);
return { data };
}
async function deleteClient(clientId, identity) { async function deleteClient(clientId, identity) {
const data = readData(); const data = readData();
const actor = resolveActor(data, identity); const actor = resolveActor(data, identity);
@ -1028,6 +1054,7 @@ export function createControlPlaneStore({ projectRoot }) {
reorderServices, reorderServices,
retrySync, retrySync,
markUserAuthentikProvisioned, markUserAuthentikProvisioned,
recordTaskManagerWorkspaceMembership,
setUserServiceAccess, setUserServiceAccess,
updateClient, updateClient,
updateGroup, updateGroup,
@ -1052,6 +1079,10 @@ function normalizeData(payload) {
} }
data.settings = normalizeSettings(data.settings); data.settings = normalizeSettings(data.settings);
data.clients = data.clients.map((client) => ({
...client,
integrations: normalizeClientIntegrations(client.integrations),
}));
return data; return data;
} }
@ -1074,6 +1105,21 @@ function normalizeSettings(payload) {
}; };
} }
function normalizeClientIntegrations(payload, fallback = {}) {
const integrations = typeof payload === "object" && payload !== null ? payload : {};
const fallbackIntegrations = typeof fallback === "object" && fallback !== null ? fallback : {};
const taskManager = typeof integrations.taskManager === "object" && integrations.taskManager !== null ? integrations.taskManager : {};
const fallbackTaskManager =
typeof fallbackIntegrations.taskManager === "object" && fallbackIntegrations.taskManager !== null ? fallbackIntegrations.taskManager : {};
return {
taskManager: {
workspaceSlug: nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null),
workspaceName: nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? null),
},
};
}
function resolveActor(data, identity) { function resolveActor(data, identity) {
const user = data.users.find( const user = data.users.find(
(item) => (item) =>

View File

@ -520,6 +520,64 @@ app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
res.json({ clients: snapshot.data.clients }); res.json({ clients: snapshot.data.clients });
}); });
app.get("/api/admin/task-manager/workspaces", requireLauncherAdmin, asyncRoute(async (_req, res) => {
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspaces/");
res.json(taskManager);
}));
app.post("/api/admin/task-manager/workspace-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;
}
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug) ?? client.integrations?.taskManager?.workspaceSlug ?? null;
if (!workspaceSlug) {
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
return;
}
const role = normalizeTaskManagerRole(req.body?.role) ?? resolveTaskManagerRoleForMembership(membership?.role);
const taskManager = await requestTaskManagerInternalJson("/api/internal/nodedc/workspace-memberships/ensure/", {
method: "POST",
body: {
workspaceSlug,
email: user.email,
subject: user.authentikUserId ?? undefined,
role,
companyRole: membership?.role ?? null,
setLastWorkspace: req.body?.setLastWorkspace !== false,
},
});
const result = await controlPlaneStore.recordTaskManagerWorkspaceMembership(
{
clientId: client.id,
userId: user.id,
workspaceSlug,
role,
taskManager,
},
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) => { app.post("/api/admin/clients", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user); const result = await controlPlaneStore.createClient(req.body, req.nodedcSession.user);
res.status(201).json(result); res.status(201).json(result);
@ -1190,6 +1248,54 @@ function getTaskBaseUrl() {
return taskBaseUrl.replace(/\/$/, ""); return taskBaseUrl.replace(/\/$/, "");
} }
async function requestTaskManagerInternalJson(pathname, init = {}) {
if (!config.internalAccessToken) {
throw new Error("NODE.DC internal access token is not configured");
}
const targetUrl = new URL(pathname, `${getTaskBaseUrl()}/`);
const hasBody = typeof init.body === "object" && init.body !== null;
const response = await fetch(targetUrl, {
method: init.method ?? (hasBody ? "POST" : "GET"),
headers: {
Accept: "application/json",
Authorization: `Bearer ${config.internalAccessToken}`,
...(hasBody ? { "Content-Type": "application/json" } : {}),
...(init.headers ?? {}),
},
body: hasBody ? JSON.stringify(init.body) : undefined,
});
const text = await response.text();
const payload = text ? parseJsonResponse(text, targetUrl.toString()) : {};
if (!response.ok) {
const error = typeof payload?.error === "string" ? payload.error : `Task Manager internal API failed: ${response.status}`;
throw new Error(error);
}
return payload;
}
function parseJsonResponse(text, url) {
try {
return JSON.parse(text);
} catch {
throw new Error(`Task Manager internal API returned non-JSON response: ${url}`);
}
}
function normalizeOptionalText(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizeTaskManagerRole(value) {
return value === "admin" || value === "member" ? value : null;
}
function resolveTaskManagerRoleForMembership(role) {
return role === "client_owner" || role === "client_admin" ? "admin" : "member";
}
function createServiceHandoff(serviceSlug, user) { function createServiceHandoff(serviceSlug, user) {
pruneExpiredServiceHandoffs(); pruneExpiredServiceHandoffs();

View File

@ -15,6 +15,8 @@ import {
deleteAdminInvite, deleteAdminInvite,
deleteAdminMembership, deleteAdminMembership,
deleteAdminService, deleteAdminService,
ensureAdminTaskManagerWorkspaceMembership,
fetchAdminTaskManagerWorkspaces,
fetchControlPlaneSnapshot, fetchControlPlaneSnapshot,
reorderAdminServices, reorderAdminServices,
retryAdminSync, retryAdminSync,
@ -27,6 +29,7 @@ import {
updateAdminSettings, updateAdminSettings,
updateAdminUserProfile, updateAdminUserProfile,
type ControlPlaneMutationResult, type ControlPlaneMutationResult,
type TaskManagerWorkspaceSummary,
} from "../shared/api/adminApi"; } from "../shared/api/adminApi";
import { import {
buildLauncherServices, buildLauncherServices,
@ -81,6 +84,10 @@ export function LauncherApp() {
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null); const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({}); const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState<Record<string, boolean>>({});
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null)); const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
@ -322,6 +329,11 @@ export function LauncherApp() {
}; };
}, [authSession]); }, [authSession]);
useEffect(() => {
if (!adminOpen || !authSession?.authenticated || !canUseAdminApi(authSession)) return;
void refreshTaskManagerWorkspaces();
}, [adminOpen, authSession]);
useEffect(() => { useEffect(() => {
if (!authSession?.authenticated) return; if (!authSession?.authenticated) return;
@ -418,6 +430,20 @@ export function LauncherApp() {
}); });
} }
async function refreshTaskManagerWorkspaces() {
setTaskManagerWorkspacesLoading(true);
setTaskManagerWorkspacesError(null);
try {
const result = await fetchAdminTaskManagerWorkspaces();
setTaskManagerWorkspaces(result.workspaces ?? []);
} catch (error: unknown) {
setTaskManagerWorkspacesError(error instanceof Error ? error.message : "Не удалось загрузить workspace Operational Core");
} finally {
setTaskManagerWorkspacesLoading(false);
}
}
function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) { function handleSetUserServiceAccess({ userId, serviceId, value }: SetUserServiceAccessCommand) {
const assignmentKey = accessAssignmentKey(userId, serviceId); const assignmentKey = accessAssignmentKey(userId, serviceId);
@ -441,6 +467,29 @@ export function LauncherApp() {
}); });
} }
function handleEnsureTaskManagerWorkspaceMember(command: { clientId: string; userId: string; role?: "member" | "admin" }) {
const membershipKey = `${command.clientId}:${command.userId}`;
if (pendingTaskManagerMemberships[membershipKey]) {
return;
}
setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true }));
ensureAdminTaskManagerWorkspaceMembership({ ...command, setLastWorkspace: true })
.then((result) => {
setData(syncLauncherServiceLinks(result.data));
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось назначить workspace Operational Core");
})
.finally(() => {
setPendingTaskManagerMemberships((current) => {
const { [membershipKey]: _completed, ...rest } = current;
return rest;
});
});
}
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) { function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
applyControlPlaneMutation(createAdminInvite(invite)); applyControlPlaneMutation(createAdminInvite(invite));
} }
@ -683,6 +732,12 @@ export function LauncherApp() {
onCreateService={handleCreateService} onCreateService={handleCreateService}
onDeleteService={handleDeleteService} onDeleteService={handleDeleteService}
onUpdateSettings={handleUpdateSettings} onUpdateSettings={handleUpdateSettings}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
onEnsureTaskManagerWorkspaceMember={handleEnsureTaskManagerWorkspaceMember}
/> />
) : null} ) : null}
{profileSettingsOpen && activeProfileUser ? ( {profileSettingsOpen && activeProfileUser ? (

View File

@ -14,6 +14,12 @@ export interface Client {
demoEndsAt?: string | null; demoEndsAt?: string | null;
contactName?: string | null; contactName?: string | null;
contactEmail?: string | null; contactEmail?: string | null;
integrations?: {
taskManager?: {
workspaceSlug?: string | null;
workspaceName?: string | null;
};
};
notes?: string | null; notes?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@ -31,10 +31,42 @@ export interface ControlPlaneMutationResult {
} | null; } | null;
} }
export interface TaskManagerWorkspaceSummary {
id: string;
slug: string;
name: string;
ownerEmail: string | null;
memberCount: number;
}
export interface TaskManagerWorkspaceMembershipResult {
created: boolean;
workspace: TaskManagerWorkspaceSummary;
member: {
id: string;
email: string;
displayName: string;
};
role: number;
isActive: boolean;
isBanned: boolean;
}
export interface TaskManagerWorkspaceMembershipMutationResult extends ControlPlaneMutationResult {
taskManager: {
ok: boolean;
membership: TaskManagerWorkspaceMembershipResult;
};
}
export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> { export async function fetchControlPlaneSnapshot(): Promise<ControlPlaneSnapshot> {
return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane"); return requestJson<ControlPlaneSnapshot>("/api/admin/control-plane");
} }
export async function fetchAdminTaskManagerWorkspaces(): Promise<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }> {
return requestJson<{ ok: boolean; workspaces: TaskManagerWorkspaceSummary[] }>("/api/admin/task-manager/workspaces");
}
export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> { export async function createAdminClient(payload: Partial<Client>): Promise<ControlPlaneMutationResult> {
return requestJson<ControlPlaneMutationResult>("/api/admin/clients", { return requestJson<ControlPlaneMutationResult>("/api/admin/clients", {
method: "POST", method: "POST",
@ -100,6 +132,18 @@ export async function deleteAdminMembership(membershipId: string): Promise<Contr
return requestJson<ControlPlaneMutationResult>(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { method: "DELETE" }); return requestJson<ControlPlaneMutationResult>(`/api/admin/memberships/${encodeURIComponent(membershipId)}`, { method: "DELETE" });
} }
export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
clientId: string;
userId: string;
role?: "member" | "admin";
setLastWorkspace?: boolean;
}): Promise<TaskManagerWorkspaceMembershipMutationResult> {
return requestJson<TaskManagerWorkspaceMembershipMutationResult>("/api/admin/task-manager/workspace-memberships/ensure", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function createAdminGroup(payload: Pick<ClientGroup, "clientId"> & Partial<ClientGroup>): Promise<ControlPlaneMutationResult> { export async function createAdminGroup(payload: Pick<ClientGroup, "clientId"> & Partial<ClientGroup>): Promise<ControlPlaneMutationResult> {
return requestJson<ControlPlaneMutationResult>("/api/admin/groups", { return requestJson<ControlPlaneMutationResult>("/api/admin/groups", {
method: "POST", method: "POST",

View File

@ -2194,6 +2194,39 @@ code {
cursor: pointer; 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;
gap: 0.6rem;
align-items: center;
}
.service-content-field small {
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.35;
}
.service-status-dropdown { .service-status-dropdown {
width: 7.45rem; width: 7.45rem;
min-width: 7.45rem; min-width: 7.45rem;

View File

@ -64,6 +64,7 @@ import {
type MeResponse, type MeResponse,
type TaskManagerWorkspaceCreationPolicy, type TaskManagerWorkspaceCreationPolicy,
} from "../../shared/api/mockApi"; } from "../../shared/api/mockApi";
import type { TaskManagerWorkspaceSummary } from "../../shared/api/adminApi";
import { uploadStorageFile } from "../../shared/api/storageApi"; import { uploadStorageFile } from "../../shared/api/storageApi";
import { cn } from "../../shared/lib/cn"; import { cn } from "../../shared/lib/cn";
import { formatDate, formatDateTime } from "../../shared/lib/format"; import { formatDate, formatDateTime } from "../../shared/lib/format";
@ -103,6 +104,12 @@ export interface CreateUserCommand {
generatePassword: boolean; generatePassword: boolean;
} }
export interface EnsureTaskManagerWorkspaceMemberCommand {
clientId: string;
userId: string;
role?: "member" | "admin";
}
const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
{ id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> }, { id: "overview", label: "Обзор", icon: <LayoutDashboard size={16} /> },
{ id: "clients", label: "Клиенты", icon: <Building2 size={16} /> }, { id: "clients", label: "Клиенты", icon: <Building2 size={16} /> },
@ -152,6 +159,12 @@ export function AdminOverlay({
onCreateService, onCreateService,
onDeleteService, onDeleteService,
onUpdateSettings, onUpdateSettings,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
pendingTaskManagerMemberships,
onRefreshTaskManagerWorkspaces,
onEnsureTaskManagerWorkspaceMember,
}: { }: {
data: LauncherData; data: LauncherData;
me: MeResponse; me: MeResponse;
@ -178,6 +191,12 @@ export function AdminOverlay({
onCreateService: () => void; onCreateService: () => void;
onDeleteService: (serviceId: string) => void; onDeleteService: (serviceId: string) => void;
onUpdateSettings: (patch: Partial<LauncherSettings>) => void; onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
pendingTaskManagerMemberships: Record<string, boolean>;
onRefreshTaskManagerWorkspaces: () => void;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) { }) {
const isRoot = me.launcherRole === "root_admin"; const isRoot = me.launcherRole === "root_admin";
const sections = isRoot ? rootSections : clientSections; const sections = isRoot ? rootSections : clientSections;
@ -289,7 +308,16 @@ export function AdminOverlay({
<div className="admin-panel-content__body"> <div className="admin-panel-content__body">
{activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null} {activeSection === "overview" ? <OverviewSection data={data} clientId={scopedClientId} isRoot={isRoot} /> : null}
{activeSection === "clients" && isRoot ? ( {activeSection === "clients" && isRoot ? (
<ClientsSection data={data} onCreateClient={onCreateClient} onUpdateClient={onUpdateClient} onDeleteClient={onDeleteClient} /> <ClientsSection
data={data}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onCreateClient={onCreateClient}
onUpdateClient={onUpdateClient}
onDeleteClient={onDeleteClient}
/>
) : null} ) : null}
{activeSection === "users" ? ( {activeSection === "users" ? (
<UsersSection <UsersSection
@ -300,6 +328,8 @@ export function AdminOverlay({
onUpdateUser={onUpdateUser} onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership} onUpdateMembership={onUpdateMembership}
onDeleteMembership={onDeleteMembership} onDeleteMembership={onDeleteMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onEnsureTaskManagerWorkspaceMember={onEnsureTaskManagerWorkspaceMember}
/> />
) : null} ) : null}
{activeSection === "groups" ? ( {activeSection === "groups" ? (
@ -399,11 +429,19 @@ function OverviewSection({ data, clientId, isRoot }: { data: LauncherData; clien
function ClientsSection({ function ClientsSection({
data, data,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
onRefreshTaskManagerWorkspaces,
onCreateClient, onCreateClient,
onUpdateClient, onUpdateClient,
onDeleteClient, onDeleteClient,
}: { }: {
data: LauncherData; data: LauncherData;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
onRefreshTaskManagerWorkspaces: () => void;
onCreateClient: () => void; onCreateClient: () => void;
onUpdateClient: (clientId: string, patch: Partial<Client>) => void; onUpdateClient: (clientId: string, patch: Partial<Client>) => void;
onDeleteClient: (clientId: string) => void; onDeleteClient: (clientId: string) => void;
@ -503,6 +541,10 @@ function ClientsSection({
{editingClient ? ( {editingClient ? (
<ClientEditorModal <ClientEditorModal
client={editingClient} client={editingClient}
taskManagerWorkspaces={taskManagerWorkspaces}
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
taskManagerWorkspacesError={taskManagerWorkspacesError}
onRefreshTaskManagerWorkspaces={onRefreshTaskManagerWorkspaces}
onClose={() => setEditingClientId(null)} onClose={() => setEditingClientId(null)}
onSave={(patch) => { onSave={(patch) => {
onUpdateClient(editingClient.id, patch); onUpdateClient(editingClient.id, patch);
@ -527,6 +569,8 @@ function UsersSection({
onUpdateUser, onUpdateUser,
onUpdateMembership, onUpdateMembership,
onDeleteMembership, onDeleteMembership,
pendingTaskManagerMemberships,
onEnsureTaskManagerWorkspaceMember,
}: { }: {
data: LauncherData; data: LauncherData;
clientId: string; clientId: string;
@ -535,6 +579,8 @@ function UsersSection({
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void; onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void; onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void; onDeleteMembership: (membershipId: string) => void;
pendingTaskManagerMemberships: Record<string, boolean>;
onEnsureTaskManagerWorkspaceMember: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) { }) {
const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null); const [editingMembershipId, setEditingMembershipId] = useState<string | null>(null);
const [newUserEmail, setNewUserEmail] = useState(""); const [newUserEmail, setNewUserEmail] = useState("");
@ -628,11 +674,18 @@ function UsersSection({
<th>Группы</th> <th>Группы</th>
<th>Статус</th> <th>Статус</th>
<th>Доступ</th> <th>Доступ</th>
<th>Tasker</th>
<th aria-label="Редактирование" /> <th aria-label="Редактирование" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map(({ membership, user, client }) => ( {rows.map(({ membership, user, client }) => {
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";
return (
<tr key={membership.id}> <tr key={membership.id}>
<td className="services-admin-table__service"> <td className="services-admin-table__service">
<input <input
@ -682,6 +735,17 @@ function UsersSection({
onChange={(status) => onUpdateMembership(membership.id, { status })} onChange={(status) => onUpdateMembership(membership.id, { status })}
/> />
</td> </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>
</td>
<td className="services-admin-table__actions"> <td className="services-admin-table__actions">
<IconButton <IconButton
label={`Редактировать пользователя ${user.name}`} label={`Редактировать пользователя ${user.name}`}
@ -693,7 +757,8 @@ function UsersSection({
</IconButton> </IconButton>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</GlassSurface> </GlassSurface>
@ -1489,18 +1554,35 @@ function ServiceContentModal({
function ClientEditorModal({ function ClientEditorModal({
client, client,
taskManagerWorkspaces,
taskManagerWorkspacesLoading,
taskManagerWorkspacesError,
onRefreshTaskManagerWorkspaces,
onClose, onClose,
onSave, onSave,
onDelete, onDelete,
canDelete, canDelete,
}: { }: {
client: Client; client: Client;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
onRefreshTaskManagerWorkspaces: () => void;
onClose: () => void; onClose: () => void;
onSave: (patch: Partial<Client>) => void; onSave: (patch: Partial<Client>) => void;
onDelete: () => void; onDelete: () => void;
canDelete: boolean; canDelete: boolean;
}) { }) {
const [draft, setDraft] = useState<Client>(client); const [draft, setDraft] = useState<Client>(client);
const taskManagerWorkspaceOptions: Array<NodeDcSelectOption<string>> = [
{ value: "none", label: "Не привязан" },
...taskManagerWorkspaces.map((workspace) => ({
value: workspace.slug,
label: workspace.name,
description: `${workspace.slug} · ${workspace.memberCount} участников`,
})),
];
const selectedTaskManagerWorkspaceSlug = draft.integrations?.taskManager?.workspaceSlug ?? "none";
useEffect(() => setDraft(client), [client]); useEffect(() => setDraft(client), [client]);
@ -1508,6 +1590,22 @@ function ClientEditorModal({
setDraft((current) => ({ ...current, [key]: value })); setDraft((current) => ({ ...current, [key]: value }));
} }
function updateTaskManagerWorkspace(workspaceSlug: string) {
const selectedWorkspace = taskManagerWorkspaces.find((workspace) => workspace.slug === workspaceSlug);
setDraft((current) => ({
...current,
integrations: {
...current.integrations,
taskManager: {
...current.integrations?.taskManager,
workspaceSlug: workspaceSlug === "none" ? null : workspaceSlug,
workspaceName: selectedWorkspace?.name ?? null,
},
},
}));
}
return ( return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}> <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">
@ -1540,6 +1638,29 @@ function ClientEditorModal({
<span>Статус</span> <span>Статус</span>
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} /> <AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
</div> </div>
<div className="service-content-field service-content-field--wide">
<span>Operational Core workspace</span>
<div className="admin-field-row">
<NodeDcSelect
className="admin-modal-select-wrap"
triggerClassName="admin-modal-select-trigger"
value={selectedTaskManagerWorkspaceSlug}
options={taskManagerWorkspaceOptions}
label="Operational Core workspace"
searchable
minMenuWidth={280}
onChange={updateTaskManagerWorkspace}
/>
<button className="admin-inline-action" type="button" disabled={taskManagerWorkspacesLoading} onClick={onRefreshTaskManagerWorkspaces}>
{taskManagerWorkspacesLoading ? "Обновляем" : "Обновить"}
</button>
</div>
<small>
{taskManagerWorkspacesError
? taskManagerWorkspacesError
: "Эта привязка используется для назначения участников клиента в workspace Task Manager."}
</small>
</div>
<label className="service-content-field"> <label className="service-content-field">
<span>Контактное лицо</span> <span>Контактное лицо</span>
<input value={draft.contactName ?? ""} onChange={(event) => update("contactName", event.target.value || null)} /> <input value={draft.contactName ?? ""} onChange={(event) => update("contactName", event.target.value || null)} />