ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Tasker workspace adapter в Launcher
This commit is contained in:
parent
897c7145f0
commit
d4eba0ff3a
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue