FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: МАТРИЦА ДОСТУПОВ OPERATIONAL CORE

This commit is contained in:
DCCONSTRUCTIONS 2026-05-08 08:59:52 +03:00
parent 784b3ca5c3
commit 652a6ef0c5
7 changed files with 588 additions and 91 deletions

View File

@ -1147,15 +1147,62 @@ function normalizeClientIntegrations(payload, fallback = {}) {
const taskManager = typeof integrations.taskManager === "object" && integrations.taskManager !== null ? integrations.taskManager : {};
const fallbackTaskManager =
typeof fallbackIntegrations.taskManager === "object" && fallbackIntegrations.taskManager !== null ? fallbackIntegrations.taskManager : {};
const workspaces = normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager);
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
return {
taskManager: {
workspaceSlug: nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null),
workspaceName: nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? null),
workspaceSlug: primaryWorkspace?.slug ?? null,
workspaceName: primaryWorkspace?.name ?? null,
workspaces,
},
};
}
function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) {
const sourceWorkspaces = Array.isArray(taskManager.workspaces)
? taskManager.workspaces
: Array.isArray(fallbackTaskManager.workspaces)
? fallbackTaskManager.workspaces
: [];
const legacySlug = nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null);
const legacyName = nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? null);
const bySlug = new Map();
for (const item of sourceWorkspaces) {
if (typeof item !== "object" || item === null) continue;
const slug = nullableString(item.slug);
if (!slug) continue;
bySlug.set(slug, {
slug,
name: nullableStringWithFallback(item.name, null),
isPrimary: item.isPrimary === true,
});
}
if (legacySlug && !bySlug.has(legacySlug)) {
bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true });
}
const workspaces = [...bySlug.values()];
if (!workspaces.length) return [];
if (!workspaces.some((workspace) => workspace.isPrimary)) {
workspaces[0].isPrimary = true;
}
let primarySeen = false;
return workspaces.map((workspace) => {
const isPrimary = workspace.isPrimary && !primarySeen;
if (isPrimary) primarySeen = true;
return {
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary,
};
});
}
function normalizeTaskManagerMembershipRole(value) {
return value === "guest" || value === "admin" || value === "member" ? value : "member";
}

View File

@ -610,7 +610,7 @@ app.post("/api/admin/task-manager/workspace-memberships/remove", requireLauncher
}
const membership = snapshot.data.memberships.find((candidate) => candidate.clientId === client.id && candidate.userId === user.id);
const workspaceSlug = client.integrations?.taskManager?.workspaceSlug ?? null;
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" });

View File

@ -472,8 +472,8 @@ export function LauncherApp() {
});
}
function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole }) {
const membershipKey = `${command.clientId}:${command.userId}`;
function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole; workspaceSlug?: string }) {
const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug ?? "primary"}`;
if (pendingTaskManagerMemberships[membershipKey]) {
return;
@ -482,10 +482,11 @@ export function LauncherApp() {
setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true }));
const request =
command.role === "unset"
? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId })
? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId, workspaceSlug: command.workspaceSlug })
: ensureAdminTaskManagerWorkspaceMembership({
clientId: command.clientId,
userId: command.userId,
workspaceSlug: command.workspaceSlug,
role: command.role,
setLastWorkspace: true,
});

View File

@ -1,6 +1,12 @@
export type ClientType = "company" | "person";
export type ClientStatus = "active" | "suspended" | "demo" | "expired";
export interface ClientTaskManagerWorkspaceBinding {
slug: string;
name?: string | null;
isPrimary?: boolean;
}
export interface Client {
id: string;
type: ClientType;
@ -18,6 +24,7 @@ export interface Client {
taskManager?: {
workspaceSlug?: string | null;
workspaceName?: string | null;
workspaces?: ClientTaskManagerWorkspaceBinding[];
};
};
notes?: string | null;

View File

@ -150,6 +150,7 @@ export async function deleteAdminMembership(membershipId: string): Promise<Contr
export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
clientId: string;
userId: string;
workspaceSlug?: string;
role?: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
setLastWorkspace?: boolean;
}): Promise<TaskManagerWorkspaceMembershipMutationResult> {
@ -162,6 +163,7 @@ export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
export async function removeAdminTaskManagerWorkspaceMembership(payload: {
clientId: string;
userId: string;
workspaceSlug?: string;
}): Promise<TaskManagerWorkspaceMembershipRemoveMutationResult> {
return requestJson<TaskManagerWorkspaceMembershipRemoveMutationResult>("/api/admin/task-manager/workspace-memberships/remove", {
method: "POST",

View File

@ -2837,6 +2837,12 @@ code {
gap: 0.6rem;
}
.service-content-modal__head-actions {
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.service-content-modal__head h3 {
margin: 0.1rem 0 0;
font-size: 1.05rem;
@ -3177,6 +3183,14 @@ code {
opacity: 0.72;
}
.access-cell--modal {
cursor: pointer;
}
.access-cell:disabled {
cursor: wait;
}
.access-cell:not(.access-cell--pending):hover,
.access-cell:not(.access-cell--pending)[aria-expanded="true"] {
filter: brightness(1.12);
@ -3199,6 +3213,67 @@ code {
background: rgba(255, 120, 120, 0.08);
}
.task-access-modal {
width: min(44rem, calc(100vw - 2rem));
}
.task-access-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.task-workspace-access-list {
display: grid;
gap: 0.7rem;
}
.task-workspace-access-card {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.055);
}
.task-workspace-access-card__head {
display: grid;
grid-template-columns: minmax(0, 1fr) 10.8rem;
gap: 0.75rem;
align-items: center;
}
.task-workspace-access-card__head strong,
.task-workspace-access-card__head small {
display: block;
min-width: 0;
}
.task-workspace-access-card__head small {
margin-top: 0.2rem;
color: var(--text-muted);
font-size: 0.75rem;
}
.task-project-access-note {
display: grid;
gap: 0.22rem;
padding: 0.7rem;
border-radius: 0.85rem;
background: rgba(0, 0, 0, 0.18);
}
.task-project-access-note strong {
color: var(--text-secondary);
font-size: 0.8rem;
}
.task-project-access-note span {
color: var(--text-muted);
font-size: 0.76rem;
line-height: 1.35;
}
.access-explanation {
display: grid;
align-content: start;
@ -3273,6 +3348,84 @@ code {
padding: 1rem;
}
.task-workspace-picker-card {
border-radius: var(--launcher-radius-control);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.02)),
rgba(0, 0, 0, 0.18);
padding: 0.42rem;
}
.task-workspace-picker {
display: grid;
max-height: 13.5rem;
gap: 0.45rem;
overflow: auto;
padding: 0;
border-radius: var(--launcher-radius-control);
background: transparent;
}
.task-workspace-picker__empty {
padding: 0.75rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.task-workspace-chip {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: center;
width: 100%;
min-height: 3rem;
border: 0;
border-radius: calc(var(--launcher-radius-control) - 0.2rem);
background: rgba(255, 255, 255, 0.055);
color: var(--text-secondary);
padding: 0.55rem 0.7rem;
text-align: left;
cursor: pointer;
}
.task-workspace-chip:hover,
.task-workspace-chip:focus-visible {
background: rgba(255, 255, 255, 0.09);
}
.task-workspace-chip--selected {
background: rgba(181, 255, 90, 0.14);
color: #defeb2;
}
.task-workspace-chip strong,
.task-workspace-chip small {
display: block;
min-width: 0;
}
.task-workspace-chip small {
margin-top: 0.16rem;
color: var(--text-muted);
font-size: 0.72rem;
}
.task-workspace-chip em {
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.08);
color: var(--text-secondary);
padding: 0.36rem 0.56rem;
font-size: 0.68rem;
font-style: normal;
font-weight: 820;
white-space: nowrap;
}
.task-workspace-chip--selected em {
background: rgba(181, 255, 90, 0.16);
color: #defeb2;
}
.invite-form__fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 12rem;

View File

@ -41,7 +41,7 @@ import {
X,
} from "lucide-react";
import type { ServiceAppRole } from "../../entities/access/types";
import type { Client, ClientStatus, ClientType } from "../../entities/client/types";
import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types";
import type { Invite, InviteStatus } from "../../entities/invite/types";
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
@ -89,7 +89,7 @@ type AdminSection =
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
type TaskManagerRoleSelectValue = TaskManagerWorkspaceMemberRole | "pending";
type OperationalCoreRoleSelectValue = AccessAssignmentValue | "pending";
export interface SetUserServiceAccessCommand {
userId: string;
@ -110,6 +110,7 @@ export interface CreateUserCommand {
export interface EnsureTaskManagerWorkspaceMemberCommand {
clientId: string;
userId: string;
workspaceSlug?: string;
role: TaskManagerWorkspaceMemberRole;
}
@ -936,27 +937,16 @@ const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error
const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ value: "unset", label: "—", description: "Не назначен" },
{ value: "viewer", label: "viewer", description: "Просмотр", tone: "green" },
{ value: "member", label: "member", description: "Участник", tone: "green" },
{ value: "admin", label: "admin", description: "Администратор", tone: "green" },
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" },
{ value: "deny", label: "Заблокирован", 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 },
const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
...accessAssignmentOptions,
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
];
}
];
function membershipRoleLabel(role: ClientMembershipRole): string {
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
@ -970,16 +960,76 @@ function mainStatusLabel(value: LauncherUserStatus): string {
return mainStatusOptions.find((option) => option.value === value)?.label ?? value;
}
function taskManagerRoleLabel(role: TaskManagerWorkspaceMemberRole | TaskManagerRoleSelectValue): string {
const labels: Record<TaskManagerRoleSelectValue, string> = {
unset: "—",
guest: "Гость",
member: "Участник",
admin: "Админ",
pending: "Сохраняем...",
};
function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorkspaceBinding[] {
const taskManager = client.integrations?.taskManager;
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
return labels[role];
for (const workspace of taskManager?.workspaces ?? []) {
if (!workspace.slug) continue;
bySlug.set(workspace.slug, {
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary: workspace.isPrimary === true,
});
}
if (taskManager?.workspaceSlug && !bySlug.has(taskManager.workspaceSlug)) {
bySlug.set(taskManager.workspaceSlug, {
slug: taskManager.workspaceSlug,
name: taskManager.workspaceName ?? null,
isPrimary: true,
});
}
const workspaces = [...bySlug.values()];
if (!workspaces.length) return [];
if (!workspaces.some((workspace) => workspace.isPrimary)) {
workspaces[0].isPrimary = true;
}
let primarySeen = false;
return workspaces.map((workspace) => {
const isPrimary = workspace.isPrimary === true && !primarySeen;
if (isPrimary) primarySeen = true;
return { ...workspace, isPrimary };
});
}
function getPrimaryTaskManagerWorkspace(client: Client): ClientTaskManagerWorkspaceBinding | null {
const workspaces = getClientTaskManagerWorkspaces(client);
return workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
}
function getTaskManagerMembershipRole(data: LauncherData, clientId: string, userId: string, workspaceSlug: string): TaskManagerWorkspaceMemberRole {
return data.taskManagerMemberships.find((membership) => membership.clientId === clientId && membership.userId === userId && membership.workspaceSlug === workspaceSlug)?.role ?? "unset";
}
function normalizeClientTaskManagerWorkspaceDraft(workspaces: ClientTaskManagerWorkspaceBinding[]): ClientTaskManagerWorkspaceBinding[] {
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
for (const workspace of workspaces) {
if (!workspace.slug) continue;
bySlug.set(workspace.slug, {
slug: workspace.slug,
name: workspace.name ?? null,
isPrimary: workspace.isPrimary === true,
});
}
const normalized = [...bySlug.values()];
if (!normalized.length) return [];
if (!normalized.some((workspace) => workspace.isPrimary)) {
normalized[0].isPrimary = true;
}
let primarySeen = false;
return normalized.map((workspace) => {
const isPrimary = workspace.isPrimary === true && !primarySeen;
if (isPrimary) primarySeen = true;
return { ...workspace, isPrimary };
});
}
function AdminStaticPill({ children }: { children: ReactNode }) {
@ -1609,15 +1659,9 @@ function ClientEditorModal({
canDelete: boolean;
}) {
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";
const taskManagerWorkspaceBindings = getClientTaskManagerWorkspaces(draft);
const selectedTaskManagerWorkspaceSlugs = new Set(taskManagerWorkspaceBindings.map((workspace) => workspace.slug));
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(draft);
useEffect(() => setDraft(client), [client]);
@ -1625,26 +1669,66 @@ function ClientEditorModal({
setDraft((current) => ({ ...current, [key]: value }));
}
function updateTaskManagerWorkspace(workspaceSlug: string) {
const selectedWorkspace = taskManagerWorkspaces.find((workspace) => workspace.slug === workspaceSlug);
function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) {
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
setDraft((current) => ({
...current,
integrations: {
...current.integrations,
taskManager: {
...current.integrations?.taskManager,
workspaceSlug: workspaceSlug === "none" ? null : workspaceSlug,
workspaceName: selectedWorkspace?.name ?? null,
workspaceSlug: primaryWorkspace?.slug ?? null,
workspaceName: primaryWorkspace?.name ?? null,
workspaces,
},
},
}));
}
function toggleTaskManagerWorkspace(workspace: TaskManagerWorkspaceSummary) {
const currentWorkspaces = getClientTaskManagerWorkspaces(draft);
const exists = currentWorkspaces.some((item) => item.slug === workspace.slug);
const nextWorkspaces = exists
? currentWorkspaces.filter((item) => item.slug !== workspace.slug)
: [
...currentWorkspaces,
{
slug: workspace.slug,
name: workspace.name,
isPrimary: currentWorkspaces.length === 0,
},
];
updateTaskManagerWorkspaces(normalizeClientTaskManagerWorkspaceDraft(nextWorkspaces));
}
function setPrimaryTaskManagerWorkspace(workspaceSlug: string) {
const nextWorkspaces = getClientTaskManagerWorkspaces(draft).map((workspace) => ({
...workspace,
isPrimary: workspace.slug === workspaceSlug,
}));
updateTaskManagerWorkspaces(normalizeClientTaskManagerWorkspaceDraft(nextWorkspaces));
}
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Редактор клиента ${client.name}`}>
<article className="service-content-modal admin-entity-modal">
<EntityModalHead eyebrow="Клиент" title={client.name} onClose={onClose} />
<EntityModalHead
eyebrow="Клиент"
title={client.name}
onClose={onClose}
actions={
<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 className="service-content-modal__grid">
<label className="service-content-field">
<span>Название</span>
@ -1674,32 +1758,58 @@ function ClientEditorModal({
<AdminStatusDropdown value={draft.status} options={clientStatusOptions} label="Статус клиента" onChange={(status) => update("status", status)} />
</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}
/>
<IconButton
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
className="admin-circle-action admin-circle-action--solid"
<span>Operational Core workspaces</span>
<div className="task-workspace-picker-card">
<div className="task-workspace-picker" role="list" aria-label="Operational Core workspaces клиента">
{taskManagerWorkspaces.length ? (
taskManagerWorkspaces.map((workspace) => {
const selected = selectedTaskManagerWorkspaceSlugs.has(workspace.slug);
const primary = primaryTaskManagerWorkspace?.slug === workspace.slug;
return (
<button
key={workspace.slug}
className={cn("task-workspace-chip", selected && "task-workspace-chip--selected")}
type="button"
disabled={taskManagerWorkspacesLoading}
onClick={onRefreshTaskManagerWorkspaces}
aria-pressed={selected}
onClick={() => toggleTaskManagerWorkspace(workspace)}
>
<RefreshCw size={16} />
</IconButton>
<span>
<strong>{workspace.name}</strong>
<small>
{workspace.slug} · {workspace.memberCount} участников
</small>
</span>
{selected ? (
<em
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
setPrimaryTaskManagerWorkspace(workspace.slug);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
setPrimaryTaskManagerWorkspace(workspace.slug);
}}
>
{primary ? "Основной" : "Сделать основным"}
</em>
) : null}
</button>
);
})
) : (
<span className="task-workspace-picker__empty">Workspace Operational Core не загружены</span>
)}
</div>
</div>
<small>
{taskManagerWorkspacesError
? taskManagerWorkspacesError
: "Эта привязка используется для назначения участников клиента в workspace Task Manager."}
: "Эти workspace доступны для детальных назначений пользователей в Operational Core."}
</small>
</div>
<label className="service-content-field">
@ -1945,17 +2055,30 @@ function GroupEditorModal({
);
}
function EntityModalHead({ eyebrow, title, onClose }: { eyebrow: string; title: string; onClose: () => void }) {
function EntityModalHead({
eyebrow,
title,
onClose,
actions,
}: {
eyebrow: string;
title: string;
onClose: () => void;
actions?: ReactNode;
}) {
return (
<div className="service-content-modal__head">
<div>
<p className="eyebrow">{eyebrow}</p>
<h3>{title}</h3>
</div>
<div className="service-content-modal__head-actions">
{actions}
<IconButton label="Закрыть редактор" className="admin-circle-action" type="button" onClick={onClose}>
<X size={15} strokeWidth={1.45} />
</IconButton>
</div>
</div>
);
}
@ -2119,7 +2242,9 @@ function AccessSection({
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const hasUsers = matrix.users.length > 0;
const taskManagerWorkspace = matrix.client.integrations?.taskManager?.workspaceSlug ?? null;
const taskManagerWorkspaces = getClientTaskManagerWorkspaces(matrix.client);
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(matrix.client);
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
if (!hasUsers) {
return (
@ -2149,7 +2274,7 @@ function AccessSection({
const selectedUser = selectedCell ? getUser(data, selectedCell.userId) : null;
const selectedService = selectedCell ? getService(data, selectedCell.serviceId) : null;
const accessGridTemplateColumns = `15rem repeat(${matrix.services.length + 2}, 9.7rem)`;
const accessGridTemplateColumns = `15rem repeat(2, 9.7rem) repeat(${matrix.services.length}, 11.25rem)`;
return (
<div className="access-layout">
@ -2180,7 +2305,7 @@ function AccessSection({
if (!membership) return null;
const protectedUser = user.id === "user_root";
const pendingKey = `${matrix.client.id}:${user.id}`;
const pendingKey = `${matrix.client.id}:${user.id}:${primaryTaskManagerWorkspace?.slug ?? "primary"}`;
const pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
const forcedTaskManagerAdmin = membership.role === "client_owner";
@ -2220,14 +2345,16 @@ function AccessSection({
onSetAccess={(value) => {
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
if (!isTaskManagerService || !taskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
onSetTaskManagerWorkspaceMemberRole({
clientId: matrix.client.id,
userId: user.id,
workspaceSlug: primaryTaskManagerWorkspace.slug,
role: accessAssignmentToTaskManagerRole(value),
});
}}
onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined}
/>
</div>
);
@ -2264,6 +2391,22 @@ function AccessSection({
</>
)}
</GlassSurface>
{detailsCell ? (
<OperationalCoreAccessModal
data={data}
client={matrix.client}
user={getUser(data, detailsCell.userId)}
service={getService(data, detailsCell.serviceId)}
cell={detailsCell}
workspaces={taskManagerWorkspaces}
pendingAccessAssignments={pendingAccessAssignments}
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
onClose={() => setDetailsCell(null)}
onSetUserServiceAccess={onSetUserServiceAccess}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
/>
) : null}
</div>
);
}
@ -2351,6 +2494,117 @@ function MainRoleControl({
);
}
function OperationalCoreAccessModal({
data,
client,
user,
service,
cell,
workspaces,
pendingAccessAssignments,
pendingTaskManagerMemberships,
onClose,
onSetUserServiceAccess,
onSetTaskManagerWorkspaceMemberRole,
}: {
data: LauncherData;
client: Client;
user: LauncherUser;
service: Service;
cell: AccessMatrixCell;
workspaces: ClientTaskManagerWorkspaceBinding[];
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
pendingTaskManagerMemberships: Record<string, boolean>;
onClose: () => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
}) {
const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id);
const protectedUser = user.id === "user_root" || membership?.role === "client_owner";
const basePendingValue = pendingAccessAssignments[accessCellKey(user.id, service.id)];
const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell);
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Operational Core доступы ${user.name}`}>
<article className="service-content-modal admin-entity-modal task-access-modal">
<EntityModalHead eyebrow="Доступы → Operational Core" title={user.name} onClose={onClose} />
<div className="task-access-summary">
<InfoLine label="Клиент" value={client.name} />
<InfoLine label="Сервис" value={service.title} />
<InfoLine label="Пользователь" value={user.email} />
</div>
<div className="task-workspace-access-list">
{workspaces.length ? (
workspaces.map((workspace) => {
const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug);
const pendingKey = `${client.id}:${user.id}:${workspace.slug}`;
const pending = Boolean(pendingTaskManagerMemberships[pendingKey]) || basePendingValue !== undefined;
const value: OperationalCoreRoleSelectValue = pending
? "pending"
: baseAssignmentValue === "deny"
? "deny"
: taskManagerRoleToAccessAssignment(role);
return (
<section key={workspace.slug} className="task-workspace-access-card">
<div className="task-workspace-access-card__head">
<div>
<strong>{workspace.name ?? workspace.slug}</strong>
<small>
{workspace.slug}
{workspace.isPrimary ? " · основной workspace" : ""}
</small>
</div>
{protectedUser ? (
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill>
) : (
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={value}
options={operationalCoreRoleOptions}
label={`Роль ${user.name} в ${workspace.name ?? workspace.slug}`}
minMenuWidth={180}
disabled={pending}
onChange={(nextValue) => {
if (nextValue === "pending") return;
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
onSetTaskManagerWorkspaceMemberRole({
clientId: client.id,
userId: user.id,
workspaceSlug: workspace.slug,
role: accessAssignmentToTaskManagerRole(nextValue),
});
}}
/>
)}
</div>
<div className="task-project-access-note">
<strong>Проекты</strong>
<span>Сейчас наследуют роль workspace. Точечные роли проектов подключаются следующим Project API-адаптером.</span>
</div>
</section>
);
})
) : (
<div className="access-empty-state">
<strong>Workspace не привязаны к клиенту</strong>
<span>Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения.</span>
</div>
)}
</div>
<div className="service-content-modal__foot">
<Button variant="secondary" surface="modal" type="button" onClick={onClose}>
Закрыть
</Button>
</div>
</article>
</div>
);
}
function AccessCellControl({
cell,
active,
@ -2358,6 +2612,7 @@ function AccessCellControl({
busy = false,
onSelectCell,
onSetAccess,
onOpenDetails,
}: {
cell: AccessMatrixCell;
active: boolean;
@ -2365,9 +2620,37 @@ function AccessCellControl({
busy?: boolean;
onSelectCell: (cell: AccessMatrixCell) => void;
onSetAccess: (value: AccessAssignmentValue) => void;
onOpenDetails?: () => void;
}) {
const isPending = pendingValue !== undefined || busy;
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
const cellClassName = cn(
"access-cell",
onOpenDetails && "access-cell--modal",
cell.effectiveAccess.allowed && "access-cell--allowed",
!cell.effectiveAccess.allowed && "access-cell--denied",
cell.effectiveAccess.source === "exception" && "access-cell--exception",
isPending && "access-cell--pending",
active && "access-cell--active"
);
if (onOpenDetails) {
return (
<button
className={cellClassName}
type="button"
aria-busy={isPending}
disabled={isPending}
onClick={() => {
onSelectCell(cell);
onOpenDetails();
}}
>
<strong>{isPending ? accessAssignmentLabel(assignmentValue) : accessCellTitle(cell)}</strong>
<span>{isPending ? "Сохраняем..." : sourceLabel(cell.effectiveAccess.source)}</span>
</button>
);
}
return (
<NodeDcSelect
@ -2381,14 +2664,7 @@ function AccessCellControl({
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className={cn(
"access-cell",
cell.effectiveAccess.allowed && "access-cell--allowed",
!cell.effectiveAccess.allowed && "access-cell--denied",
cell.effectiveAccess.source === "exception" && "access-cell--exception",
isPending && "access-cell--pending",
active && "access-cell--active"
)}
className={cellClassName}
type="button"
aria-expanded={open}
aria-busy={isPending}
@ -2867,8 +3143,8 @@ function sectionTitle(section: AdminSection): string {
}
function accessCellTitle(cell: AccessMatrixCell): string {
if (!cell.effectiveAccess.allowed) return cell.effectiveAccess.source === "exception" ? "Deny" : "—";
return cell.effectiveAccess.appRole ?? "allow";
if (!cell.effectiveAccess.allowed) return cell.effectiveAccess.source === "exception" ? accessAssignmentLabel("deny") : "—";
return accessAssignmentRoleLabel(cell.effectiveAccess.appRole);
}
function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue {
@ -2883,6 +3159,11 @@ function accessAssignmentLabel(value: AccessAssignmentValue): string {
return accessAssignmentOptions.find((option) => option.value === value)?.label ?? value;
}
function accessAssignmentRoleLabel(role?: ServiceAppRole | null): string {
if (!role) return "—";
return accessAssignmentLabel(role === "owner" ? "admin" : role);
}
function accessCellKey(userId: string, serviceId: string): string {
return `${userId}:${serviceId}`;
}
@ -2897,6 +3178,12 @@ function accessAssignmentToTaskManagerRole(value: AccessAssignmentValue): TaskMa
return "unset";
}
function taskManagerRoleToAccessAssignment(role: TaskManagerWorkspaceMemberRole): AccessAssignmentValue {
if (role === "admin" || role === "member") return role;
if (role === "guest") return "viewer";
return "unset";
}
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
if (!source) return "—";
const labels = {