FEAT - LAUNCHER: модульный доступ Codex Agent API

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 20:32:26 +03:00
parent 5e18a7f3c2
commit 34917e007a
8 changed files with 463 additions and 15 deletions

View File

@ -11,6 +11,7 @@ const collectionKeys = [
"services", "services",
"grants", "grants",
"exceptions", "exceptions",
"serviceModuleEntitlements",
"invites", "invites",
"accessRequests", "accessRequests",
"revokedAccounts", "revokedAccounts",
@ -31,6 +32,7 @@ const grantStatuses = new Set(["active", "disabled"]);
const exceptionTypes = new Set(["deny", "allow"]); const exceptionTypes = new Set(["deny", "allow"]);
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]); const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]); const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]);
const serviceModuleIds = new Set(["codex_agents"]);
const accessRequestStatuses = new Set(["new", "approved", "rejected"]); const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]); const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
@ -345,6 +347,7 @@ export function createControlPlaneStore({ projectRoot }) {
!(grant.targetType === "client" && grant.targetId === clientId) && !(grant.targetType === "client" && grant.targetId === clientId) &&
!(grant.targetType === "group" && deletedGroupIds.has(grant.targetId)) !(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.invites = data.invites.filter((invite) => invite.clientId !== clientId);
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== 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.grants = data.grants.filter((grant) => !(grant.targetType === "user" && grant.targetId === user.id));
data.exceptions = data.exceptions.filter((exception) => exception.userId !== 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( data.invites = data.invites.filter(
(invite) => invite.email.toLowerCase() !== email && invite.invitedByUserId !== user.id (invite) => invite.email.toLowerCase() !== email && invite.invitedByUserId !== user.id
); );
@ -1551,6 +1555,72 @@ export function createControlPlaneStore({ projectRoot }) {
return { data }; 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) { async function updateService(serviceId, payload, identity) {
return enqueueMutation(async () => { return enqueueMutation(async () => {
const data = readData(); const data = readData();
@ -1831,6 +1901,7 @@ export function createControlPlaneStore({ projectRoot }) {
recordTaskManagerWorkspaceMembership, recordTaskManagerWorkspaceMembership,
removeTaskManagerProjectMembership, removeTaskManagerProjectMembership,
removeTaskManagerWorkspaceMembership, removeTaskManagerWorkspaceMembership,
setServiceModuleEntitlement,
setUserServiceAccess, setUserServiceAccess,
updateAccessRequest, updateAccessRequest,
updateClient, updateClient,
@ -1872,10 +1943,33 @@ function normalizeData(payload) {
})); }));
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean); data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).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); data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
return data; 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) { function normalizeRevokedAccount(payload) {
if (typeof payload !== "object" || payload === null) return null; if (typeof payload !== "object" || payload === null) return null;
const email = normalizeEmail(payload.email); const email = normalizeEmail(payload.email);

View File

@ -1481,6 +1481,24 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); 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) => { app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user); const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
publishControlPlaneEvent("admin.sync.retry"); publishControlPlaneEvent("admin.sync.retry");
@ -3139,6 +3157,9 @@ function scopeControlPlaneData(data, scope) {
return false; return false;
}), }),
exceptions: data.exceptions.filter((exception) => userIds.has(exception.userId)), 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( taskManagerMemberships: data.taskManagerMemberships.filter(
(membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId) (membership) => clientIds.has(membership.clientId) && userIds.has(membership.userId)
), ),
@ -3168,6 +3189,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
taskerInviteRequests: [], taskerInviteRequests: [],
grants: [], grants: [],
exceptions: [], exceptions: [],
serviceModuleEntitlements: [],
syncStatuses: [], syncStatuses: [],
auditEvents: [], auditEvents: [],
taskManagerMemberships: [], taskManagerMemberships: [],
@ -3199,6 +3221,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
return false; return false;
}), }),
exceptions: data.exceptions.filter((exception) => exception.userId === user.id), exceptions: data.exceptions.filter((exception) => exception.userId === user.id),
serviceModuleEntitlements: data.serviceModuleEntitlements.filter((entitlement) => entitlement.userId === user.id),
syncStatuses: [], syncStatuses: [],
auditEvents: [], auditEvents: [],
taskManagerMemberships: data.taskManagerMemberships.filter( taskManagerMemberships: data.taskManagerMemberships.filter(

View File

@ -28,6 +28,7 @@ import {
rejectAdminTaskerInviteRequest, rejectAdminTaskerInviteRequest,
removeAdminTaskManagerProjectMembership, removeAdminTaskManagerProjectMembership,
removeAdminTaskManagerWorkspaceMembership, removeAdminTaskManagerWorkspaceMembership,
setAdminServiceModuleEntitlement,
setAdminUserServiceAccess, setAdminUserServiceAccess,
updateAdminClient, updateAdminClient,
updateAdminAccessRequest, updateAdminAccessRequest,
@ -68,6 +69,7 @@ import {
type AccessAssignmentValue, type AccessAssignmentValue,
type CreateUserCommand, type CreateUserCommand,
type EnsureTaskManagerProjectMemberCommand, type EnsureTaskManagerProjectMemberCommand,
type SetServiceModuleEntitlementCommand,
type SetUserServiceAccessCommand, type SetUserServiceAccessCommand,
} from "../widgets/admin-overlay/AdminOverlay"; } from "../widgets/admin-overlay/AdminOverlay";
import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel"; import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel";
@ -101,6 +103,7 @@ export function LauncherApp() {
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null); const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({}); const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
const [pendingServiceModuleEntitlements, setPendingServiceModuleEntitlements] = useState<Record<string, boolean>>({});
const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState<Record<string, boolean>>({}); const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState<Record<string, boolean>>({});
const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState<Record<string, boolean>>({}); const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState<Record<string, boolean>>({});
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]); const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
@ -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 }) { function handleSetTaskManagerWorkspaceMemberRole(command: { clientId: string; userId: string; role: TaskManagerWorkspaceMemberRole; workspaceSlug?: string }) {
const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug ?? "primary"}`; const membershipKey = `${command.clientId}:${command.userId}:${command.workspaceSlug ?? "primary"}`;
@ -922,6 +948,7 @@ export function LauncherApp() {
onUpdateMembership={handleUpdateMembership} onUpdateMembership={handleUpdateMembership}
onDeleteMembership={handleDeleteMembership} onDeleteMembership={handleDeleteMembership}
pendingAccessAssignments={pendingAccessAssignments} pendingAccessAssignments={pendingAccessAssignments}
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
onCreateGroup={handleCreateGroup} onCreateGroup={handleCreateGroup}
onUpdateGroup={handleUpdateGroup} onUpdateGroup={handleUpdateGroup}
onDeleteGroup={handleDeleteGroup} onDeleteGroup={handleDeleteGroup}
@ -938,6 +965,7 @@ export function LauncherApp() {
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()} onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole} onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole} onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
/> />
) : null} ) : null}
{profileSettingsOpen && activeProfileUser ? ( {profileSettingsOpen && activeProfileUser ? (

View File

@ -36,3 +36,17 @@ export interface EffectiveAccessResult {
source?: ServiceGrantTargetType | "exception"; source?: ServiceGrantTargetType | "exception";
sourceId?: string; 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;
}

View File

@ -1,5 +1,5 @@
import type { AccessRequest } from "../../entities/access-request/types"; 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 { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
import type { Invite } from "../../entities/invite/types"; import type { Invite } from "../../entities/invite/types";
import type { Service } from "../../entities/service/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<ControlPlaneMutationResult & { entitlement?: ServiceModuleEntitlement | null }> {
return requestJson<ControlPlaneMutationResult & { entitlement?: ServiceModuleEntitlement | null }>("/api/admin/access/service-modules", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function upsertAdminGrant(payload: Partial<ServiceGrant>): Promise<ControlPlaneMutationResult> { export async function upsertAdminGrant(payload: Partial<ServiceGrant>): Promise<ControlPlaneMutationResult> {
return requestJson<ControlPlaneMutationResult>("/api/admin/access/grants", { return requestJson<ControlPlaneMutationResult>("/api/admin/access/grants", {
method: "POST", method: "POST",

View File

@ -1,6 +1,6 @@
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess"; import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
import type { AccessRequest } from "../../entities/access-request/types"; 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 { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
import type { Invite } from "../../entities/invite/types"; import type { Invite } from "../../entities/invite/types";
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants"; import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
@ -62,6 +62,7 @@ export interface LauncherData {
services: Service[]; services: Service[];
grants: ServiceGrant[]; grants: ServiceGrant[];
exceptions: ServiceAccessException[]; exceptions: ServiceAccessException[];
serviceModuleEntitlements: ServiceModuleEntitlement[];
invites: Invite[]; invites: Invite[];
accessRequests: AccessRequest[]; accessRequests: AccessRequest[];
revokedAccounts: RevokedAccount[]; revokedAccounts: RevokedAccount[];
@ -214,6 +215,7 @@ export function normalizeLauncherData(data: Partial<LauncherData> | null | undef
services: Array.isArray(payload.services) ? payload.services : mockServices, services: Array.isArray(payload.services) ? payload.services : mockServices,
grants: Array.isArray(payload.grants) ? payload.grants : mockGrants, grants: Array.isArray(payload.grants) ? payload.grants : mockGrants,
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions, exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
serviceModuleEntitlements: Array.isArray(payload.serviceModuleEntitlements) ? payload.serviceModuleEntitlements : [],
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites, invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests, accessRequests: Array.isArray(payload.accessRequests) ? payload.accessRequests : mockAccessRequests,
revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [], revokedAccounts: Array.isArray(payload.revokedAccounts) ? payload.revokedAccounts : [],

View File

@ -3841,6 +3841,120 @@ code {
font-size: 0.72rem; 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 { .access-explanation {
display: grid; display: grid;
align-content: start; align-content: start;

View File

@ -41,7 +41,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import type { AccessRequest, AccessRequestStatus } from "../../entities/access-request/types"; 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 { 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 { PUBLIC_POOL_CLIENT_ID, PUBLIC_POOL_CONTEXT_DESCRIPTION, PUBLIC_POOL_CONTEXT_LABEL, isPublicPoolClientId } from "../../entities/public-pool/constants"; 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; value: AccessAssignmentValue;
} }
export interface SetServiceModuleEntitlementCommand {
clientId: string;
userId: string;
serviceId: string;
moduleId: ServiceModuleId;
enabled: boolean;
}
export interface CreateUserCommand { export interface CreateUserCommand {
clientId: string; clientId: string;
email: string; email: string;
@ -180,6 +188,7 @@ export function AdminOverlay({
onUpdateMembership, onUpdateMembership,
onDeleteMembership, onDeleteMembership,
pendingAccessAssignments, pendingAccessAssignments,
pendingServiceModuleEntitlements,
onCreateGroup, onCreateGroup,
onUpdateGroup, onUpdateGroup,
onDeleteGroup, onDeleteGroup,
@ -196,6 +205,7 @@ export function AdminOverlay({
onRefreshTaskManagerWorkspaces, onRefreshTaskManagerWorkspaces,
onSetTaskManagerWorkspaceMemberRole, onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole, onSetTaskManagerProjectMemberRole,
onSetServiceModuleEntitlement,
}: { }: {
data: LauncherData; data: LauncherData;
me: MeResponse; me: MeResponse;
@ -227,6 +237,7 @@ export function AdminOverlay({
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void; onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onDeleteMembership: (membershipId: string) => void; onDeleteMembership: (membershipId: string) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>; pendingAccessAssignments: Record<string, AccessAssignmentValue>;
pendingServiceModuleEntitlements: Record<string, boolean>;
onCreateGroup: (clientId: string) => void; onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void; onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void; onDeleteGroup: (groupId: string) => void;
@ -243,6 +254,7 @@ export function AdminOverlay({
onRefreshTaskManagerWorkspaces: () => void; onRefreshTaskManagerWorkspaces: () => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void;
}) { }) {
const isRoot = me.launcherRole === "root_admin"; const isRoot = me.launcherRole === "root_admin";
const isPlatformMode = isRoot && mode === "platform"; const isPlatformMode = isRoot && mode === "platform";
@ -510,6 +522,7 @@ export function AdminOverlay({
onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })} onSelectCell={(cell) => setSelectedCell({ userId: cell.userId, serviceId: cell.serviceId })}
onSetUserServiceAccess={onSetUserServiceAccess} onSetUserServiceAccess={onSetUserServiceAccess}
pendingAccessAssignments={pendingAccessAssignments} pendingAccessAssignments={pendingAccessAssignments}
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
onUpdateUser={onUpdateUser} onUpdateUser={onUpdateUser}
onUpdateMembership={onUpdateMembership} onUpdateMembership={onUpdateMembership}
pendingTaskManagerMemberships={pendingTaskManagerMemberships} pendingTaskManagerMemberships={pendingTaskManagerMemberships}
@ -517,6 +530,7 @@ export function AdminOverlay({
taskManagerWorkspaceCatalog={taskManagerWorkspaces} taskManagerWorkspaceCatalog={taskManagerWorkspaces}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole} onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole} onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
onSetServiceModuleEntitlement={onSetServiceModuleEntitlement}
/> />
) : null} ) : null}
{activeSection === "invites" ? ( {activeSection === "invites" ? (
@ -1367,6 +1381,22 @@ const taskManagerProjectRoleOptions: Array<NodeDcSelectOption<OperationalCoreRol
{ value: "pending", label: "Сохраняем...", disabled: true, hidden: true }, { value: "pending", label: "Сохраняем...", disabled: true, hidden: true },
]; ];
const operationalCoreModules: Array<{
id: ServiceModuleId;
title: string;
description: string;
enabledLabel: string;
disabledLabel: string;
}> = [
{
id: "codex_agents",
title: "Codex Agent API",
description: "Разрешает пользователю подключать локального Codex-агента к своим разрешённым workspace/project в Operational Core.",
enabledLabel: "Модуль включён",
disabledLabel: "Модуль выключен",
},
];
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;
} }
@ -2874,6 +2904,7 @@ function AccessSection({
onSelectCell, onSelectCell,
onSetUserServiceAccess, onSetUserServiceAccess,
pendingAccessAssignments, pendingAccessAssignments,
pendingServiceModuleEntitlements,
onUpdateUser, onUpdateUser,
onUpdateMembership, onUpdateMembership,
pendingTaskManagerMemberships, pendingTaskManagerMemberships,
@ -2881,6 +2912,7 @@ function AccessSection({
taskManagerWorkspaceCatalog, taskManagerWorkspaceCatalog,
onSetTaskManagerWorkspaceMemberRole, onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole, onSetTaskManagerProjectMemberRole,
onSetServiceModuleEntitlement,
}: { }: {
data: LauncherData; data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>; matrix: ReturnType<typeof buildAccessMatrix>;
@ -2888,6 +2920,7 @@ function AccessSection({
onSelectCell: (cell: AccessMatrixCell) => void; onSelectCell: (cell: AccessMatrixCell) => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>; pendingAccessAssignments: Record<string, AccessAssignmentValue>;
pendingServiceModuleEntitlements: Record<string, boolean>;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void; onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void; onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
pendingTaskManagerMemberships: Record<string, boolean>; pendingTaskManagerMemberships: Record<string, boolean>;
@ -2895,6 +2928,7 @@ function AccessSection({
taskManagerWorkspaceCatalog: TaskManagerWorkspaceSummary[]; taskManagerWorkspaceCatalog: TaskManagerWorkspaceSummary[];
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void;
}) { }) {
const hasUsers = matrix.users.length > 0; const hasUsers = matrix.users.length > 0;
const isPublicPoolContext = isPublicPoolClientId(matrix.client.id); const isPublicPoolContext = isPublicPoolClientId(matrix.client.id);
@ -3010,7 +3044,7 @@ function AccessSection({
role: accessAssignmentToTaskManagerRole(nextValue), role: accessAssignmentToTaskManagerRole(nextValue),
}); });
}} }}
onOpenDetails={isTaskManagerService && !usePublicTaskerAccess ? () => setDetailsCell(cell) : undefined} onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined}
/> />
</div> </div>
); );
@ -3029,15 +3063,18 @@ function AccessSection({
user={getUser(data, detailsCell.userId)} user={getUser(data, detailsCell.userId)}
service={detailsService} service={detailsService}
cell={detailsCell} cell={detailsCell}
workspaces={clientTaskManagerWorkspaces} workspaces={isPublicPoolContext ? [] : clientTaskManagerWorkspaces}
workspaceCatalog={taskManagerWorkspaceCatalog} workspaceCatalog={taskManagerWorkspaceCatalog}
publicSelfService={isPublicPoolContext}
pendingAccessAssignments={pendingAccessAssignments} pendingAccessAssignments={pendingAccessAssignments}
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
pendingTaskManagerMemberships={pendingTaskManagerMemberships} pendingTaskManagerMemberships={pendingTaskManagerMemberships}
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships} pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
onClose={() => setDetailsCell(null)} onClose={() => setDetailsCell(null)}
onSetUserServiceAccess={onSetUserServiceAccess} onSetUserServiceAccess={onSetUserServiceAccess}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole} onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole} onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
onSetServiceModuleEntitlement={onSetServiceModuleEntitlement}
/> />
) : null} ) : null}
</div> </div>
@ -3051,8 +3088,10 @@ function PublicAccessUsersPanel({
onSelectCell, onSelectCell,
onSetUserServiceAccess, onSetUserServiceAccess,
pendingAccessAssignments, pendingAccessAssignments,
pendingServiceModuleEntitlements,
onUpdateUser, onUpdateUser,
onUpdateMembership, onUpdateMembership,
onSetServiceModuleEntitlement,
}: { }: {
data: LauncherData; data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>; matrix: ReturnType<typeof buildAccessMatrix>;
@ -3060,10 +3099,14 @@ function PublicAccessUsersPanel({
onSelectCell: (cell: AccessMatrixCell) => void; onSelectCell: (cell: AccessMatrixCell) => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
pendingAccessAssignments: Record<string, AccessAssignmentValue>; pendingAccessAssignments: Record<string, AccessAssignmentValue>;
pendingServiceModuleEntitlements: Record<string, boolean>;
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void; onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void; onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void;
}) { }) {
const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null; const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null;
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
const detailsService = detailsCell ? getService(data, detailsCell.serviceId) : null;
return ( return (
<GlassSurface className="table-shell table-shell--users table-shell--public-access-users"> <GlassSurface className="table-shell table-shell--users table-shell--public-access-users">
@ -3168,13 +3211,8 @@ function PublicAccessUsersPanel({
pendingValue={pendingAccessAssignments[accessCellKey(user.id, operationalCoreCell.serviceId)]} pendingValue={pendingAccessAssignments[accessCellKey(user.id, operationalCoreCell.serviceId)]}
publicSelfService publicSelfService
onSelectCell={onSelectCell} onSelectCell={onSelectCell}
onSetAccess={(value) => onSetAccess={() => undefined}
onSetUserServiceAccess({ onOpenDetails={() => setDetailsCell(operationalCoreCell)}
userId: user.id,
serviceId: operationalCoreCell.serviceId,
value,
})
}
/> />
) : ( ) : (
<span className="muted-text"></span> <span className="muted-text"></span>
@ -3185,6 +3223,27 @@ function PublicAccessUsersPanel({
})} })}
</tbody> </tbody>
</table> </table>
{detailsCell && detailsService ? (
<OperationalCoreAccessModal
data={data}
client={matrix.client}
user={getUser(data, detailsCell.userId)}
service={detailsService}
cell={detailsCell}
workspaces={[]}
workspaceCatalog={[]}
publicSelfService
pendingAccessAssignments={pendingAccessAssignments}
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
pendingTaskManagerMemberships={{}}
pendingTaskManagerProjectMemberships={{}}
onClose={() => setDetailsCell(null)}
onSetUserServiceAccess={onSetUserServiceAccess}
onSetTaskManagerWorkspaceMemberRole={() => undefined}
onSetTaskManagerProjectMemberRole={() => undefined}
onSetServiceModuleEntitlement={onSetServiceModuleEntitlement}
/>
) : null}
</GlassSurface> </GlassSurface>
); );
} }
@ -3280,13 +3339,16 @@ function OperationalCoreAccessModal({
cell, cell,
workspaces, workspaces,
workspaceCatalog, workspaceCatalog,
publicSelfService = false,
pendingAccessAssignments, pendingAccessAssignments,
pendingTaskManagerMemberships, pendingTaskManagerMemberships,
pendingTaskManagerProjectMemberships, pendingTaskManagerProjectMemberships,
pendingServiceModuleEntitlements,
onClose, onClose,
onSetUserServiceAccess, onSetUserServiceAccess,
onSetTaskManagerWorkspaceMemberRole, onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole, onSetTaskManagerProjectMemberRole,
onSetServiceModuleEntitlement,
}: { }: {
data: LauncherData; data: LauncherData;
client: Client; client: Client;
@ -3295,18 +3357,24 @@ function OperationalCoreAccessModal({
cell: AccessMatrixCell; cell: AccessMatrixCell;
workspaces: ClientTaskManagerWorkspaceBinding[]; workspaces: ClientTaskManagerWorkspaceBinding[];
workspaceCatalog: TaskManagerWorkspaceSummary[]; workspaceCatalog: TaskManagerWorkspaceSummary[];
publicSelfService?: boolean;
pendingAccessAssignments: Record<string, AccessAssignmentValue>; pendingAccessAssignments: Record<string, AccessAssignmentValue>;
pendingTaskManagerMemberships: Record<string, boolean>; pendingTaskManagerMemberships: Record<string, boolean>;
pendingTaskManagerProjectMemberships: Record<string, boolean>; pendingTaskManagerProjectMemberships: Record<string, boolean>;
pendingServiceModuleEntitlements: Record<string, boolean>;
onClose: () => void; onClose: () => void;
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void; onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void; onSetTaskManagerWorkspaceMemberRole: (command: EnsureTaskManagerWorkspaceMemberCommand) => void;
onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void; onSetTaskManagerProjectMemberRole: (command: EnsureTaskManagerProjectMemberCommand) => void;
onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void;
}) { }) {
const membership = data.memberships.find((item) => item.clientId === client.id && item.userId === user.id); 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 protectedUser = user.id === "user_root" || membership?.role === "client_owner";
const basePendingValue = pendingAccessAssignments[accessCellKey(user.id, service.id)]; const basePendingValue = pendingAccessAssignments[accessCellKey(user.id, service.id)];
const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell); const baseAssignmentValue = basePendingValue ?? accessAssignmentValue(cell);
const baseSelectOptions = publicSelfService ? publicOperationalCoreAccessOptions : accessAssignmentOptions;
const baseSelectValue = publicSelfService ? publicOperationalCoreSelectValue(baseAssignmentValue) : baseAssignmentValue;
const basePending = basePendingValue !== undefined;
return ( return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Operational Core доступы ${user.name}`}> <div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Operational Core доступы ${user.name}`}>
@ -3319,7 +3387,82 @@ function OperationalCoreAccessModal({
</div> </div>
<div className="task-workspace-access-list"> <div className="task-workspace-access-list">
{workspaces.length ? ( <section className="task-workspace-access-card task-access-base-card">
<div className="task-workspace-access-card__head">
<div>
<strong>Базовый доступ</strong>
<small>
{publicSelfService
? "Открытый контур: workspace member, service admin или блокировка instance."
: "Глобальная роль пользователя в Operational Core для выбранного клиента."}
</small>
</div>
{protectedUser ? (
<AdminStaticPill>{accessAssignmentLabel("admin")}</AdminStaticPill>
) : (
<NodeDcSelect
className="admin-table-select-wrap"
triggerClassName="admin-table-select-trigger"
value={baseSelectValue}
options={baseSelectOptions}
label={`Базовый доступ ${user.name} к Operational Core`}
minMenuWidth={220}
disabled={basePending}
onChange={(nextValue) => {
onSetUserServiceAccess({ userId: user.id, serviceId: service.id, value: nextValue });
}}
/>
)}
</div>
</section>
<section className="task-workspace-access-card task-module-access-card">
<div className="task-module-access-card__head">
<div>
<strong>Дополнительные модули ops-слоя</strong>
<small>Модули работают только внутри выданного доступа к Operational Core и не расширяют workspace/project права сами по себе.</small>
</div>
</div>
<div className="task-module-access-list">
{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 (
<button
key={module.id}
className={cn("task-module-access-row", enabled && "task-module-access-row--enabled", pending && "task-module-access-row--pending")}
type="button"
aria-pressed={enabled}
disabled={pending}
onClick={() =>
onSetServiceModuleEntitlement({
clientId: client.id,
userId: user.id,
serviceId: service.id,
moduleId: module.id,
enabled: !enabled,
})
}
>
<span className="task-module-access-row__meta">
<strong>{module.title}</strong>
<small>{module.description}</small>
</span>
<span className="task-module-access-row__state">
<span className="task-module-checker" aria-hidden="true">
{enabled ? <span /> : null}
</span>
<span>{pending ? "Сохраняем..." : enabled ? module.enabledLabel : module.disabledLabel}</span>
</span>
</button>
);
})}
</div>
</section>
{!publicSelfService ? (
workspaces.map((workspace) => { workspaces.map((workspace) => {
const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug); const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug);
const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog); const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog);
@ -3431,12 +3574,14 @@ function OperationalCoreAccessModal({
</section> </section>
); );
}) })
) : ( ) : null}
{!publicSelfService && !workspaces.length ? (
<div className="access-empty-state"> <div className="access-empty-state">
<strong>Workspace не привязаны к клиенту</strong> <strong>Workspace не привязаны к клиенту</strong>
<span>Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения.</span> <span>Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения.</span>
</div> </div>
)} ) : null}
</div> </div>
<div className="service-content-modal__foot"> <div className="service-content-modal__foot">
@ -4653,6 +4798,21 @@ function accessCellKey(userId: string, serviceId: string): string {
return `${userId}:${serviceId}`; 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 { function isOperationalCoreService(service: Service): boolean {
return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager"; return service.slug === "task-manager" || service.authentikApplicationSlug === "task-manager";
} }