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 taskManager = typeof integrations.taskManager === "object" && integrations.taskManager !== null ? integrations.taskManager : {};
|
||||||
const fallbackTaskManager =
|
const fallbackTaskManager =
|
||||||
typeof fallbackIntegrations.taskManager === "object" && fallbackIntegrations.taskManager !== null ? fallbackIntegrations.taskManager : {};
|
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 {
|
return {
|
||||||
taskManager: {
|
taskManager: {
|
||||||
workspaceSlug: nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null),
|
workspaceSlug: primaryWorkspace?.slug ?? null,
|
||||||
workspaceName: nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? 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) {
|
function normalizeTaskManagerMembershipRole(value) {
|
||||||
return value === "guest" || value === "admin" || value === "member" ? value : "member";
|
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 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) {
|
if (!workspaceSlug) {
|
||||||
res.status(400).json({ ok: false, error: "task_manager_workspace_not_configured" });
|
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 }) {
|
function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole; workspaceSlug?: string }) {
|
||||||
const membershipKey = `${command.clientId}:${command.userId}`;
|
const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug ?? "primary"}`;
|
||||||
|
|
||||||
if (pendingTaskManagerMemberships[membershipKey]) {
|
if (pendingTaskManagerMemberships[membershipKey]) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -482,10 +482,11 @@ export function LauncherApp() {
|
||||||
setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true }));
|
setPendingTaskManagerMemberships((current) => ({ ...current, [membershipKey]: true }));
|
||||||
const request =
|
const request =
|
||||||
command.role === "unset"
|
command.role === "unset"
|
||||||
? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId })
|
? removeAdminTaskManagerWorkspaceMembership({ clientId: command.clientId, userId: command.userId, workspaceSlug: command.workspaceSlug })
|
||||||
: ensureAdminTaskManagerWorkspaceMembership({
|
: ensureAdminTaskManagerWorkspaceMembership({
|
||||||
clientId: command.clientId,
|
clientId: command.clientId,
|
||||||
userId: command.userId,
|
userId: command.userId,
|
||||||
|
workspaceSlug: command.workspaceSlug,
|
||||||
role: command.role,
|
role: command.role,
|
||||||
setLastWorkspace: true,
|
setLastWorkspace: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
export type ClientType = "company" | "person";
|
export type ClientType = "company" | "person";
|
||||||
export type ClientStatus = "active" | "suspended" | "demo" | "expired";
|
export type ClientStatus = "active" | "suspended" | "demo" | "expired";
|
||||||
|
|
||||||
|
export interface ClientTaskManagerWorkspaceBinding {
|
||||||
|
slug: string;
|
||||||
|
name?: string | null;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
id: string;
|
id: string;
|
||||||
type: ClientType;
|
type: ClientType;
|
||||||
|
|
@ -18,6 +24,7 @@ export interface Client {
|
||||||
taskManager?: {
|
taskManager?: {
|
||||||
workspaceSlug?: string | null;
|
workspaceSlug?: string | null;
|
||||||
workspaceName?: string | null;
|
workspaceName?: string | null;
|
||||||
|
workspaces?: ClientTaskManagerWorkspaceBinding[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ export async function deleteAdminMembership(membershipId: string): Promise<Contr
|
||||||
export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
|
export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
workspaceSlug?: string;
|
||||||
role?: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
|
role?: Exclude<TaskManagerWorkspaceMemberRole, "unset">;
|
||||||
setLastWorkspace?: boolean;
|
setLastWorkspace?: boolean;
|
||||||
}): Promise<TaskManagerWorkspaceMembershipMutationResult> {
|
}): Promise<TaskManagerWorkspaceMembershipMutationResult> {
|
||||||
|
|
@ -162,6 +163,7 @@ export async function ensureAdminTaskManagerWorkspaceMembership(payload: {
|
||||||
export async function removeAdminTaskManagerWorkspaceMembership(payload: {
|
export async function removeAdminTaskManagerWorkspaceMembership(payload: {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
workspaceSlug?: string;
|
||||||
}): Promise<TaskManagerWorkspaceMembershipRemoveMutationResult> {
|
}): Promise<TaskManagerWorkspaceMembershipRemoveMutationResult> {
|
||||||
return requestJson<TaskManagerWorkspaceMembershipRemoveMutationResult>("/api/admin/task-manager/workspace-memberships/remove", {
|
return requestJson<TaskManagerWorkspaceMembershipRemoveMutationResult>("/api/admin/task-manager/workspace-memberships/remove", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -2837,6 +2837,12 @@ code {
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service-content-modal__head-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.service-content-modal__head h3 {
|
.service-content-modal__head h3 {
|
||||||
margin: 0.1rem 0 0;
|
margin: 0.1rem 0 0;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|
@ -3177,6 +3183,14 @@ code {
|
||||||
opacity: 0.72;
|
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):hover,
|
||||||
.access-cell:not(.access-cell--pending)[aria-expanded="true"] {
|
.access-cell:not(.access-cell--pending)[aria-expanded="true"] {
|
||||||
filter: brightness(1.12);
|
filter: brightness(1.12);
|
||||||
|
|
@ -3199,6 +3213,67 @@ code {
|
||||||
background: rgba(255, 120, 120, 0.08);
|
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 {
|
.access-explanation {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
|
@ -3273,6 +3348,84 @@ code {
|
||||||
padding: 1rem;
|
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 {
|
.invite-form__fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 12rem;
|
grid-template-columns: minmax(0, 1fr) 12rem;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ServiceAppRole } from "../../entities/access/types";
|
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 type { Invite, InviteStatus } from "../../entities/invite/types";
|
||||||
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
|
import { createServiceLaunchLinkPatch, getServiceLaunchLink } from "../../entities/service/links";
|
||||||
import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
|
import type { MediaKind, Service, ServiceMediaSource, ServiceStatus } from "../../entities/service/types";
|
||||||
|
|
@ -89,7 +89,7 @@ type AdminSection =
|
||||||
|
|
||||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||||||
type TaskManagerRoleSelectValue = TaskManagerWorkspaceMemberRole | "pending";
|
type OperationalCoreRoleSelectValue = AccessAssignmentValue | "pending";
|
||||||
|
|
||||||
export interface SetUserServiceAccessCommand {
|
export interface SetUserServiceAccessCommand {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -110,6 +110,7 @@ export interface CreateUserCommand {
|
||||||
export interface EnsureTaskManagerWorkspaceMemberCommand {
|
export interface EnsureTaskManagerWorkspaceMemberCommand {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
workspaceSlug?: string;
|
||||||
role: TaskManagerWorkspaceMemberRole;
|
role: TaskManagerWorkspaceMemberRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -936,27 +937,16 @@ const auditResultOptions: Array<AdminStatusOption<"success" | "warning" | "error
|
||||||
|
|
||||||
const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
|
const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
|
||||||
{ value: "unset", label: "—", description: "Не назначен" },
|
{ value: "unset", label: "—", description: "Не назначен" },
|
||||||
{ value: "viewer", label: "viewer", description: "Просмотр", tone: "green" },
|
{ value: "viewer", label: "Гость", description: "Просмотр", tone: "green" },
|
||||||
{ value: "member", label: "member", description: "Участник", tone: "green" },
|
{ value: "member", label: "Участник", description: "Рабочий доступ", tone: "green" },
|
||||||
{ value: "admin", label: "admin", description: "Администратор", tone: "green" },
|
{ value: "admin", label: "Админ", description: "Администрирование", tone: "green" },
|
||||||
{ value: "deny", label: "Deny", description: "Исключение", tone: "red" },
|
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildTaskManagerRoleOptions({
|
const operationalCoreRoleOptions: Array<NodeDcSelectOption<OperationalCoreRoleSelectValue>> = [
|
||||||
hasWorkspace,
|
...accessAssignmentOptions,
|
||||||
disabled,
|
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
||||||
}: {
|
];
|
||||||
hasWorkspace: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
}): Array<NodeDcSelectOption<TaskManagerRoleSelectValue>> {
|
|
||||||
return [
|
|
||||||
{ value: "unset", label: hasWorkspace ? "—" : "Workspace не выбран" },
|
|
||||||
{ value: "guest", label: "Гость", disabled: !hasWorkspace || disabled },
|
|
||||||
{ value: "member", label: "Участник", disabled: !hasWorkspace || disabled },
|
|
||||||
{ value: "admin", label: "Админ", disabled: !hasWorkspace || disabled },
|
|
||||||
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function membershipRoleLabel(role: ClientMembershipRole): string {
|
function membershipRoleLabel(role: ClientMembershipRole): string {
|
||||||
return membershipRoleOptions.find((option) => option.value === role)?.label ?? role;
|
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;
|
return mainStatusOptions.find((option) => option.value === value)?.label ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskManagerRoleLabel(role: TaskManagerWorkspaceMemberRole | TaskManagerRoleSelectValue): string {
|
function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorkspaceBinding[] {
|
||||||
const labels: Record<TaskManagerRoleSelectValue, string> = {
|
const taskManager = client.integrations?.taskManager;
|
||||||
unset: "—",
|
const bySlug = new Map<string, ClientTaskManagerWorkspaceBinding>();
|
||||||
guest: "Гость",
|
|
||||||
member: "Участник",
|
|
||||||
admin: "Админ",
|
|
||||||
pending: "Сохраняем...",
|
|
||||||
};
|
|
||||||
|
|
||||||
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 }) {
|
function AdminStaticPill({ children }: { children: ReactNode }) {
|
||||||
|
|
@ -1609,15 +1659,9 @@ function ClientEditorModal({
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<Client>(client);
|
const [draft, setDraft] = useState<Client>(client);
|
||||||
const taskManagerWorkspaceOptions: Array<NodeDcSelectOption<string>> = [
|
const taskManagerWorkspaceBindings = getClientTaskManagerWorkspaces(draft);
|
||||||
{ value: "none", label: "Не привязан" },
|
const selectedTaskManagerWorkspaceSlugs = new Set(taskManagerWorkspaceBindings.map((workspace) => workspace.slug));
|
||||||
...taskManagerWorkspaces.map((workspace) => ({
|
const primaryTaskManagerWorkspace = getPrimaryTaskManagerWorkspace(draft);
|
||||||
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]);
|
||||||
|
|
||||||
|
|
@ -1625,26 +1669,66 @@ function ClientEditorModal({
|
||||||
setDraft((current) => ({ ...current, [key]: value }));
|
setDraft((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTaskManagerWorkspace(workspaceSlug: string) {
|
function updateTaskManagerWorkspaces(workspaces: ClientTaskManagerWorkspaceBinding[]) {
|
||||||
const selectedWorkspace = taskManagerWorkspaces.find((workspace) => workspace.slug === workspaceSlug);
|
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
||||||
|
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
integrations: {
|
integrations: {
|
||||||
...current.integrations,
|
...current.integrations,
|
||||||
taskManager: {
|
taskManager: {
|
||||||
...current.integrations?.taskManager,
|
...current.integrations?.taskManager,
|
||||||
workspaceSlug: workspaceSlug === "none" ? null : workspaceSlug,
|
workspaceSlug: primaryWorkspace?.slug ?? null,
|
||||||
workspaceName: selectedWorkspace?.name ?? 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 (
|
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">
|
||||||
<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">
|
<div className="service-content-modal__grid">
|
||||||
<label className="service-content-field">
|
<label className="service-content-field">
|
||||||
<span>Название</span>
|
<span>Название</span>
|
||||||
|
|
@ -1674,32 +1758,58 @@ function ClientEditorModal({
|
||||||
<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">
|
<div className="service-content-field service-content-field--wide">
|
||||||
<span>Operational Core workspace</span>
|
<span>Operational Core workspaces</span>
|
||||||
<div className="admin-field-row">
|
<div className="task-workspace-picker-card">
|
||||||
<NodeDcSelect
|
<div className="task-workspace-picker" role="list" aria-label="Operational Core workspaces клиента">
|
||||||
className="admin-modal-select-wrap"
|
{taskManagerWorkspaces.length ? (
|
||||||
triggerClassName="admin-modal-select-trigger"
|
taskManagerWorkspaces.map((workspace) => {
|
||||||
value={selectedTaskManagerWorkspaceSlug}
|
const selected = selectedTaskManagerWorkspaceSlugs.has(workspace.slug);
|
||||||
options={taskManagerWorkspaceOptions}
|
const primary = primaryTaskManagerWorkspace?.slug === workspace.slug;
|
||||||
label="Operational Core workspace"
|
|
||||||
searchable
|
return (
|
||||||
minMenuWidth={280}
|
<button
|
||||||
onChange={updateTaskManagerWorkspace}
|
key={workspace.slug}
|
||||||
/>
|
className={cn("task-workspace-chip", selected && "task-workspace-chip--selected")}
|
||||||
<IconButton
|
type="button"
|
||||||
label={taskManagerWorkspacesLoading ? "Обновляем workspace Operational Core" : "Обновить workspace Operational Core"}
|
aria-pressed={selected}
|
||||||
className="admin-circle-action admin-circle-action--solid"
|
onClick={() => toggleTaskManagerWorkspace(workspace)}
|
||||||
type="button"
|
>
|
||||||
disabled={taskManagerWorkspacesLoading}
|
<span>
|
||||||
onClick={onRefreshTaskManagerWorkspaces}
|
<strong>{workspace.name}</strong>
|
||||||
>
|
<small>
|
||||||
<RefreshCw size={16} />
|
{workspace.slug} · {workspace.memberCount} участников
|
||||||
</IconButton>
|
</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>
|
</div>
|
||||||
<small>
|
<small>
|
||||||
{taskManagerWorkspacesError
|
{taskManagerWorkspacesError
|
||||||
? taskManagerWorkspacesError
|
? taskManagerWorkspacesError
|
||||||
: "Эта привязка используется для назначения участников клиента в workspace Task Manager."}
|
: "Эти workspace доступны для детальных назначений пользователей в Operational Core."}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<label className="service-content-field">
|
<label className="service-content-field">
|
||||||
|
|
@ -1945,16 +2055,29 @@ 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 (
|
return (
|
||||||
<div className="service-content-modal__head">
|
<div className="service-content-modal__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{eyebrow}</p>
|
<p className="eyebrow">{eyebrow}</p>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<IconButton label="Закрыть редактор" className="admin-circle-action" type="button" onClick={onClose}>
|
<div className="service-content-modal__head-actions">
|
||||||
<X size={15} strokeWidth={1.45} />
|
{actions}
|
||||||
</IconButton>
|
<IconButton label="Закрыть редактор" className="admin-circle-action" type="button" onClick={onClose}>
|
||||||
|
<X size={15} strokeWidth={1.45} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2119,7 +2242,9 @@ function AccessSection({
|
||||||
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
|
||||||
}) {
|
}) {
|
||||||
const hasUsers = matrix.users.length > 0;
|
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) {
|
if (!hasUsers) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -2149,7 +2274,7 @@ function AccessSection({
|
||||||
|
|
||||||
const selectedUser = selectedCell ? getUser(data, selectedCell.userId) : null;
|
const selectedUser = selectedCell ? getUser(data, selectedCell.userId) : null;
|
||||||
const selectedService = selectedCell ? getService(data, selectedCell.serviceId) : 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 (
|
return (
|
||||||
<div className="access-layout">
|
<div className="access-layout">
|
||||||
|
|
@ -2180,7 +2305,7 @@ function AccessSection({
|
||||||
if (!membership) return null;
|
if (!membership) return null;
|
||||||
|
|
||||||
const protectedUser = user.id === "user_root";
|
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 pendingTaskerAssignment = Boolean(pendingTaskManagerMemberships[pendingKey]);
|
||||||
const forcedTaskManagerAdmin = membership.role === "client_owner";
|
const forcedTaskManagerAdmin = membership.role === "client_owner";
|
||||||
|
|
||||||
|
|
@ -2220,14 +2345,16 @@ function AccessSection({
|
||||||
onSetAccess={(value) => {
|
onSetAccess={(value) => {
|
||||||
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
|
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value });
|
||||||
|
|
||||||
if (!isTaskManagerService || !taskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
|
if (!isTaskManagerService || !primaryTaskManagerWorkspace || protectedUser || forcedTaskManagerAdmin) return;
|
||||||
|
|
||||||
onSetTaskManagerWorkspaceMemberRole({
|
onSetTaskManagerWorkspaceMemberRole({
|
||||||
clientId: matrix.client.id,
|
clientId: matrix.client.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
workspaceSlug: primaryTaskManagerWorkspace.slug,
|
||||||
role: accessAssignmentToTaskManagerRole(value),
|
role: accessAssignmentToTaskManagerRole(value),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -2264,6 +2391,22 @@ function AccessSection({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</GlassSurface>
|
</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>
|
</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({
|
function AccessCellControl({
|
||||||
cell,
|
cell,
|
||||||
active,
|
active,
|
||||||
|
|
@ -2358,6 +2612,7 @@ function AccessCellControl({
|
||||||
busy = false,
|
busy = false,
|
||||||
onSelectCell,
|
onSelectCell,
|
||||||
onSetAccess,
|
onSetAccess,
|
||||||
|
onOpenDetails,
|
||||||
}: {
|
}: {
|
||||||
cell: AccessMatrixCell;
|
cell: AccessMatrixCell;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
|
@ -2365,9 +2620,37 @@ function AccessCellControl({
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onSelectCell: (cell: AccessMatrixCell) => void;
|
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||||
onSetAccess: (value: AccessAssignmentValue) => void;
|
onSetAccess: (value: AccessAssignmentValue) => void;
|
||||||
|
onOpenDetails?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isPending = pendingValue !== undefined || busy;
|
const isPending = pendingValue !== undefined || busy;
|
||||||
const assignmentValue = pendingValue ?? accessAssignmentValue(cell);
|
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 (
|
return (
|
||||||
<NodeDcSelect
|
<NodeDcSelect
|
||||||
|
|
@ -2381,14 +2664,7 @@ function AccessCellControl({
|
||||||
trigger={({ open, toggle, setTriggerRef }) => (
|
trigger={({ open, toggle, setTriggerRef }) => (
|
||||||
<button
|
<button
|
||||||
ref={setTriggerRef}
|
ref={setTriggerRef}
|
||||||
className={cn(
|
className={cellClassName}
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-busy={isPending}
|
aria-busy={isPending}
|
||||||
|
|
@ -2867,8 +3143,8 @@ function sectionTitle(section: AdminSection): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function accessCellTitle(cell: AccessMatrixCell): string {
|
function accessCellTitle(cell: AccessMatrixCell): string {
|
||||||
if (!cell.effectiveAccess.allowed) return cell.effectiveAccess.source === "exception" ? "Deny" : "—";
|
if (!cell.effectiveAccess.allowed) return cell.effectiveAccess.source === "exception" ? accessAssignmentLabel("deny") : "—";
|
||||||
return cell.effectiveAccess.appRole ?? "allow";
|
return accessAssignmentRoleLabel(cell.effectiveAccess.appRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue {
|
function accessAssignmentValue(cell: AccessMatrixCell): AccessAssignmentValue {
|
||||||
|
|
@ -2883,6 +3159,11 @@ function accessAssignmentLabel(value: AccessAssignmentValue): string {
|
||||||
return accessAssignmentOptions.find((option) => option.value === value)?.label ?? value;
|
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 {
|
function accessCellKey(userId: string, serviceId: string): string {
|
||||||
return `${userId}:${serviceId}`;
|
return `${userId}:${serviceId}`;
|
||||||
}
|
}
|
||||||
|
|
@ -2897,6 +3178,12 @@ function accessAssignmentToTaskManagerRole(value: AccessAssignmentValue): TaskMa
|
||||||
return "unset";
|
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 {
|
function sourceLabel(source?: AccessMatrixCell["effectiveAccess"]["source"]): string {
|
||||||
if (!source) return "—";
|
if (!source) return "—";
|
||||||
const labels = {
|
const labels = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue