diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index e295b48..d406a54 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -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"; } diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 6cd985a..585319f 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -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" }); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 6cbcb50..bc79af7 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -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, }); diff --git a/src/entities/client/types.ts b/src/entities/client/types.ts index 1627850..405619b 100644 --- a/src/entities/client/types.ts +++ b/src/entities/client/types.ts @@ -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; diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index d14705d..58bb478 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -150,6 +150,7 @@ export async function deleteAdminMembership(membershipId: string): Promise; setLastWorkspace?: boolean; }): Promise { @@ -162,6 +163,7 @@ export async function ensureAdminTaskManagerWorkspaceMembership(payload: { export async function removeAdminTaskManagerWorkspaceMembership(payload: { clientId: string; userId: string; + workspaceSlug?: string; }): Promise { return requestJson("/api/admin/task-manager/workspace-memberships/remove", { method: "POST", diff --git a/src/styles/globals.css b/src/styles/globals.css index a448694..8f5e1fa 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 3701b77..b3757c3 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -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; 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> = [ { 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> { - 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 }, - ]; -} +const operationalCoreRoleOptions: Array> = [ + ...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 = { - unset: "—", - guest: "Гость", - member: "Участник", - admin: "Админ", - pending: "Сохраняем...", - }; +function getClientTaskManagerWorkspaces(client: Client): ClientTaskManagerWorkspaceBinding[] { + const taskManager = client.integrations?.taskManager; + const bySlug = new Map(); - 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(); + + 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); - const taskManagerWorkspaceOptions: Array> = [ - { 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 (
- + + + + } + />
- Operational Core workspace -
- - - - + Operational Core workspaces +
+
+ {taskManagerWorkspaces.length ? ( + taskManagerWorkspaces.map((workspace) => { + const selected = selectedTaskManagerWorkspaceSlugs.has(workspace.slug); + const primary = primaryTaskManagerWorkspace?.slug === workspace.slug; + + return ( + + ); + }) + ) : ( + Workspace Operational Core не загружены + )} +
{taskManagerWorkspacesError ? taskManagerWorkspacesError - : "Эта привязка используется для назначения участников клиента в workspace Task Manager."} + : "Эти workspace доступны для детальных назначений пользователей в Operational Core."}
); } @@ -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; + pendingTaskManagerMemberships: Record; + 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 ( +
+
+ +
+ + + +
+ +
+ {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 ( +
+
+
+ {workspace.name ?? workspace.slug} + + {workspace.slug} + {workspace.isPrimary ? " · основной workspace" : ""} + +
+ {protectedUser ? ( + {accessAssignmentLabel("admin")} + ) : ( + { + 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), + }); + }} + /> + )} +
+
+ Проекты + Сейчас наследуют роль workspace. Точечные роли проектов подключаются следующим Project API-адаптером. +
+
+ ); + }) + ) : ( +
+ Workspace не привязаны к клиенту + Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения. +
+ )} +
+ +
+ +
+
+
+ ); +} + 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 ( + + ); + } return ( (