FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: МАТРИЦА ДОСТУПОВ OPERATIONAL CORE
This commit is contained in:
parent
784b3ca5c3
commit
652a6ef0c5
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue