diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 6807eae..cfffe80 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -11,6 +11,7 @@ const collectionKeys = [ "services", "grants", "exceptions", + "serviceModuleEntitlements", "invites", "accessRequests", "revokedAccounts", @@ -31,6 +32,7 @@ const grantStatuses = new Set(["active", "disabled"]); const exceptionTypes = new Set(["deny", "allow"]); const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]); const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]); +const serviceModuleIds = new Set(["codex_agents"]); const accessRequestStatuses = new Set(["new", "approved", "rejected"]); const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); const taskManagerInviteRoles = new Set(["guest", "member", "admin"]); @@ -345,6 +347,7 @@ export function createControlPlaneStore({ projectRoot }) { !(grant.targetType === "client" && grant.targetId === clientId) && !(grant.targetType === "group" && deletedGroupIds.has(grant.targetId)) ); + data.serviceModuleEntitlements = data.serviceModuleEntitlements.filter((entitlement) => entitlement.clientId !== clientId); data.invites = data.invites.filter((invite) => invite.clientId !== clientId); data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== clientId); @@ -599,6 +602,7 @@ export function createControlPlaneStore({ projectRoot }) { })); data.grants = data.grants.filter((grant) => !(grant.targetType === "user" && grant.targetId === user.id)); data.exceptions = data.exceptions.filter((exception) => exception.userId !== user.id); + data.serviceModuleEntitlements = data.serviceModuleEntitlements.filter((entitlement) => entitlement.userId !== user.id); data.invites = data.invites.filter( (invite) => invite.email.toLowerCase() !== email && invite.invitedByUserId !== user.id ); @@ -1551,6 +1555,72 @@ export function createControlPlaneStore({ projectRoot }) { return { data }; } + async function setServiceModuleEntitlement(payload, identity) { + const data = readData(); + const actor = resolveActor(data, identity); + const now = isoNow(); + const clientId = requireString(payload?.clientId, "clientId"); + const userId = requireString(payload?.userId, "userId"); + const serviceId = requireString(payload?.serviceId, "serviceId"); + const moduleId = pickEnum(payload?.moduleId, serviceModuleIds, "codex_agents"); + const enabled = payload?.enabled === true; + const client = findById(data.clients, clientId, "client"); + const user = findById(data.users, userId, "user"); + const service = findById(data.services, serviceId, "service"); + + const existingEntitlement = data.serviceModuleEntitlements.find( + (entitlement) => + entitlement.clientId === client.id && + entitlement.userId === user.id && + entitlement.serviceId === service.id && + entitlement.moduleId === moduleId + ); + + let entitlement = null; + + if (enabled) { + entitlement = + existingEntitlement ?? + { + id: uniqueId(data.serviceModuleEntitlements, "svc_module", `${client.id}-${service.slug}-${user.email}-${moduleId}`), + clientId: client.id, + userId: user.id, + serviceId: service.id, + moduleId, + createdByUserId: actor.id, + createdAt: now, + }; + entitlement.enabled = true; + entitlement.updatedAt = now; + + if (!existingEntitlement) { + data.serviceModuleEntitlements.push(entitlement); + } + } else { + data.serviceModuleEntitlements = data.serviceModuleEntitlements.filter( + (candidate) => + !( + candidate.clientId === client.id && + candidate.userId === user.id && + candidate.serviceId === service.id && + candidate.moduleId === moduleId + ) + ); + } + + addAuditEvent(data, actor, { + action: enabled ? "Включён модуль сервиса" : "Отключён модуль сервиса", + objectType: "service-module-entitlement", + objectName: `${service.slug}:${moduleId}:${user.email}`, + clientId: client.id, + result: "success", + details: `Module: ${moduleId}; enabled: ${enabled}`, + }); + + await writeData(data); + return { entitlement, data }; + } + async function updateService(serviceId, payload, identity) { return enqueueMutation(async () => { const data = readData(); @@ -1831,6 +1901,7 @@ export function createControlPlaneStore({ projectRoot }) { recordTaskManagerWorkspaceMembership, removeTaskManagerProjectMembership, removeTaskManagerWorkspaceMembership, + setServiceModuleEntitlement, setUserServiceAccess, updateAccessRequest, updateClient, @@ -1872,10 +1943,33 @@ function normalizeData(payload) { })); data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean); data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean); + data.serviceModuleEntitlements = data.serviceModuleEntitlements.map(normalizeServiceModuleEntitlement).filter(Boolean); data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean); return data; } +function normalizeServiceModuleEntitlement(payload) { + if (typeof payload !== "object" || payload === null) return null; + const clientId = nullableString(payload.clientId); + const serviceId = nullableString(payload.serviceId); + const userId = nullableString(payload.userId); + const moduleId = pickEnum(payload.moduleId, serviceModuleIds, null); + if (!clientId || !serviceId || !userId || !moduleId || payload.enabled === false) return null; + const now = isoNow(); + + return { + id: optionalString(payload.id, `svc_module_${slugify(`${clientId}-${serviceId}-${userId}-${moduleId}`)}`), + clientId, + serviceId, + userId, + moduleId, + enabled: true, + createdByUserId: nullableStringWithFallback(payload.createdByUserId, null), + createdAt: optionalString(payload.createdAt, now), + updatedAt: optionalString(payload.updatedAt, now), + }; +} + function normalizeRevokedAccount(payload) { if (typeof payload !== "object" || payload === null) return null; const email = normalizeEmail(payload.email); diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 1dbf7bf..6e9b173 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -1481,6 +1481,24 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); +app.post("/api/admin/access/service-modules", requireLauncherAdmin, asyncRoute(async (req, res) => { + if (!assertAdminCanManageClient(req, res, req.body?.clientId) || !assertAdminCanManageUser(req, res, req.body?.userId)) { + return; + } + + const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user); + const service = snapshot.data.services.find((candidate) => candidate.id === req.body?.serviceId); + + if (!service) { + res.status(404).json({ ok: false, error: "service_not_found" }); + return; + } + + const result = await controlPlaneStore.setServiceModuleEntitlement(req.body, req.nodedcSession.user); + publishControlPlaneEvent("admin.access.service-module.updated", [req.body?.userId]); + res.json(scopeAdminMutationResult(req, result)); +})); + app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => { const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user); publishControlPlaneEvent("admin.sync.retry"); @@ -3139,6 +3157,9 @@ function scopeControlPlaneData(data, scope) { return false; }), exceptions: data.exceptions.filter((exception) => userIds.has(exception.userId)), + serviceModuleEntitlements: data.serviceModuleEntitlements.filter( + (entitlement) => clientIds.has(entitlement.clientId) && userIds.has(entitlement.userId) + ), taskManagerMemberships: data.taskManagerMemberships.filter( (membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId) ), @@ -3168,6 +3189,7 @@ function scopeRuntimeControlPlaneData(data, userId) { taskerInviteRequests: [], grants: [], exceptions: [], + serviceModuleEntitlements: [], syncStatuses: [], auditEvents: [], taskManagerMemberships: [], @@ -3199,6 +3221,7 @@ function scopeRuntimeControlPlaneData(data, userId) { return false; }), exceptions: data.exceptions.filter((exception) => exception.userId === user.id), + serviceModuleEntitlements: data.serviceModuleEntitlements.filter((entitlement) => entitlement.userId === user.id), syncStatuses: [], auditEvents: [], taskManagerMemberships: data.taskManagerMemberships.filter( diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 84aeb82..b74f3e3 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -28,6 +28,7 @@ import { rejectAdminTaskerInviteRequest, removeAdminTaskManagerProjectMembership, removeAdminTaskManagerWorkspaceMembership, + setAdminServiceModuleEntitlement, setAdminUserServiceAccess, updateAdminClient, updateAdminAccessRequest, @@ -68,6 +69,7 @@ import { type AccessAssignmentValue, type CreateUserCommand, type EnsureTaskManagerProjectMemberCommand, + type SetServiceModuleEntitlementCommand, type SetUserServiceAccessCommand, } from "../widgets/admin-overlay/AdminOverlay"; import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel"; @@ -101,6 +103,7 @@ export function LauncherApp() { const [authApps, setAuthApps] = useState(null); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [pendingAccessAssignments, setPendingAccessAssignments] = useState>({}); + const [pendingServiceModuleEntitlements, setPendingServiceModuleEntitlements] = useState>({}); const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState>({}); const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState>({}); const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState([]); @@ -552,6 +555,29 @@ export function LauncherApp() { }); } + function handleSetServiceModuleEntitlement(command: SetServiceModuleEntitlementCommand) { + const entitlementKey = `${command.clientId}:${command.userId}:${command.serviceId}:${command.moduleId}`; + + if (pendingServiceModuleEntitlements[entitlementKey]) { + return; + } + + setPendingServiceModuleEntitlements((current) => ({ ...current, [entitlementKey]: true })); + setAdminServiceModuleEntitlement(command) + .then((result) => { + setData(syncLauncherServiceLinks(result.data)); + }) + .catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : "Не удалось обновить модуль сервиса"); + }) + .finally(() => { + setPendingServiceModuleEntitlements((current) => { + const { [entitlementKey]: _completed, ...rest } = current; + return rest; + }); + }); + } + function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole; workspaceSlug?: string }) { const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug ?? "primary"}`; @@ -922,6 +948,7 @@ export function LauncherApp() { onUpdateMembership={handleUpdateMembership} onDeleteMembership={handleDeleteMembership} pendingAccessAssignments={pendingAccessAssignments} + pendingServiceModuleEntitlements={pendingServiceModuleEntitlements} onCreateGroup={handleCreateGroup} onUpdateGroup={handleUpdateGroup} onDeleteGroup={handleDeleteGroup} @@ -938,6 +965,7 @@ export function LauncherApp() { onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()} onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole} onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole} + onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement} /> ) : null} {profileSettingsOpen && activeProfileUser ? ( diff --git a/src/entities/access/types.ts b/src/entities/access/types.ts index 391fd96..b9869a1 100644 --- a/src/entities/access/types.ts +++ b/src/entities/access/types.ts @@ -36,3 +36,17 @@ export interface EffectiveAccessResult { source?: ServiceGrantTargetType | "exception"; sourceId?: string; } + +export type ServiceModuleId = "codex_agents"; + +export interface ServiceModuleEntitlement { + id: string; + clientId: string; + serviceId: string; + userId: string; + moduleId: ServiceModuleId; + enabled: boolean; + createdByUserId?: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 26ec739..72d0a74 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -1,5 +1,5 @@ import type { AccessRequest } from "../../entities/access-request/types"; -import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types"; +import type { ServiceAccessException, ServiceAppRole, ServiceGrant, ServiceModuleEntitlement, ServiceModuleId } from "../../entities/access/types"; import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types"; import type { Invite } from "../../entities/invite/types"; import type { Service } from "../../entities/service/types"; @@ -404,6 +404,19 @@ export async function setAdminUserServiceAccess(payload: { }); } +export async function setAdminServiceModuleEntitlement(payload: { + clientId: string; + userId: string; + serviceId: string; + moduleId: ServiceModuleId; + enabled: boolean; +}): Promise { + return requestJson("/api/admin/access/service-modules", { + method: "POST", + body: JSON.stringify(payload), + }); +} + export async function upsertAdminGrant(payload: Partial): Promise { return requestJson("/api/admin/access/grants", { method: "POST", diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index e62d90b..4b19375 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -1,6 +1,6 @@ import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess"; import type { AccessRequest } from "../../entities/access-request/types"; -import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types"; +import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant, ServiceModuleEntitlement } from "../../entities/access/types"; import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types"; import type { Invite } from "../../entities/invite/types"; import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants"; @@ -62,6 +62,7 @@ export interface LauncherData { services: Service[]; grants: ServiceGrant[]; exceptions: ServiceAccessException[]; + serviceModuleEntitlements: ServiceModuleEntitlement[]; invites: Invite[]; accessRequests: AccessRequest[]; revokedAccounts: RevokedAccount[]; @@ -214,6 +215,7 @@ export function normalizeLauncherData(data: Partial | null | undef services: Array.isArray(payload.services) ? payload.services : mockServices, grants: Array.isArray(payload.grants) ? payload.grants : mockGrants, exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions, + serviceModuleEntitlements: Array.isArray(payload.serviceModuleEntitlements) ? payload.serviceModuleEntitlements : [], invites: Array.isArray(payload.invites) ? payload.invites : mockInvites, accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests, revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [], diff --git a/src/styles/globals.css b/src/styles/globals.css index 4ab76dc..f61cae2 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -3841,6 +3841,120 @@ code { font-size: 0.72rem; } +.task-module-access-card { + gap: 0.65rem; +} + +.task-module-access-card__head { + display: grid; + gap: 0.2rem; +} + +.task-module-access-card__head strong { + color: var(--text-secondary); + font-size: 0.86rem; +} + +.task-module-access-card__head small { + color: var(--text-muted); + font-size: 0.74rem; + line-height: 1.35; +} + +.task-module-access-list { + display: grid; + gap: 0.55rem; +} + +.task-module-access-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: center; + width: 100%; + min-height: 4rem; + border: 0; + border-radius: 0.95rem; + background: rgba(255, 255, 255, 0.045); + color: var(--text-secondary); + padding: 0.72rem; + text-align: left; + cursor: pointer; + transition: + background 160ms ease, + opacity 160ms ease, + transform 160ms ease; +} + +.task-module-access-row:hover:not(:disabled), +.task-module-access-row:focus-visible:not(:disabled) { + background: rgba(255, 255, 255, 0.075); + transform: translateY(-1px); +} + +.task-module-access-row--enabled { + background: rgba(181, 255, 90, 0.1); +} + +.task-module-access-row--pending { + cursor: progress; + opacity: 0.58; +} + +.task-module-access-row__meta { + display: grid; + min-width: 0; + gap: 0.18rem; +} + +.task-module-access-row__meta strong, +.task-module-access-row__meta small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.task-module-access-row__meta strong { + color: var(--text-primary); + font-size: 0.84rem; +} + +.task-module-access-row__meta small { + color: var(--text-muted); + font-size: 0.73rem; + line-height: 1.35; +} + +.task-module-access-row__state { + display: inline-flex; + align-items: center; + gap: 0.48rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 760; + white-space: nowrap; +} + +.task-module-checker { + display: grid; + width: 1.1rem; + height: 1.1rem; + place-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); +} + +.task-module-access-row--enabled .task-module-checker { + background: rgb(var(--nodedc-accent-rgb)); +} + +.task-module-checker span { + width: 0.38rem; + height: 0.38rem; + border-radius: 999px; + background: rgb(var(--nodedc-on-accent-rgb)); +} + .access-explanation { display: grid; align-content: start; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 4278754..079b1ff 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 { AccessRequest, AccessRequestStatus } from "../../entities/access-request/types"; -import type { ServiceAppRole } from "../../entities/access/types"; +import type { ServiceAppRole, ServiceModuleId } from "../../entities/access/types"; import type { Client, ClientStatus, ClientTaskManagerWorkspaceBinding, ClientType } from "../../entities/client/types"; import type { Invite, InviteStatus } from "../../entities/invite/types"; import { PUBLIC_POOL_CLIENT_ID, PUBLIC_POOL_CONTEXT_DESCRIPTION, PUBLIC_POOL_CONTEXT_LABEL, isPublicPoolClientId } from "../../entities/public-pool/constants"; @@ -102,6 +102,14 @@ export interface SetUserServiceAccessCommand { value: AccessAssignmentValue; } +export interface SetServiceModuleEntitlementCommand { + clientId: string; + userId: string; + serviceId: string; + moduleId: ServiceModuleId; + enabled: boolean; +} + export interface CreateUserCommand { clientId: string; email: string; @@ -180,6 +188,7 @@ export function AdminOverlay({ onUpdateMembership, onDeleteMembership, pendingAccessAssignments, + pendingServiceModuleEntitlements, onCreateGroup, onUpdateGroup, onDeleteGroup, @@ -196,6 +205,7 @@ export function AdminOverlay({ onRefreshTaskManagerWorkspaces, onSetTaskManagerWorkspaceMemberRole, onSetTaskManagerProjectMemberRole, + onSetServiceModuleEntitlement, }: { data: LauncherData; me: MeResponse; @@ -227,6 +237,7 @@ export function AdminOverlay({ onUpdateMembership: (membershipId: string, patch: Partial) => void; onDeleteMembership: (membershipId: string) => void; pendingAccessAssignments: Record; + pendingServiceModuleEntitlements: Record; onCreateGroup: (clientId: string) => void; onUpdateGroup: (groupId: string, patch: Partial) => void; onDeleteGroup: (groupId: string) => void; @@ -243,6 +254,7 @@ export function AdminOverlay({ onRefreshTaskManagerWorkspaces: () => void; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; + onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void; }) { const isRoot = me.launcherRole === "root_admin"; const isPlatformMode = isRoot && mode === "platform"; @@ -510,6 +522,7 @@ export function AdminOverlay({ onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })} onSetUserServiceAccess={onSetUserServiceAccess} pendingAccessAssignments={pendingAccessAssignments} + pendingServiceModuleEntitlements={pendingServiceModuleEntitlements} onUpdateUser={onUpdateUser} onUpdateMembership={onUpdateMembership} pendingTaskManagerMemberships={pendingTaskManagerMemberships} @@ -517,6 +530,7 @@ export function AdminOverlay({ taskManagerWorkspaceCatalog={taskManagerWorkspaces} onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole} onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole} + onSetServiceModuleEntitlement={onSetServiceModuleEntitlement} /> ) : null} {activeSection === "invites" ? ( @@ -1367,6 +1381,22 @@ const taskManagerProjectRoleOptions: Array = [ + { + id: "codex_agents", + title: "Codex Agent API", + description: "Разрешает пользователю подключать локального Codex-агента к своим разрешённым workspace/project в Operational Core.", + enabledLabel: "Модуль включён", + disabledLabel: "Модуль выключен", + }, +]; + function membershipRoleLabel(role: ClientMembershipRole): string { return membershipRoleOptions.find((option) => option.value === role)?.label ?? role; } @@ -2874,6 +2904,7 @@ function AccessSection({ onSelectCell, onSetUserServiceAccess, pendingAccessAssignments, + pendingServiceModuleEntitlements, onUpdateUser, onUpdateMembership, pendingTaskManagerMemberships, @@ -2881,6 +2912,7 @@ function AccessSection({ taskManagerWorkspaceCatalog, onSetTaskManagerWorkspaceMemberRole, onSetTaskManagerProjectMemberRole, + onSetServiceModuleEntitlement, }: { data: LauncherData; matrix: ReturnType; @@ -2888,6 +2920,7 @@ function AccessSection({ onSelectCell: (cell: AccessMatrixCell) => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; pendingAccessAssignments: Record; + pendingServiceModuleEntitlements: Record; onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; pendingTaskManagerMemberships: Record; @@ -2895,6 +2928,7 @@ function AccessSection({ taskManagerWorkspaceCatalog: TaskManagerWorkspaceSummary[]; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; + onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void; }) { const hasUsers = matrix.users.length > 0; const isPublicPoolContext = isPublicPoolClientId(matrix.client.id); @@ -3010,7 +3044,7 @@ function AccessSection({ role: accessAssignmentToTaskManagerRole(nextValue), }); }} - onOpenDetails={isTaskManagerService && !usePublicTaskerAccess ? () => setDetailsCell(cell) : undefined} + onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined} /> ); @@ -3029,15 +3063,18 @@ function AccessSection({ user={getUser(data, detailsCell.userId)} service={detailsService} cell={detailsCell} - workspaces={clientTaskManagerWorkspaces} + workspaces={isPublicPoolContext ? [] : clientTaskManagerWorkspaces} workspaceCatalog={taskManagerWorkspaceCatalog} + publicSelfService={isPublicPoolContext} pendingAccessAssignments={pendingAccessAssignments} + pendingServiceModuleEntitlements={pendingServiceModuleEntitlements} pendingTaskManagerMemberships={pendingTaskManagerMemberships} pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships} onClose={() => setDetailsCell(null)} onSetUserServiceAccess={onSetUserServiceAccess} onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole} onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole} + onSetServiceModuleEntitlement={onSetServiceModuleEntitlement} /> ) : null} @@ -3051,8 +3088,10 @@ function PublicAccessUsersPanel({ onSelectCell, onSetUserServiceAccess, pendingAccessAssignments, + pendingServiceModuleEntitlements, onUpdateUser, onUpdateMembership, + onSetServiceModuleEntitlement, }: { data: LauncherData; matrix: ReturnType; @@ -3060,10 +3099,14 @@ function PublicAccessUsersPanel({ onSelectCell: (cell: AccessMatrixCell) => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; pendingAccessAssignments: Record; + pendingServiceModuleEntitlements: Record; onUpdateUser: (userId: string, patch: Partial) => void; onUpdateMembership: (membershipId: string, patch: Partial) => void; + onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void; }) { const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null; + const [detailsCell, setDetailsCell] = useState(null); + const detailsService = detailsCell ? getService(data, detailsCell.serviceId) : null; return ( @@ -3168,13 +3211,8 @@ function PublicAccessUsersPanel({ pendingValue={pendingAccessAssignments[accessCellKey(user.id, operationalCoreCell.serviceId)]} publicSelfService onSelectCell={onSelectCell} - onSetAccess={(value) => - onSetUserServiceAccess({ - userId: user.id, - serviceId: operationalCoreCell.serviceId, - value, - }) - } + onSetAccess={() => undefined} + onOpenDetails={() => setDetailsCell(operationalCoreCell)} /> ) : ( @@ -3185,6 +3223,27 @@ function PublicAccessUsersPanel({ })} + {detailsCell && detailsService ? ( + setDetailsCell(null)} + onSetUserServiceAccess={onSetUserServiceAccess} + onSetTaskManagerWorkspaceMemberRole={() => undefined} + onSetTaskManagerProjectMemberRole={() => undefined} + onSetServiceModuleEntitlement={onSetServiceModuleEntitlement} + /> + ) : null} ); } @@ -3280,13 +3339,16 @@ function OperationalCoreAccessModal({ cell, workspaces, workspaceCatalog, + publicSelfService = false, pendingAccessAssignments, pendingTaskManagerMemberships, pendingTaskManagerProjectMemberships, + pendingServiceModuleEntitlements, onClose, onSetUserServiceAccess, onSetTaskManagerWorkspaceMemberRole, onSetTaskManagerProjectMemberRole, + onSetServiceModuleEntitlement, }: { data: LauncherData; client: Client; @@ -3295,18 +3357,24 @@ function OperationalCoreAccessModal({ cell: AccessMatrixCell; workspaces: ClientTaskManagerWorkspaceBinding[]; workspaceCatalog: TaskManagerWorkspaceSummary[]; + publicSelfService?: boolean; pendingAccessAssignments: Record; pendingTaskManagerMemberships: Record; pendingTaskManagerProjectMemberships: Record; + pendingServiceModuleEntitlements: Record; onClose: () => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; + onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => 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); + const baseSelectOptions = publicSelfService ? publicOperationalCoreAccessOptions : accessAssignmentOptions; + const baseSelectValue = publicSelfService ? publicOperationalCoreSelectValue(baseAssignmentValue) : baseAssignmentValue; + const basePending = basePendingValue !== undefined; return (
@@ -3319,7 +3387,82 @@ function OperationalCoreAccessModal({
- {workspaces.length ? ( +
+
+
+ Базовый доступ + + {publicSelfService + ? "Открытый контур: workspace member, service admin или блокировка instance." + : "Глобальная роль пользователя в Operational Core для выбранного клиента."} + +
+ {protectedUser ? ( + {accessAssignmentLabel("admin")} + ) : ( + { + onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue }); + }} + /> + )} +
+
+ +
+
+
+ Дополнительные модули ops-слоя + Модули работают только внутри выданного доступа к Operational Core и не расширяют workspace/project права сами по себе. +
+
+
+ {operationalCoreModules.map((module) => { + const enabled = hasServiceModuleEntitlement(data, client.id, user.id, service.id, module.id); + const pendingKey = serviceModuleEntitlementKey(client.id, user.id, service.id, module.id); + const pending = Boolean(pendingServiceModuleEntitlements[pendingKey]); + + return ( + + ); + })} +
+
+ + {!publicSelfService ? ( workspaces.map((workspace) => { const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug); const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog); @@ -3431,12 +3574,14 @@ function OperationalCoreAccessModal({ ); }) - ) : ( + ) : null} + + {!publicSelfService && !workspaces.length ? (
Workspace не привязаны к клиенту Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения.
- )} + ) : null}
@@ -4653,6 +4798,21 @@ function accessCellKey(userId: string, serviceId: string): string { return `${userId}:${serviceId}`; } +function serviceModuleEntitlementKey(clientId: string, userId: string, serviceId: string, moduleId: ServiceModuleId): string { + return `${clientId}:${userId}:${serviceId}:${moduleId}`; +} + +function hasServiceModuleEntitlement(data: LauncherData, clientId: string, userId: string, serviceId: string, moduleId: ServiceModuleId): boolean { + return data.serviceModuleEntitlements.some( + (entitlement) => + entitlement.clientId === clientId && + entitlement.userId === userId && + entitlement.serviceId === serviceId && + entitlement.moduleId === moduleId && + entitlement.enabled + ); +} + function isOperationalCoreService(service: Service): boolean { return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager"; }