FEAT - LAUNCHER: модульный доступ Codex Agent API
This commit is contained in:
parent
5e18a7f3c2
commit
34917e007a
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<LauncherAuthApp[] | null>(null);
|
||||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||||
const [pendingServiceModuleEntitlements, setPendingServiceModuleEntitlements] = useState<Record<string, boolean>>({});
|
||||
const [pendingTaskManagerMemberships, setPendingTaskManagerMemberships] = useState<Record<string, boolean>>({});
|
||||
const [pendingTaskManagerProjectMemberships, setPendingTaskManagerProjectMemberships] = useState<Record<string, boolean>>({});
|
||||
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 }) {
|
||||
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 ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/access/grants", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -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<LauncherData> | 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 : [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ClientMembership>) => void;
|
||||
onDeleteMembership: (membershipId: string) => void;
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
pendingServiceModuleEntitlements: Record<string, boolean>;
|
||||
onCreateGroup: (clientId: string) => void;
|
||||
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => 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<NodeDcSelectOption<OperationalCoreRol
|
|||
{ 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 {
|
||||
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<typeof buildAccessMatrix>;
|
||||
|
|
@ -2888,6 +2920,7 @@ function AccessSection({
|
|||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
pendingServiceModuleEntitlements: Record<string, boolean>;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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}
|
||||
</div>
|
||||
|
|
@ -3051,8 +3088,10 @@ function PublicAccessUsersPanel({
|
|||
onSelectCell,
|
||||
onSetUserServiceAccess,
|
||||
pendingAccessAssignments,
|
||||
pendingServiceModuleEntitlements,
|
||||
onUpdateUser,
|
||||
onUpdateMembership,
|
||||
onSetServiceModuleEntitlement,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
matrix: ReturnType<typeof buildAccessMatrix>;
|
||||
|
|
@ -3060,10 +3099,14 @@ function PublicAccessUsersPanel({
|
|||
onSelectCell: (cell: AccessMatrixCell) => void;
|
||||
onSetUserServiceAccess: (command: SetUserServiceAccessCommand) => void;
|
||||
pendingAccessAssignments: Record<string, AccessAssignmentValue>;
|
||||
pendingServiceModuleEntitlements: Record<string, boolean>;
|
||||
onUpdateUser: (userId: string, patch: Partial<LauncherUser>) => void;
|
||||
onUpdateMembership: (membershipId: string, patch: Partial<ClientMembership>) => void;
|
||||
onSetServiceModuleEntitlement: (command: SetServiceModuleEntitlementCommand) => void;
|
||||
}) {
|
||||
const operationalCoreService = matrix.services.find(isOperationalCoreService) ?? null;
|
||||
const [detailsCell, setDetailsCell] = useState<AccessMatrixCell | null>(null);
|
||||
const detailsService = detailsCell ? getService(data, detailsCell.serviceId) : null;
|
||||
|
||||
return (
|
||||
<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)]}
|
||||
publicSelfService
|
||||
onSelectCell={onSelectCell}
|
||||
onSetAccess={(value) =>
|
||||
onSetUserServiceAccess({
|
||||
userId: user.id,
|
||||
serviceId: operationalCoreCell.serviceId,
|
||||
value,
|
||||
})
|
||||
}
|
||||
onSetAccess={() => undefined}
|
||||
onOpenDetails={() => setDetailsCell(operationalCoreCell)}
|
||||
/>
|
||||
) : (
|
||||
<span className="muted-text">—</span>
|
||||
|
|
@ -3185,6 +3223,27 @@ function PublicAccessUsersPanel({
|
|||
})}
|
||||
</tbody>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, AccessAssignmentValue>;
|
||||
pendingTaskManagerMemberships: Record<string, boolean>;
|
||||
pendingTaskManagerProjectMemberships: Record<string, boolean>;
|
||||
pendingServiceModuleEntitlements: Record<string, boolean>;
|
||||
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 (
|
||||
<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 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) => {
|
||||
const role = getTaskManagerMembershipRole(data, client.id, user.id, workspace.slug);
|
||||
const projects = getWorkspaceCatalogProjects(workspace, workspaceCatalog);
|
||||
|
|
@ -3431,12 +3574,14 @@ function OperationalCoreAccessModal({
|
|||
</section>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{!publicSelfService && !workspaces.length ? (
|
||||
<div className="access-empty-state">
|
||||
<strong>Workspace не привязаны к клиенту</strong>
|
||||
<span>Откройте карточку клиента и выберите Operational Core workspaces, после этого здесь появятся назначения.</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="service-content-modal__foot">
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue