Compare commits

...

15 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 39843b7737 SEC - LAUNCHER: bind agent modules to Tasker workspace 2026-05-15 00:47:59 +03:00
DCCONSTRUCTIONS 17406d570f SEC - LAUNCHER: scope service modules by Tasker workspace 2026-05-14 21:18:53 +03:00
DCCONSTRUCTIONS 0782e13c77 FEAT - LAUNCHER: выдача service module entitlement в access check 2026-05-14 20:40:40 +03:00
DCCONSTRUCTIONS 34917e007a FEAT - LAUNCHER: модульный доступ Codex Agent API 2026-05-14 20:32:26 +03:00
DCCONSTRUCTIONS 5e18a7f3c2 FIX - HUB PERMISSIONS: poll visible runtime session 2026-05-14 14:52:06 +03:00
DCCONSTRUCTIONS dc5f36e5f4 FIX - HUB PERMISSIONS: refresh admin downgrade live 2026-05-14 14:41:10 +03:00
DCCONSTRUCTIONS 712e9224c0 FIX - HUB AUTH: prevent request-access login loop 2026-05-14 14:16:42 +03:00
DCCONSTRUCTIONS 5898b94875 FIX - HUB ADMIN: stabilize NAS persistence 2026-05-14 13:15:35 +03:00
DCCONSTRUCTIONS 784195f747 FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: автоподтверждение повторных workspace-инвайтов 2026-05-13 01:38:54 +03:00
DCCONSTRUCTIONS 0ba6dc7115 SECURITY - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: очистка доступа Tasker 2026-05-12 22:26:48 +03:00
DCCONSTRUCTIONS 6e47e12f2d ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: guest-доступ Operational Core 2026-05-12 19:35:44 +03:00
DCCONSTRUCTIONS b698741687 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: маппинг имени профиля 2026-05-12 18:28:26 +03:00
DCCONSTRUCTIONS 95225280e7 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: синхронизация профиля из Tasker 2026-05-12 17:57:57 +03:00
DCCONSTRUCTIONS 06a6160a46 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: витрина сервисов и таблицы Launcher 2026-05-12 15:29:49 +03:00
DCCONSTRUCTIONS b3915a851c SECURITY - LAUNCHER: harden storage and access lifecycle 2026-05-12 12:50:52 +03:00
19 changed files with 1550 additions and 4519 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules/
dist/
.git/
.env
.env.*
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.tsbuildinfo
server/storage/*
!server/storage/.gitkeep
public/storage/uploads/*
!public/storage/uploads/.gitkeep
public/storage/backups/

View File

@ -6,3 +6,4 @@ NODEDC_PLATFORM_ENV=../../NODEDC/platform/infra/.env
PORT=5173
LAUNCHER_BASE_URL=http://launcher.local.nodedc
TASK_BASE_URL=http://task.local.nodedc
NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-platform-init-dev-env

5
.gitignore vendored
View File

@ -9,4 +9,9 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.tsbuildinfo
server/storage/*
!server/storage/.gitkeep
public/storage/launcher-data.json
dist/storage/launcher-data.json
public/storage/uploads/
public/storage/backups/

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-alpine AS runner
ENV NODE_ENV=production
ENV PORT=5173
ENV NODEDC_LAUNCHER_STORAGE_DIR=/app/server/storage
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server ./server
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
RUN mkdir -p /app/server/storage /app/dist/storage/uploads /app/public/storage/uploads \
&& chown -R node:node /app/server/storage /app/dist/storage /app/public/storage
USER node
EXPOSE 5173
CMD ["node", "server/dev-server.mjs"]

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,15 @@
import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const publicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
const distDataPath = join(projectRoot, "dist", "storage", "launcher-data.json");
const serverStorageRoot = resolveStorageRoot(projectRoot);
const dataPath = join(serverStorageRoot, "launcher-data.json");
const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
const now = new Date().toISOString();
const existingData = readJson(publicDataPath);
const existingData = existsSync(dataPath) ? readJson(dataPath) : readJson(legacyPublicDataPath);
const services = Array.isArray(existingData.services) ? existingData.services : [];
const existingUsersByEmail = new Map(
(Array.isArray(existingData.users) ? existingData.users : []).map((user) => [String(user.email || "").toLowerCase(), user])
@ -214,11 +215,7 @@ const liveData = {
services,
};
await writeJson(publicDataPath, liveData);
if (existsSync(join(projectRoot, "dist"))) {
await writeJson(distDataPath, liveData);
}
await writeJson(dataPath, liveData);
console.log(`Seeded ${liveData.users.length} users, ${liveData.clients.length} client, ${liveData.groups.length} groups.`);
@ -234,3 +231,13 @@ async function writeJson(path, data) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
function resolveStorageRoot(projectRoot) {
const configuredRoot = process.env.NODEDC_LAUNCHER_STORAGE_DIR;
if (configuredRoot && configuredRoot.trim()) {
return isAbsolute(configuredRoot) ? configuredRoot : resolve(projectRoot, configuredRoot);
}
return join(projectRoot, "server", "storage");
}

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { isAbsolute, join, resolve } from "node:path";
const collectionKeys = [
"clients",
@ -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"]);
@ -62,16 +64,28 @@ const defaultSettings = {
};
export function createControlPlaneStore({ projectRoot }) {
const publicStorageRoot = join(projectRoot, "public", "storage");
const distStorageRoot = join(projectRoot, "dist", "storage");
const dataPath = join(publicStorageRoot, "launcher-data.json");
const serverStorageRoot = resolveStorageRoot(projectRoot);
const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
const dataPath = join(serverStorageRoot, "launcher-data.json");
let mutationQueue = Promise.resolve();
function enqueueMutation(operation) {
const nextOperation = mutationQueue.catch(() => {}).then(operation);
mutationQueue = nextOperation.then(
() => undefined,
() => undefined
);
return nextOperation;
}
function readData() {
if (!existsSync(dataPath)) {
const readablePath = existsSync(dataPath) ? dataPath : legacyPublicDataPath;
if (!existsSync(readablePath)) {
return normalizeData({});
}
return normalizeData(JSON.parse(readFileSync(dataPath, "utf8")));
return normalizeData(JSON.parse(readFileSync(readablePath, "utf8")));
}
async function writeData(data) {
@ -333,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);
@ -349,33 +364,35 @@ export function createControlPlaneStore({ projectRoot }) {
}
async function updateSettings(payload, identity) {
const data = readData();
const actor = resolveActor(data, identity);
const patch = typeof payload === "object" && payload !== null ? payload : {};
const settings = normalizeSettings({
...data.settings,
...patch,
brand: {
...(data.settings?.brand ?? {}),
...(patch.brand ?? {}),
},
taskManager: {
...(data.settings?.taskManager ?? {}),
...(patch.taskManager ?? {}),
},
});
return enqueueMutation(async () => {
const data = readData();
const actor = resolveActor(data, identity);
const patch = typeof payload === "object" && payload !== null ? payload : {};
const settings = normalizeSettings({
...data.settings,
...patch,
brand: {
...(data.settings?.brand ?? {}),
...(patch.brand ?? {}),
},
taskManager: {
...(data.settings?.taskManager ?? {}),
...(patch.taskManager ?? {}),
},
});
data.settings = settings;
addAuditEvent(data, actor, {
action: "Обновлены системные настройки",
objectType: "settings",
objectName: "Brand settings",
result: "success",
details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`,
});
data.settings = settings;
addAuditEvent(data, actor, {
action: "Обновлены системные настройки",
objectType: "settings",
objectName: "Brand settings",
result: "success",
details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`,
});
await writeData(data);
return { settings, data };
await writeData(data);
return { settings, data };
});
}
async function updateUserProfile(userId, payload, identity) {
@ -585,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
);
@ -890,6 +908,8 @@ export function createControlPlaneStore({ projectRoot }) {
const workspaceName = optionalString(payload?.workspaceName, workspaceSlug);
const inviteeEmail = requireString(payload?.inviteeEmail, "inviteeEmail").toLowerCase();
const role = normalizeTaskManagerInviteRole(payload?.role);
const inviteeUser = data.users.find((user) => user.email.toLowerCase() === inviteeEmail && user.globalStatus === "active");
const autoApproveExistingUser = Boolean(inviteeUser && !hasTaskManagerDenyException(data, inviteeUser.id));
const existingRequest = data.taskerInviteRequests.find(
(request) =>
request.taskerInviteId === taskerInviteId ||
@ -902,6 +922,18 @@ export function createControlPlaneStore({ projectRoot }) {
taskerInviteId,
createdAt: now,
};
let nextStatus = "new";
if (existingRequest?.status && existingRequest.status !== "rejected") {
nextStatus = existingRequest.status;
}
if (autoApproveExistingUser) {
nextStatus = "approved";
}
let auditAction = existingRequest ? "Обновлена заявка workspace-инвайта" : "Создана заявка workspace-инвайта";
if (autoApproveExistingUser) {
auditAction = "Автоподтверждена заявка workspace-инвайта";
}
Object.assign(request, {
taskerInviteId,
@ -914,11 +946,13 @@ export function createControlPlaneStore({ projectRoot }) {
inviterPlaneUserId: nullableStringWithFallback(payload?.inviterPlaneUserId, request.inviterPlaneUserId ?? null),
inviterEmail: requireString(payload?.inviterEmail, "inviterEmail").toLowerCase(),
inviterName: optionalString(payload?.inviterName, payload?.inviterEmail ?? "Operational Core user"),
status: existingRequest?.status && existingRequest.status !== "rejected" ? existingRequest.status : "new",
status: nextStatus,
taskerInviteLink: existingRequest?.taskerInviteLink ?? null,
reviewedByUserId: existingRequest?.reviewedByUserId ?? null,
reviewedAt: existingRequest?.reviewedAt ?? null,
comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null),
reviewedByUserId: autoApproveExistingUser ? actor.id : existingRequest?.reviewedByUserId ?? null,
reviewedAt: autoApproveExistingUser ? now : existingRequest?.reviewedAt ?? null,
comment: autoApproveExistingUser
? "Автоподтверждено: пользователь уже активен в NODE.DC."
: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null),
updatedAt: now,
});
@ -926,8 +960,20 @@ export function createControlPlaneStore({ projectRoot }) {
data.taskerInviteRequests.push(request);
}
if (autoApproveExistingUser && inviteeUser) {
ensureTaskerInviteServiceAccess(
data,
{
source: "tasker_workspace_invite",
sourceTaskerInviteRequestId: request.id,
},
inviteeUser,
now
);
}
addAuditEvent(data, actor, {
action: existingRequest ? "Обновлена заявка workspace-инвайта" : "Создана заявка workspace-инвайта",
action: auditAction,
objectType: "tasker_invite_request",
objectName: `${workspaceSlug}:${inviteeEmail}`,
result: "success",
@ -935,7 +981,12 @@ export function createControlPlaneStore({ projectRoot }) {
});
await writeData(data);
return { taskerInviteRequest: request, data };
return {
taskerInviteRequest: request,
autoApproved: autoApproveExistingUser,
affectedUserIds: [payload?.inviterUserId, inviteeUser?.id].filter((userId) => typeof userId === "string" && userId),
data,
};
}
async function approveTaskerInviteRequest(taskerInviteRequestId, payload, identity) {
@ -1192,6 +1243,10 @@ export function createControlPlaneStore({ projectRoot }) {
throw new Error("Этот инвайт выписан на другую почту");
}
if (invite.status === "accepted") {
throw new Error("Инвайт уже принят");
}
if (invite.status === "revoked") {
throw new Error("Инвайт отозван");
}
@ -1500,25 +1555,93 @@ export function createControlPlaneStore({ projectRoot }) {
return { data };
}
async function updateService(serviceId, payload, identity) {
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");
Object.assign(service, sanitizeServicePatch(payload, service));
Object.assign(service, syncServiceLaunchLink(service));
service.updatedAt = isoNow();
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: "Обновлён сервис",
objectType: "service",
objectName: service.title,
action: enabled ? "Включён модуль сервиса" : "Отключён модуль сервиса",
objectType: "service-module-entitlement",
objectName: `${service.slug}:${moduleId}:${user.email}`,
clientId: client.id,
result: "success",
details: `Module: ${moduleId}; enabled: ${enabled}`,
});
markPendingSync(data, service, "service");
await writeData(data);
return { service, data };
return { entitlement, data };
}
async function updateService(serviceId, payload, identity) {
return enqueueMutation(async () => {
const data = readData();
const actor = resolveActor(data, identity);
const service = findById(data.services, serviceId, "service");
Object.assign(service, sanitizeServicePatch(payload, service));
Object.assign(service, syncServiceLaunchLink(service));
service.updatedAt = isoNow();
addAuditEvent(data, actor, {
action: "Обновлён сервис",
objectType: "service",
objectName: service.title,
result: "success",
});
markPendingSync(data, service, "service");
await writeData(data);
return { service, data };
});
}
async function reorderServices(payload, identity) {
@ -1711,13 +1834,7 @@ export function createControlPlaneStore({ projectRoot }) {
}
function getWritableStorageRoots() {
const roots = [publicStorageRoot];
if (existsSync(join(projectRoot, "dist"))) {
roots.push(distStorageRoot);
}
return roots;
return [serverStorageRoot];
}
function getLoginAccountStatus(email) {
@ -1784,6 +1901,7 @@ export function createControlPlaneStore({ projectRoot }) {
recordTaskManagerWorkspaceMembership,
removeTaskManagerProjectMembership,
removeTaskManagerWorkspaceMembership,
setServiceModuleEntitlement,
setUserServiceAccess,
updateAccessRequest,
updateClient,
@ -1799,6 +1917,16 @@ export function createControlPlaneStore({ projectRoot }) {
};
}
function resolveStorageRoot(projectRoot) {
const configuredRoot = process.env.NODEDC_LAUNCHER_STORAGE_DIR;
if (configuredRoot && configuredRoot.trim()) {
return isAbsolute(configuredRoot) ? configuredRoot : resolve(projectRoot, configuredRoot);
}
return join(projectRoot, "server", "storage");
}
function normalizeData(payload) {
const data = typeof payload === "object" && payload !== null ? { ...payload } : {};
@ -1815,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);
@ -2285,6 +2436,17 @@ function ensureTaskerInviteServiceAccess(data, invite, user, now) {
return grant;
}
function hasTaskManagerDenyException(data, userId) {
const service = data.services.find((candidate) => candidate.slug === "task-manager");
if (!service) {
return false;
}
return data.exceptions.some(
(exception) => exception.serviceId === service.id && exception.userId === userId && exception.type === "deny"
);
}
function findTaskerInviteRequestForCancellation(data, payload) {
const requestId = nullableString(payload?.requestId);
const taskerInviteId = nullableString(payload?.taskerInviteId);

View File

@ -5,7 +5,6 @@ import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer as createViteServer } from "vite";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
import { createControlPlaneStore } from "./control-plane-store.mjs";
@ -249,7 +248,14 @@ app.get("/api/me", (req, res) => {
return;
}
const runtimeContext = getRuntimeSessionContext(session);
const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
const runtimeContext = sessionAccess.runtimeContext;
res.json({
authenticated: true,
@ -268,7 +274,14 @@ app.get("/api/apps", (req, res) => {
return;
}
res.json({ apps: getAppsForSession(session) });
const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
res.json({ apps: getAppsForUser(sessionAccess.runtimeContext.groups) });
});
app.get("/api/services/:serviceSlug/launch", requireSession, (req, res) => {
@ -393,6 +406,7 @@ app.post("/api/internal/access/check", (req, res) => {
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC internal access check" });
const user = findInternalAccessUser(snapshot.data, req.body);
const serviceSlug = sanitizeServiceSlug(req.body?.serviceSlug);
const workspaceSlug = normalizeOptionalText(req.body?.workspaceSlug ?? req.body?.workspace?.slug);
if (!user) {
res.json({
@ -410,8 +424,14 @@ app.post("/api/internal/access/check", (req, res) => {
const groups = resolveRequiredGroups(snapshot.data, user);
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
const allowed = Boolean(app?.hasAccess);
const serviceModules =
serviceSlug === "task-manager"
? resolveTaskManagerWorkspaceServiceModules(snapshot.data, user, serviceSlug, workspaceSlug)
: resolveUserServiceModules(snapshot.data, user, serviceSlug, null);
const workspacePolicy =
serviceSlug === "task-manager" ? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user) : null;
serviceSlug === "task-manager"
? resolveTaskManagerWorkspacePolicy(snapshot.data, groups, allowed, user, workspaceSlug, serviceModules)
: null;
res.json({
ok: true,
@ -420,6 +440,7 @@ app.post("/api/internal/access/check", (req, res) => {
serviceSlug,
groups,
matchedGroups: app?.matchedGroups ?? [],
serviceModules,
workspacePolicy,
user: {
id: user.id,
@ -456,9 +477,20 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) =>
const groups = resolveRequiredGroups(snapshot.data, inviter);
const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager");
const workspacePolicy = resolveTaskManagerWorkspacePolicy(snapshot.data, groups, Boolean(app?.hasAccess), inviter);
const workspaceSlug = req.body?.workspace?.slug ?? req.body?.workspaceSlug;
const workspacePolicy = resolveTaskManagerWorkspacePolicy(
snapshot.data,
groups,
Boolean(app?.hasAccess),
inviter,
workspaceSlug
);
if (!app?.hasAccess || workspacePolicy.inviteApproval !== "nodedc") {
if (
!app?.hasAccess ||
workspacePolicy.managedBy !== "tasker" ||
!["nodedc", "launcher"].includes(workspacePolicy.inviteApproval)
) {
res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy });
return;
}
@ -476,8 +508,11 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) =>
inviterName: inviter.name,
}, inviter);
publishControlPlaneEvent("tasker.invite-request.created", [inviter.id]);
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
publishControlPlaneEvent(
"tasker.invite-request.created",
result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id]
);
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest, autoApproved: Boolean(result.autoApproved) });
}));
app.post("/api/internal/tasker/invite-requests/cancel", asyncRoute(async (req, res) => {
@ -510,6 +545,44 @@ app.post("/api/internal/tasker/invite-requests/cancel", asyncRoute(async (req, r
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest });
}));
app.post("/api/internal/tasker/profile-sync", asyncRoute(async (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
});
return;
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC tasker profile sync" });
const user = findInternalAccessUser(snapshot.data, req.body);
if (!user) {
res.status(404).json({ ok: false, error: "user_not_found" });
return;
}
const patch = sanitizeTaskerProfilePatch(req.body);
if (Object.keys(patch).length === 0) {
res.json({ ok: true, user, data: snapshot.data, skipped: true });
return;
}
const actor = {
sub: "tasker-profile-sync",
name: req.body?.source === "tasker" ? "Operational Core" : "NODE.DC profile sync",
email: typeof req.body?.email === "string" ? req.body.email : user.email,
source: "tasker",
};
const result = await controlPlaneStore.updateUserProfile(user.id, patch, actor);
const syncResult = await syncUsersToAuthentik(result.data, [user.id], actor);
const updatedUser = syncResult.data.users.find((candidate) => candidate.id === user.id) ?? result.user;
publishControlPlaneEvent("tasker.profile.updated", [user.id]);
res.json({ ok: true, user: updatedUser, data: syncResult.data });
}));
app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
const { actor } = getLauncherProfileContext(req.nodedcSession);
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
@ -518,9 +591,10 @@ app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
userId: actor.id,
});
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
const taskManagerProfile = await syncTaskManagerUserProfile(storeResult.user);
publishControlPlaneEvent("profile.updated", [actor.id]);
res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser) });
res.json({ ...storeResult, provisioning: toProvisioningResponse(provisionedUser), taskManagerProfile });
}));
app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => {
@ -629,7 +703,15 @@ app.get("/tasker-workspace-invite/:taskerInviteRequestId", (req, res) => {
return;
}
const runtimeContext = getRuntimeSessionContext(session);
const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
expireSession(res, session);
res.redirect(buildLoginRedirectUrl(req.originalUrl, { forceLogin: true }));
return;
}
const runtimeContext = sessionAccess.runtimeContext;
const request = controlPlaneStore
.getSnapshot({ name: "NODE.DC tasker invite redirect" })
.data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId);
@ -660,6 +742,25 @@ app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(scopeAdminSnapshot(req));
});
app.get("/api/storage/data", requireSession, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
if (snapshot.actor.source !== "launcher") {
res.status(403).json({ error: "Профиль пользователя не найден в Launcher control-plane" });
return;
}
const runtimeContext = getRuntimeSessionContext(req.nodedcSession);
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
if (adminScope.isRoot || adminScope.clientIds.size > 0) {
res.json(scopeControlPlaneData(snapshot.data, adminScope));
return;
}
res.json(scopeRuntimeControlPlaneData(snapshot.data, snapshot.actor.id));
});
app.patch("/api/admin/settings", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.settings.updated");
@ -1017,10 +1118,22 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a
return;
}
const beforeSnapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
const beforeUser = beforeSnapshot.data.users.find((candidate) => candidate.id === req.params.userId) ?? null;
const result = await controlPlaneStore.updateUserProfile(req.params.userId, req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.params.userId], req.nodedcSession.user);
const updatedUser = syncResult.data.users.find((candidate) => candidate.id === req.params.userId);
const taskManagerProfile = await syncTaskManagerUserProfile(updatedUser);
const taskManagerCleanup =
beforeUser?.globalStatus === "active" && updatedUser?.globalStatus === "blocked"
? await cleanupTaskManagerUserAccess(updatedUser, {
source: "launcher-user-blocked",
revokeIdentityLinks: false,
revokeTaskerAccess: true,
})
: null;
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
res.json({ ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile, taskManagerCleanup });
}));
app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
@ -1038,9 +1151,14 @@ app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncher
authentik = await authentikSyncClient.deleteUser({ data: snapshot.data, userId: req.params.userId });
}
const taskManagerCleanup = await cleanupTaskManagerUserAccess(user, {
source: "launcher-user-hard-delete",
revokeIdentityLinks: true,
revokeTaskerAccess: true,
});
const result = await controlPlaneStore.deleteUser(req.params.userId, req.nodedcSession.user);
publishControlPlaneEvent("admin.user.deleted", [req.params.userId]);
res.json({ ...scopeAdminMutationResult(req, result), authentik });
res.json({ ...scopeAdminMutationResult(req, result), authentik, taskManagerCleanup });
}));
app.post("/api/admin/users/:userId/provision-authentik", requireLauncherAdmin, asyncRoute(async (req, res) => {
@ -1355,12 +1473,40 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn
return;
}
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
if (isPublicTaskManagerGuestServiceAssignment(snapshot.data, req.body)) {
res.status(400).json({
ok: false,
error: "task_manager_public_guest_not_assignable",
message: "Workspace Guest выдаётся только через настройки Operational Core.",
});
return;
}
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
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");
@ -1371,7 +1517,7 @@ app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLaunc
res.json(controlPlaneStore.buildAuthentikSyncPlan());
});
app.post("/api/storage/upload", asyncRoute(async (req, res) => {
app.post("/api/storage/upload", requireSession, asyncRoute(async (req, res) => {
const result = await saveUploadedFile(req.body);
res.json(result);
}));
@ -1379,22 +1525,57 @@ app.post("/api/storage/upload", asyncRoute(async (req, res) => {
app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
await saveLauncherData(req.body);
publishControlPlaneEvent("storage.data.updated");
res.json({ ok: true, url: "/storage/launcher-data.json" });
res.json({ ok: true });
}));
const vite = await createViteServer({
root: projectRoot,
appType: "spa",
server: {
middlewareMode: true,
hmr: { server: httpServer },
},
app.get("/storage/launcher-data.json", (_req, res) => {
setNoStore(res);
res.status(404).json({ error: "not_found" });
});
app.use(vite.middlewares);
let fixFrontendStacktrace = () => {};
if (process.env.NODE_ENV === "production") {
const distRoot = join(projectRoot, "dist");
const indexHtmlPath = join(distRoot, "index.html");
if (!existsSync(indexHtmlPath)) {
throw new Error("Launcher production build is missing. Run npm run build before starting the server.");
}
app.use(express.static(distRoot, { index: false }));
app.use((req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") {
next();
return;
}
const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
if (!accept.includes("text/html")) {
next();
return;
}
setNoStore(res);
res.sendFile(indexHtmlPath);
});
} else {
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: projectRoot,
appType: "spa",
server: {
middlewareMode: true,
hmr: { server: httpServer },
},
});
fixFrontendStacktrace = (error) => vite.ssrFixStacktrace(error);
app.use(vite.middlewares);
}
app.use((error, _req, res, _next) => {
vite.ssrFixStacktrace(error);
fixFrontendStacktrace(error);
const message = error instanceof Error ? error.message : "Unexpected server error";
res.status(500).json({ error: message });
});
@ -1433,7 +1614,6 @@ function readConfig() {
internalAccessToken:
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ??
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ??
process.env.PLANE_OIDC_CLIENT_SECRET ??
"",
taskLogoutUrl:
process.env.TASK_LOGOUT_URL ??
@ -1675,6 +1855,64 @@ function sanitizeSelfProfilePatch(payload) {
};
}
function sanitizeTaskerProfilePatch(payload) {
const patch = {};
const changedFields = Array.isArray(payload?.changedFields) ? payload.changedFields : [];
const taskerFullName = joinTaskerProfileName(
payload?.firstName ?? payload?.first_name,
payload?.lastName ?? payload?.last_name
);
const taskerDisplayName = firstNonEmptyString(payload?.displayName, payload?.display_name, payload?.name);
const name =
hasChangedField(changedFields, ["display_name"]) || !hasChangedField(changedFields, ["first_name", "last_name"])
? firstNonEmptyString(taskerDisplayName, taskerFullName)
: firstNonEmptyString(taskerFullName, taskerDisplayName);
const hasAvatar =
Object.hasOwn(payload ?? {}, "avatarUrl") ||
Object.hasOwn(payload ?? {}, "avatar_url") ||
Object.hasOwn(payload ?? {}, "avatar");
if (name) {
patch.name = name;
}
if (hasAvatar) {
patch.avatarUrl = nullableProfileUrl(payload?.avatarUrl ?? payload?.avatar_url ?? payload?.avatar);
}
return patch;
}
function firstNonEmptyString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function joinTaskerProfileName(firstName, lastName) {
return [firstName, lastName].filter((value) => typeof value === "string" && value.trim()).join(" ").trim();
}
function splitTaskerProfileName(name) {
const trimmedName = typeof name === "string" ? name.trim() : "";
const parts = trimmedName.split(/\s+/, 2);
return {
firstName: parts[0] ?? "",
lastName: parts.length > 1 ? trimmedName.slice(parts[0].length).trim() : "",
};
}
function hasChangedField(changedFields, fields) {
return fields.some((field) => changedFields.includes(field));
}
function nullableProfileUrl(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function toProvisioningResponse(provisionedUser) {
return {
authentikUserId: provisionedUser.authentikUserId,
@ -1771,26 +2009,78 @@ function getRuntimeSessionContext(session) {
return fallback;
}
const groups = resolveRequiredGroups(snapshot.data, user);
return {
groups,
user: {
...session.user,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? session.user.avatarUrl,
groups,
},
};
return buildRuntimeSessionContext(session, snapshot.data, user);
} catch (error) {
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
return fallback;
}
}
function getAppsForSession(session) {
return getAppsForUser(getRuntimeSessionContext(session).groups);
function buildRuntimeSessionContext(session, data, user) {
const groups = resolveRequiredGroups(data, user);
return {
groups,
user: {
...session.user,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? session.user.avatarUrl,
groups,
},
};
}
function getSessionAccessState(session) {
try {
const snapshot = controlPlaneStore.getSnapshot(session.user);
const sessionEmail = typeof session.user?.email === "string" ? session.user.email.toLowerCase() : "";
if (snapshot.actor.source !== "launcher") {
const revokedAccount = snapshot.data.revokedAccounts.find((account) => account.email.toLowerCase() === sessionEmail);
return {
ok: false,
status: 401,
error: revokedAccount ? "account_revoked" : "account_not_found",
message: revokedAccount
? "Аккаунт больше не активен. Запросите доступ, если хотите подключиться снова."
: "Профиль пользователя не найден в Launcher control-plane.",
};
}
const user = snapshot.data.users.find((candidate) => candidate.id === snapshot.actor.id);
if (!user) {
return {
ok: false,
status: 401,
error: "account_not_found",
message: "Профиль пользователя не найден в Launcher control-plane.",
};
}
if (user.globalStatus === "blocked") {
return {
ok: false,
status: 401,
error: "account_blocked",
message: "Аккаунт заблокирован. Обратитесь к администратору NODE.DC.",
};
}
return {
ok: true,
runtimeContext: buildRuntimeSessionContext(session, snapshot.data, user),
};
} catch (error) {
console.warn(error instanceof Error ? error.message : "Не удалось проверить Launcher-сессию");
return {
ok: false,
status: 401,
error: "session_invalid",
message: "Не удалось проверить Launcher-сессию.",
};
}
}
function getAppsForUser(userGroups) {
@ -1904,6 +2194,59 @@ async function requestTaskManagerInternalJson(pathname, init = {}) {
return payload;
}
async function syncTaskManagerUserProfile(user) {
if (!user?.email || !config.internalAccessToken) {
return null;
}
const taskerNameParts = splitTaskerProfileName(user.name);
try {
return await requestTaskManagerInternalJson("/api/internal/nodedc/users/profile-sync/", {
method: "POST",
body: {
email: user.email,
subject: user.authentikUserId ?? undefined,
name: user.name,
displayName: user.name,
firstName: taskerNameParts.firstName,
lastName: taskerNameParts.lastName,
avatarUrl: resolveUserAvatarPublicUrl(user),
},
});
} catch (error) {
console.warn(
error instanceof Error
? `Task Manager profile sync failed: ${error.message}`
: "Task Manager profile sync failed"
);
return null;
}
}
async function cleanupTaskManagerUserAccess(user, options = {}) {
if (!user?.email || !config.internalAccessToken) {
return null;
}
try {
return await requestTaskManagerInternalJson("/api/internal/nodedc/logout/", {
method: "POST",
body: {
source: normalizeOptionalText(options.source) ?? "launcher-user-access-revoked",
subject: user.authentikUserId ?? undefined,
email: user.email,
revokeIdentityLinks: options.revokeIdentityLinks === true,
revokeTaskerAccess: options.revokeTaskerAccess !== false,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Task Manager user cleanup failed";
console.warn(`Task Manager user cleanup failed: ${message}`);
return { ok: false, error: message };
}
}
function parseJsonResponse(text, url) {
try {
return JSON.parse(text);
@ -1920,6 +2263,24 @@ function normalizeTaskManagerRole(value) {
return value === "guest" || value === "admin" || value === "member" ? value : null;
}
function isPublicTaskManagerGuestServiceAssignment(data, payload) {
if (payload?.value !== "viewer") return false;
const service = data.services.find((candidate) => candidate.id === payload?.serviceId);
if (!service || !isTaskManagerService(service)) return false;
return data.memberships.some(
(membership) =>
membership.userId === payload?.userId &&
membership.clientId === publicPoolClientId &&
membership.status === "active"
);
}
function isTaskManagerService(service) {
return service?.slug === "task-manager" || service?.authentikApplicationSlug === "task-manager";
}
function resolveTaskManagerRoleForMembership(role) {
return role === "client_owner" || role === "client_admin" ? "admin" : "member";
}
@ -2037,7 +2398,7 @@ function pruneExpiredServiceHandoffs() {
}
}
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user) {
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user, workspaceSlug = null, serviceModules = {}) {
const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
const groupSet = new Set(groups);
const isSuperAdmin = groupSet.has("nodedc:superadmin");
@ -2049,6 +2410,10 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
);
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "tasker";
const workspaceAssignment =
typeof workspaceSlug === "string" && workspaceSlug.trim()
? workspaces.find((workspace) => workspace.slug === workspaceSlug.trim())
: null;
if (!hasTaskManagerAccess) {
return {
@ -2058,6 +2423,7 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
inviteApproval: "disabled",
defaultInviteApproval,
workspaces,
serviceModules,
canCreateWorkspace: false,
reason: "Нет доступа к Operational Core.",
};
@ -2071,12 +2437,41 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
inviteApproval: "disabled",
defaultInviteApproval,
workspaces,
serviceModules,
canCreateWorkspace: false,
reason: "Создание рабочих пространств отключено на уровне платформы.",
};
}
if (hasLauncherManagedWorkspace && !isSuperAdmin) {
if (hasLauncherManagedWorkspace && !isSuperAdmin && !isTaskManagerAdmin) {
if (workspaceAssignment?.managedBy === "launcher") {
return {
mode,
managedBy: "launcher",
defaultManagedBy: "launcher",
inviteApproval: "launcher",
defaultInviteApproval: "launcher",
workspaces,
serviceModules,
canCreateWorkspace: false,
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
};
}
if (workspaceSlug) {
return {
mode,
managedBy: "tasker",
defaultManagedBy: "launcher",
inviteApproval: defaultInviteApproval,
defaultInviteApproval,
workspaces,
serviceModules,
canCreateWorkspace: false,
reason: "Self-service workspace работает через Tasker, approve инвайтов выполняет Launcher.",
};
}
return {
mode,
managedBy: "launcher",
@ -2084,6 +2479,7 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
inviteApproval: "launcher",
defaultInviteApproval: "launcher",
workspaces,
serviceModules,
canCreateWorkspace: false,
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
};
@ -2097,6 +2493,7 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
inviteApproval: defaultInviteApproval,
defaultInviteApproval,
workspaces,
serviceModules,
canCreateWorkspace: false,
reason: "Создание рабочих пространств доступно только администраторам Operational Core.",
};
@ -2109,11 +2506,65 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
inviteApproval: defaultInviteApproval,
defaultInviteApproval,
workspaces,
serviceModules,
canCreateWorkspace: true,
reason: "Создание рабочих пространств разрешено платформенной policy.",
};
}
function resolveTaskManagerWorkspaceServiceModules(data, user, serviceSlug, workspaceSlug) {
const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug);
if (!normalizedWorkspaceSlug) {
return {};
}
const workspaces = resolveTaskManagerWorkspaceAssignments(data, user);
const workspaceAssignment = workspaces.find((workspace) => workspace.slug === normalizedWorkspaceSlug);
const boundClientId = resolveTaskManagerWorkspaceClientId(data, normalizedWorkspaceSlug);
const clientId =
workspaceAssignment?.clientId ??
(boundClientId ? null : isPublicPoolUser(data, user) ? publicPoolClientId : null);
return resolveUserServiceModules(data, user, serviceSlug, clientId);
}
function resolveTaskManagerWorkspaceClientId(data, workspaceSlug) {
const normalizedWorkspaceSlug = normalizeOptionalText(workspaceSlug);
if (!normalizedWorkspaceSlug) return null;
const client = data.clients.find((candidate) => resolveTaskManagerWorkspaceBinding(candidate, normalizedWorkspaceSlug));
return client?.id ?? null;
}
function isPublicPoolUser(data, user) {
return data.memberships.some(
(membership) => membership.userId === user?.id && membership.clientId === publicPoolClientId && membership.status === "active"
);
}
function resolveUserServiceModules(data, user, serviceSlug, clientId) {
if (!user?.id) return {};
const normalizedClientId = normalizeOptionalText(clientId);
if (!normalizedClientId) return {};
const service = data.services.find(
(candidate) => candidate.slug === serviceSlug || candidate.authentikApplicationSlug === serviceSlug
);
if (!service?.id) return {};
return Object.fromEntries(
(data.serviceModuleEntitlements ?? [])
.filter(
(entitlement) =>
entitlement.clientId === normalizedClientId &&
entitlement.userId === user.id &&
entitlement.serviceId === service.id &&
entitlement.enabled
)
.map((entitlement) => [entitlement.moduleId, true])
);
}
function getFrontchannelLogoutUrls() {
const urls = [config.taskLogoutUrl];
const launcherData = readLauncherData();
@ -2306,11 +2757,8 @@ function getSessionSyncAllowedOrigins() {
}
function readLauncherData() {
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
try {
if (!existsSync(dataPath)) return null;
return JSON.parse(readFileSync(dataPath, "utf8"));
return controlPlaneStore.readData();
} catch {
return null;
}
@ -2433,6 +2881,21 @@ function getCurrentSession(req) {
return session;
}
function expireSession(res, session) {
sessions.delete(session.id);
res.clearCookie(sessionCookieName, clearCookieOptions());
}
function rejectInactiveSession(res, session, sessionAccess) {
expireSession(res, session);
res.status(sessionAccess.status ?? 401).json({
authenticated: false,
loginUrl: "/auth/login",
error: sessionAccess.error,
message: sessionAccess.message,
});
}
function pruneExpiredSessions() {
for (const [sessionId, session] of sessions) {
if (session.expiresAt < Date.now()) {
@ -2501,7 +2964,14 @@ function requireLauncherAdmin(req, res, next) {
return;
}
const runtimeContext = getRuntimeSessionContext(session);
const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
const runtimeContext = sessionAccess.runtimeContext;
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
@ -2531,7 +3001,14 @@ function requireSession(req, res, next) {
return;
}
const runtimeContext = getRuntimeSessionContext(session);
const sessionAccess = getSessionAccessState(session);
if (!sessionAccess.ok) {
rejectInactiveSession(res, session, sessionAccess);
return;
}
const runtimeContext = sessionAccess.runtimeContext;
req.nodedcSession = { ...session, user: runtimeContext.user };
next();
}
@ -2748,6 +3225,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)
),
@ -2761,6 +3241,66 @@ function scopeControlPlaneData(data, scope) {
};
}
function scopeRuntimeControlPlaneData(data, userId) {
const user = data.users.find((candidate) => candidate.id === userId);
if (!user) {
return {
...data,
clients: [],
users: [],
memberships: [],
groups: [],
invites: [],
accessRequests: [],
revokedAccounts: [],
taskerInviteRequests: [],
grants: [],
exceptions: [],
serviceModuleEntitlements: [],
syncStatuses: [],
auditEvents: [],
taskManagerMemberships: [],
taskManagerProjectMemberships: [],
};
}
const memberships = data.memberships.filter((membership) => membership.userId === user.id);
const clientIds = new Set(memberships.map((membership) => membership.clientId));
const groups = data.groups
.filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
const groupIds = new Set(groups.map((group) => group.id));
return {
...data,
clients: data.clients.filter((client) => clientIds.has(client.id)),
users: [user],
memberships,
groups,
invites: [],
accessRequests: [],
revokedAccounts: [],
taskerInviteRequests: [],
grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.has(grant.targetId);
if (grant.targetType === "user") return grant.targetId === user.id;
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(
(membership) => membership.userId === user.id && clientIds.has(membership.clientId)
),
taskManagerProjectMemberships: data.taskManagerProjectMemberships.filter(
(membership) => membership.userId === user.id && clientIds.has(membership.clientId)
),
};
}
function cookieOptions(maxAgeMs) {
const options = {
httpOnly: true,

0
server/storage/.gitkeep Normal file
View File

View File

@ -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";
@ -86,6 +88,8 @@ type InviteFlowState =
| { status: "registered"; payload: PublicInviteResponse; loginUrl: string }
| { status: "error"; message: string; payload?: PublicInviteResponse };
type ControlPlaneMutationOutcome = { ok: true; data: LauncherData } | { ok: false; message: string };
export function LauncherApp() {
const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []);
const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []);
@ -99,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[]>([]);
@ -108,15 +113,19 @@ export function LauncherApp() {
const runtimeDataRef = useRef(data);
const runtimeProfileIdRef = useRef(activeProfileId);
const runtimeClientIdRef = useRef(activeClientId);
const resolvedProfileId = useMemo(
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
[activeProfileId, authSession, data]
);
useEffect(() => {
runtimeDataRef.current = data;
runtimeProfileIdRef.current = activeProfileId;
runtimeProfileIdRef.current = resolvedProfileId;
runtimeClientIdRef.current = activeClientId;
}, [activeClientId, activeProfileId, data]);
}, [activeClientId, data, resolvedProfileId]);
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
const me = useMemo(() => buildMe(data, resolvedProfileId, activeClientId), [data, resolvedProfileId, activeClientId]);
const activeProfileUser = data.users.find((user) => user.id === resolvedProfileId) ?? data.users[0];
const currentAccessRequest = useMemo(() => {
if (!authSession?.authenticated || !authSession.user.email) return null;
@ -150,10 +159,10 @@ export function LauncherApp() {
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
const launcherServices = useMemo(
() => {
const services = buildLauncherServices(data, activeProfileId, resolvedClientId);
const services = buildLauncherServices(data, resolvedProfileId, resolvedClientId);
if (!authSession?.authenticated || authApps === null) {
return services;
return [];
}
return services.map((service) => {
@ -174,25 +183,27 @@ export function LauncherApp() {
};
}
const openEnabled = app.hasAccess && app.status === "active";
const appVisible = app.status !== "hidden" && app.status !== "disabled";
const allowed = app.hasAccess && appVisible && service.effectiveAccess.allowed;
const openEnabled = allowed && app.status === "active" && service.effectiveAccess.openEnabled;
return {
...service,
title: app.title || service.title,
description: app.description || service.description,
openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null,
userAccess: openEnabled ? ("allowed" as const) : ("denied" as const),
userAccess: allowed ? ("allowed" as const) : ("denied" as const),
effectiveAccess: {
...service.effectiveAccess,
allowed: app.hasAccess,
visible: true,
allowed,
visible: appVisible,
openEnabled,
reason: app.accessReason || (app.hasAccess ? "Доступ подтверждён" : "Нет доступа"),
reason: !app.hasAccess ? app.accessReason || "Нет доступа" : service.effectiveAccess.reason,
},
};
});
}).filter((service) => service.effectiveAccess.visible);
},
[authApps, authAppsBySlug, authSession, data, activeProfileId, resolvedClientId]
[authApps, authAppsBySlug, authSession, data, resolvedProfileId, resolvedClientId]
);
useEffect(() => {
@ -367,6 +378,13 @@ export function LauncherApp() {
void refreshTaskManagerWorkspaces();
}, [adminOpen, canOpenAdminApi]);
useEffect(() => {
if (runtimeMe.permissions.canOpenAdmin) return;
setAdminOpen(false);
setAdminMode("admin");
}, [runtimeMe.permissions.canOpenAdmin]);
const refreshRuntimeState = useCallback(async () => {
try {
const nextSession = await fetchAuthSession();
@ -378,18 +396,8 @@ export function LauncherApp() {
return;
}
const currentData = runtimeDataRef.current;
const nextContext = resolveAuthenticatedContext(
currentData,
nextSession,
runtimeProfileIdRef.current,
runtimeClientIdRef.current
);
const nextMe = buildMe(currentData, nextContext.profileId, nextContext.clientId);
const [persistedData, apps] = await Promise.all([
nextSession.isSuperAdmin || nextMe.permissions.canOpenAdmin
? fetchControlPlaneSnapshot().then((snapshot) => snapshot.data)
: loadPersistedLauncherData(),
loadPersistedLauncherData(),
fetchAvailableApps(),
]);
@ -451,6 +459,20 @@ export function LauncherApp() {
};
}, [authSession?.authenticated, refreshRuntimeState]);
useEffect(() => {
if (!authSession?.authenticated) return;
const intervalId = window.setInterval(() => {
if (document.visibilityState === "visible") {
void refreshRuntimeState();
}
}, 5000);
return () => {
window.clearInterval(intervalId);
};
}, [authSession?.authenticated, refreshRuntimeState]);
function handleProfileChange(userId: string) {
const profile = profileOptions.find((option) => option.userId === userId);
setActiveProfileId(userId);
@ -484,14 +506,16 @@ export function LauncherApp() {
});
}
function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>) {
request
.then((result) => {
setData(syncLauncherServiceLinks(result.data));
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию");
});
async function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>): Promise<ControlPlaneMutationOutcome> {
try {
const result = await request;
setData(syncLauncherServiceLinks(result.data));
return { ok: true, data: result.data };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Не удалось выполнить admin API операцию";
console.warn(message);
return { ok: false, message };
}
}
async function refreshTaskManagerWorkspaces() {
@ -531,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"}`;
@ -691,11 +738,11 @@ export function LauncherApp() {
}
function handleUpdateSettings(patch: Partial<LauncherSettings>) {
applyControlPlaneMutation(updateAdminSettings(patch));
return applyControlPlaneMutation(updateAdminSettings(patch));
}
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
applyControlPlaneMutation(updateAdminService(serviceId, patch));
return applyControlPlaneMutation(updateAdminService(serviceId, patch));
}
function handleCreateClient() {
@ -799,7 +846,7 @@ export function LauncherApp() {
return (
<AccessRequestScreen
onSubmit={createAccessRequest}
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)}
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl, { returnTo: "/" })}
/>
);
}
@ -846,7 +893,7 @@ export function LauncherApp() {
me={runtimeMe}
clients={data.clients}
profileOptions={profileOptions}
activeProfileId={activeProfileId}
activeProfileId={resolvedProfileId}
activeClientId={resolvedClientId}
adminOpen={adminOpen}
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
@ -901,6 +948,7 @@ export function LauncherApp() {
onUpdateMembership={handleUpdateMembership}
onDeleteMembership={handleDeleteMembership}
pendingAccessAssignments={pendingAccessAssignments}
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
onCreateGroup={handleCreateGroup}
onUpdateGroup={handleUpdateGroup}
onDeleteGroup={handleDeleteGroup}
@ -917,6 +965,7 @@ export function LauncherApp() {
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
/>
) : null}
{profileSettingsOpen && activeProfileUser ? (
@ -1191,6 +1240,28 @@ function resolveAuthenticatedContext(
};
}
function resolveRuntimeProfileId(data: LauncherData, session: AuthSession | null, currentProfileId: string): string {
if (data.users.some((user) => user.id === currentProfileId)) {
return currentProfileId;
}
if (session?.authenticated) {
const sessionEmail = session.user.email?.toLowerCase();
const sessionSub = session.user.sub;
const sessionUser = data.users.find(
(user) =>
(sessionSub && user.authentikUserId === sessionSub) ||
(sessionEmail && user.email.toLowerCase() === sessionEmail)
);
if (sessionUser) {
return sessionUser.id;
}
}
return data.users[0]?.id ?? currentProfileId;
}
function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string {
const user = data.users.find((item) => item.id === userId);
const isRoot = user?.id === "user_root";
@ -1519,22 +1590,26 @@ function membershipRoleLabel(role: ClientMembership["role"]) {
}[role];
}
function buildLoginRedirectUrl(loginUrl?: string) {
function buildLoginRedirectUrl(loginUrl?: string, options: { returnTo?: string | null } = {}) {
const url = new URL(loginUrl || "/auth/login", window.location.origin);
if (!url.searchParams.has("returnTo")) {
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (options.returnTo === null) {
url.searchParams.delete("returnTo");
} else if (!url.searchParams.has("returnTo")) {
const returnTo = options.returnTo ?? `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (returnTo && returnTo !== "/") {
url.searchParams.set("returnTo", returnTo);
} else if (options.returnTo === "/") {
url.searchParams.set("returnTo", "/");
}
}
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : url.toString();
}
function redirectToLogin(loginUrl?: string) {
const redirectUrl = buildLoginRedirectUrl(loginUrl);
function redirectToLogin(loginUrl?: string, options?: { returnTo?: string | null }) {
const redirectUrl = buildLoginRedirectUrl(loginUrl, options);
const now = Date.now();
if (lastAuthRedirect && now - lastAuthRedirect.startedAt < 1500) {

View File

@ -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;
}

View File

@ -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",

View File

@ -45,7 +45,7 @@ export interface LauncherAuthApp {
export async function fetchAuthSession(): Promise<AuthSession> {
const response = await fetch("/api/me", { cache: "no-store" });
if (response.status === 401) {
if (response.status === 401 || response.status === 403) {
return (await response.json()) as UnauthenticatedSession;
}

View File

@ -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 : [],
@ -261,7 +263,9 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?:
}));
const fallbackClientId =
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ?? availableMemberships[0]?.clientId;
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ??
availableMemberships[0]?.clientId ??
PUBLIC_POOL_CLIENT.id;
const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);

View File

@ -30,11 +30,13 @@ export async function uploadStorageFile(file: File): Promise<StoredFileResponse>
export async function loadPersistedLauncherData(): Promise<LauncherData | null> {
try {
const response = await fetch(`/storage/launcher-data.json?ts=${Date.now()}`, { cache: "no-store" });
const response = await fetch("/api/storage/data", { cache: "no-store" });
if (!response.ok) return null;
return normalizeLauncherData((await response.json()) as Partial<LauncherData>);
const data = normalizeLauncherData((await response.json()) as Partial<LauncherData>);
return data.users.length > 0 ? data : null;
} catch {
return null;
}

View File

@ -2259,15 +2259,20 @@ code {
background: var(--access-matrix-table-bg) !important;
}
.table-shell--users {
.table-shell--users,
.table-shell--sticky-user-column {
position: relative;
--admin-users-table-bg: rgb(20, 20, 22);
margin-top: 1rem;
padding: 0 0 1rem;
background: var(--admin-users-table-bg) !important;
}
.table-shell--users .table-toolbar {
.table-shell--users {
margin-top: 1rem;
}
.table-shell--users .table-toolbar,
.table-shell--sticky-user-column .table-toolbar {
position: sticky;
left: 0;
z-index: 6;
@ -2278,7 +2283,8 @@ code {
background: var(--admin-users-table-bg);
}
.table-shell--users .admin-data-table {
.table-shell--users .admin-data-table,
.table-shell--sticky-user-column .admin-data-table {
margin: 0;
}
@ -2288,12 +2294,16 @@ code {
}
.admin-data-table--users th,
.admin-data-table--users td {
.admin-data-table--users td,
.admin-data-table--sticky-user-column th,
.admin-data-table--sticky-user-column td {
white-space: nowrap;
}
.admin-data-table--users th:nth-child(1),
.admin-data-table--users td:nth-child(1) {
.admin-data-table--users td:nth-child(1),
.admin-data-table--sticky-user-column th:nth-child(1),
.admin-data-table--sticky-user-column td:nth-child(1) {
position: sticky;
left: 0;
z-index: 3;
@ -2302,7 +2312,8 @@ code {
background: var(--admin-users-table-bg);
}
.admin-data-table--users th:nth-child(1) {
.admin-data-table--users th:nth-child(1),
.admin-data-table--sticky-user-column th:nth-child(1) {
z-index: 4;
}
@ -2345,6 +2356,12 @@ code {
table-layout: fixed;
}
.admin-data-table--platform-users th:nth-child(1),
.admin-data-table--platform-users td:nth-child(1) {
width: 18rem;
min-width: 18rem;
}
.admin-data-table--platform-users th,
.admin-data-table--platform-users td {
white-space: nowrap;
@ -3682,7 +3699,7 @@ code {
}
.access-cell-menu {
min-width: 10.75rem;
min-width: 20rem;
}
.access-cell-menu .nodedc-ui-option[data-tone="green"][data-selected="true"] {
@ -3824,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;
@ -4220,6 +4351,17 @@ code {
max-width: 44rem;
}
.admin-settings-save-message {
margin: -0.35rem 0 1rem;
color: rgba(135, 255, 190, 0.92);
font-size: 0.76rem;
font-weight: 750;
}
.admin-settings-save-message--error {
color: #ffd0d0;
}
.admin-settings-grid {
display: grid;
gap: 1rem;

View File

@ -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";
@ -90,6 +90,7 @@ type AdminSection =
| "misc"
| "company";
type AdminOverlayMode = "admin" | "platform";
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
@ -101,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;
@ -179,6 +188,7 @@ export function AdminOverlay({
onUpdateMembership,
onDeleteMembership,
pendingAccessAssignments,
pendingServiceModuleEntitlements,
onCreateGroup,
onUpdateGroup,
onDeleteGroup,
@ -195,6 +205,7 @@ export function AdminOverlay({
onRefreshTaskManagerWorkspaces,
onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole,
onSetServiceModuleEntitlement,
}: {
data: LauncherData;
me: MeResponse;
@ -226,14 +237,15 @@ 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;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
onDeleteService: (serviceId: string) => void;
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
onUpdateSettings: (patch: Partial<LauncherSettings>) => Promise<AdminMutationOutcome>;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null;
@ -242,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";
@ -509,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}
@ -516,6 +530,7 @@ export function AdminOverlay({
taskManagerWorkspaceCatalog={taskManagerWorkspaces}
onSetTaskManagerWorkspaceMemberRole={onSetTaskManagerWorkspaceMemberRole}
onSetTaskManagerProjectMemberRole={onSetTaskManagerProjectMemberRole}
onSetServiceModuleEntitlement={onSetServiceModuleEntitlement}
/>
) : null}
{activeSection === "invites" ? (
@ -1040,7 +1055,7 @@ function PlatformUsersSection({
return (
<>
<GlassSurface className="table-shell table-shell--platform-users">
<GlassSurface className="table-shell table-shell--sticky-user-column table-shell--platform-users">
<div className="table-toolbar">
<div>
<h3>Пользователи платформы</h3>
@ -1049,7 +1064,7 @@ function PlatformUsersSection({
</p>
</div>
</div>
<table className="admin-data-table admin-data-table--platform-users">
<table className="admin-data-table admin-data-table--sticky-user-column admin-data-table--platform-users">
<thead>
<tr>
<th>Пользователь</th>
@ -1346,8 +1361,8 @@ const accessAssignmentOptions: Array<NodeDcSelectOption<AccessAssignmentValue>>
];
const publicOperationalCoreAccessOptions: Array<NodeDcSelectOption<AccessAssignmentValue>> = [
{ value: "unset", label: "—", description: "Не назначен" },
{ value: "viewer", label: "Workspace Guest", description: "Доступ к приглашённому workspace", tone: "green" },
{ value: "unset", label: "—", description: "Не назначен", hidden: true },
{ value: "viewer", label: "Workspace Guest", description: "Выдаётся только через Tasker", tone: "green", hidden: true },
{ value: "member", label: "Workspace Member", description: "Доступ к приглашённому workspace", tone: "green" },
{ value: "admin", label: "Service Admin", description: "Self-service", tone: "green" },
{ value: "deny", label: "Заблокирован", description: "Запрет доступа", tone: "red" },
@ -1366,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;
}
@ -1546,7 +1577,7 @@ function ServicesSection({
}: {
data: LauncherData;
isPublicPoolContext: boolean;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void;
onDeleteService: (serviceId: string) => void;
@ -1653,9 +1684,12 @@ function ServicesSection({
<ServiceContentModal
service={contentService}
onClose={() => setContentServiceId(null)}
onSave={(patch) => {
onUpdateService(contentService.id, patch);
setContentServiceId(null);
onSave={async (patch) => {
const result = await onUpdateService(contentService.id, patch);
if (result.ok) {
setContentServiceId(null);
}
return result;
}}
onDelete={() => {
onDeleteService(contentService.id);
@ -1687,7 +1721,7 @@ function SortableServiceRow({
onOpenContent,
}: {
service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onOpenContent: () => void;
}) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id });
@ -1719,7 +1753,7 @@ function ServiceTableCells({
setDragHandleRef,
}: {
service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onOpenContent: () => void;
dragAttributes?: DraggableAttributes;
dragListeners?: DraggableSyntheticListeners;
@ -1927,7 +1961,7 @@ function ServiceContentModal({
}: {
service: Service;
onClose: () => void;
onSave: (patch: Partial<Service>) => void;
onSave: (patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onDelete: () => void;
}) {
const [draft, setDraft] = useState<Service>(service);
@ -1937,6 +1971,8 @@ function ServiceContentModal({
});
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
@ -1946,6 +1982,8 @@ function ServiceContentModal({
ambient: service.ambientVideoUrl ?? null,
});
setStorageError(null);
setSaveError(null);
setIsSaving(false);
setUploadingSlot(null);
}, [service]);
@ -2006,6 +2044,33 @@ function ServiceContentModal({
}
}
async function handleSave() {
setSaveError(null);
setIsSaving(true);
const result = await onSave({
title: draft.title,
subtitle: draft.subtitle,
description: draft.description,
fullDescription: draft.fullDescription,
url: draft.url,
launchUrl: draft.launchUrl,
coverImageUrl: draft.coverImageUrl,
coverMediaKind: draft.coverMediaKind,
coverMediaSource: draft.coverMediaSource,
coverMediaFileName: draft.coverMediaFileName,
ambientVideoUrl: draft.ambientVideoUrl,
ambientMediaKind: draft.ambientMediaKind,
ambientMediaSource: draft.ambientMediaSource,
ambientMediaFileName: draft.ambientMediaFileName,
});
if (!result.ok) {
setSaveError(result.message);
setIsSaving(false);
}
}
return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Контент витрины ${service.title}`}>
<article className="service-content-modal">
@ -2091,6 +2156,7 @@ function ServiceContentModal({
/>
{storageError ? <p className="service-content-storage-error">{storageError}</p> : null}
{saveError ? <p className="service-content-storage-error">{saveError}</p> : null}
</div>
<div className="service-content-modal__foot">
@ -2106,28 +2172,11 @@ function ServiceContentModal({
surface="modal"
accentRgb={modalActionAccentRgb}
type="button"
disabled={uploadingSlot !== null}
disabled={uploadingSlot !== null || isSaving}
icon={<Save size={16} />}
onClick={() =>
onSave({
title: draft.title,
subtitle: draft.subtitle,
description: draft.description,
fullDescription: draft.fullDescription,
url: draft.url,
launchUrl: draft.launchUrl,
coverImageUrl: draft.coverImageUrl,
coverMediaKind: draft.coverMediaKind,
coverMediaSource: draft.coverMediaSource,
coverMediaFileName: draft.coverMediaFileName,
ambientVideoUrl: draft.ambientVideoUrl,
ambientMediaKind: draft.ambientMediaKind,
ambientMediaSource: draft.ambientMediaSource,
ambientMediaFileName: draft.ambientMediaFileName,
})
}
onClick={handleSave}
>
{uploadingSlot ? "Сохраняем файл" : "Сохранить"}
{uploadingSlot ? "Сохраняем файл" : isSaving ? "Сохраняем" : "Сохранить"}
</Button>
</div>
</div>
@ -2855,6 +2904,7 @@ function AccessSection({
onSelectCell,
onSetUserServiceAccess,
pendingAccessAssignments,
pendingServiceModuleEntitlements,
onUpdateUser,
onUpdateMembership,
pendingTaskManagerMemberships,
@ -2862,6 +2912,7 @@ function AccessSection({
taskManagerWorkspaceCatalog,
onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole,
onSetServiceModuleEntitlement,
}: {
data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>;
@ -2869,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>;
@ -2876,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);
@ -2991,7 +3044,7 @@ function AccessSection({
role: accessAssignmentToTaskManagerRole(nextValue),
});
}}
onOpenDetails={isTaskManagerService && !usePublicTaskerAccess ? () => setDetailsCell(cell) : undefined}
onOpenDetails={isTaskManagerService ? () => setDetailsCell(cell) : undefined}
/>
</div>
);
@ -3010,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>
@ -3032,8 +3088,10 @@ function PublicAccessUsersPanel({
onSelectCell,
onSetUserServiceAccess,
pendingAccessAssignments,
pendingServiceModuleEntitlements,
onUpdateUser,
onUpdateMembership,
onSetServiceModuleEntitlement,
}: {
data: LauncherData;
matrix: ReturnType<typeof buildAccessMatrix>;
@ -3041,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">
@ -3149,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>
@ -3166,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>
);
}
@ -3196,7 +3274,7 @@ function MainStatusControl({
value={value}
options={mainStatusOptions}
label="MAIN статус"
minMenuWidth={172}
minMenuWidth={320}
menuClassName="access-cell-menu"
onChange={onChange}
trigger={({ open, toggle, setTriggerRef, selectedOption }) => (
@ -3261,13 +3339,16 @@ function OperationalCoreAccessModal({
cell,
workspaces,
workspaceCatalog,
publicSelfService = false,
pendingAccessAssignments,
pendingTaskManagerMemberships,
pendingTaskManagerProjectMemberships,
pendingServiceModuleEntitlements,
onClose,
onSetUserServiceAccess,
onSetTaskManagerWorkspaceMemberRole,
onSetTaskManagerProjectMemberRole,
onSetServiceModuleEntitlement,
}: {
data: LauncherData;
client: Client;
@ -3276,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}`}>
@ -3300,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);
@ -3412,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">
@ -4408,12 +4572,14 @@ function MiscSection({
onUpdateSettings,
}: {
data: LauncherData;
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
onUpdateSettings: (patch: Partial<LauncherSettings>) => Promise<AdminMutationOutcome>;
}) {
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState(
data.settings.taskManager.workspaceCreationPolicy
);
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [saveMessage, setSaveMessage] = useState<string | null>(null);
useEffect(() => {
setLogoLinkUrl(data.settings.brand.logoLinkUrl);
@ -4425,6 +4591,25 @@ function MiscSection({
normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl ||
workspaceCreationPolicy !== data.settings.taskManager.workspaceCreationPolicy;
async function handleSave() {
setSaveState("saving");
setSaveMessage(null);
const result = await onUpdateSettings({
brand: { logoLinkUrl: normalizedLogoLinkUrl },
taskManager: { workspaceCreationPolicy },
});
if (result.ok) {
setSaveState("saved");
setSaveMessage("Сохранено");
return;
}
setSaveState("error");
setSaveMessage(result.message);
}
return (
<GlassSurface className="table-shell admin-settings-panel">
<div className="table-toolbar">
@ -4438,17 +4623,17 @@ function MiscSection({
variant="accent"
type="button"
icon={<Save size={16} />}
disabled={!hasChanges}
onClick={() =>
onUpdateSettings({
brand: { logoLinkUrl: normalizedLogoLinkUrl },
taskManager: { workspaceCreationPolicy },
})
}
disabled={!hasChanges || saveState === "saving"}
onClick={handleSave}
>
Сохранить
{saveState === "saving" ? "Сохраняем" : "Сохранить"}
</Button>
</div>
{saveMessage ? (
<p className={cn("admin-settings-save-message", saveState === "error" && "admin-settings-save-message--error")}>
{saveMessage}
</p>
) : null}
<div className="admin-settings-grid">
<label className="admin-settings-field">
@ -4457,7 +4642,11 @@ function MiscSection({
className="admin-table-input admin-settings-field__input"
value={logoLinkUrl}
placeholder="/"
onChange={(event) => setLogoLinkUrl(event.target.value)}
onChange={(event) => {
setLogoLinkUrl(event.target.value);
setSaveState("idle");
setSaveMessage(null);
}}
/>
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
</label>
@ -4469,7 +4658,11 @@ function MiscSection({
value={workspaceCreationPolicy}
options={taskManagerWorkspacePolicyOptions}
label="Политика создания workspace в Operational Core"
onChange={(value) => setWorkspaceCreationPolicy(value)}
onChange={(value) => {
setWorkspaceCreationPolicy(value);
setSaveState("idle");
setSaveMessage(null);
}}
/>
<small>Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.</small>
</label>
@ -4605,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";
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { KeyRound, Save, Upload, X } from "lucide-react";
import type { LauncherUser } from "../../entities/user/types";
import { uploadStorageFile } from "../../shared/api/storageApi";
@ -17,13 +17,42 @@ export function ProfileSettingsPanel({
onChangePassword: (newPassword: string) => Promise<void>;
}) {
const [draft, setDraft] = useState<LauncherUser>(user);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(user.avatarUrl ?? null);
const [newPassword, setNewPassword] = useState("");
const [uploading, setUploading] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const draftRef = useRef(draft);
const hasUnsavedProfileChangesRef = useRef(false);
const lastSyncedUserIdRef = useRef(user.id);
useEffect(() => setDraft(user), [user]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
useEffect(() => {
const isAnotherUser = lastSyncedUserIdRef.current !== user.id;
if (!isAnotherUser && hasUnsavedProfileChangesRef.current) {
return;
}
lastSyncedUserIdRef.current = user.id;
hasUnsavedProfileChangesRef.current = false;
setDraft(user);
setAvatarPreviewUrl(user.avatarUrl ?? null);
setUploading(false);
}, [user]);
useEffect(
() => () => {
if (avatarPreviewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(avatarPreviewUrl);
}
},
[avatarPreviewUrl]
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@ -35,12 +64,20 @@ export function ProfileSettingsPanel({
}, [onClose]);
function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
setDraft((current) => ({ ...current, [key]: value }));
hasUnsavedProfileChangesRef.current = true;
setDraft((current) => {
const nextDraft = { ...current, [key]: value };
draftRef.current = nextDraft;
return nextDraft;
});
}
async function handleAvatarUpload(file: File | undefined) {
if (!file) return;
const localPreviewUrl = URL.createObjectURL(file);
hasUnsavedProfileChangesRef.current = true;
setAvatarPreviewUrl(localPreviewUrl);
setUploading(true);
setMessage(null);
@ -49,6 +86,7 @@ export function ProfileSettingsPanel({
update("avatarUrl", result.url);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
setAvatarPreviewUrl(draftRef.current.avatarUrl ?? null);
} finally {
setUploading(false);
}
@ -59,13 +97,16 @@ export function ProfileSettingsPanel({
setMessage(null);
try {
const profileDraft = draftRef.current;
await onSaveProfile({
name: draft.name,
email: draft.email,
phone: draft.phone ?? null,
position: draft.position ?? null,
avatarUrl: draft.avatarUrl ?? null,
name: profileDraft.name,
email: profileDraft.email,
phone: profileDraft.phone ?? null,
position: profileDraft.position ?? null,
avatarUrl: profileDraft.avatarUrl ?? null,
});
hasUnsavedProfileChangesRef.current = false;
setAvatarPreviewUrl(profileDraft.avatarUrl ?? null);
setMessage("Профиль сохранён");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
@ -109,8 +150,8 @@ export function ProfileSettingsPanel({
<div className="profile-settings-panel__body">
<div className="profile-settings-avatar-card">
{draft.avatarUrl ? (
<img className="profile-settings-avatar-card__image" src={draft.avatarUrl} alt="" />
{avatarPreviewUrl ? (
<img className="profile-settings-avatar-card__image" src={avatarPreviewUrl} alt="" />
) : (
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
)}
@ -121,7 +162,11 @@ export function ProfileSettingsPanel({
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={uploading}
onChange={(event) => void handleAvatarUpload(event.target.files?.[0])}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void handleAvatarUpload(file);
}}
/>
</label>
</div>

View File

@ -53,6 +53,7 @@ export function TopBar({
description: client.legalName ?? undefined,
}));
const canOpenPlatform = me.launcherRole === "root_admin";
const showLauncherNavigation = me.permissions.canOpenAdmin || canOpenPlatform;
return (
<header className="nodedc-expanded-toolbar-shell">
@ -65,55 +66,59 @@ export function TopBar({
</div>
<div className="nodedc-expanded-toolbar-center">
<NodeDcSelect
value={activeClientId}
options={clientOptions}
label="Выбрать клиента"
searchable
minMenuWidth={248}
onChange={(clientId) => onClientChange(clientId)}
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="nodedc-expanded-workspace-button"
title={activeClient?.name ?? "Клиент"}
type="button"
aria-label="Выбрать клиента"
aria-expanded={open}
onClick={toggle}
>
{activeClient?.avatarUrl ? <img src={activeClient.avatarUrl} alt="" className="nodedc-expanded-workspace-avatar" /> : null}
</button>
)}
/>
{showLauncherNavigation ? (
<>
<NodeDcSelect
value={activeClientId}
options={clientOptions}
label="Выбрать клиента"
searchable
minMenuWidth={248}
onChange={(clientId) => onClientChange(clientId)}
trigger={({ open, toggle, setTriggerRef }) => (
<button
ref={setTriggerRef}
className="nodedc-expanded-workspace-button"
title={activeClient?.name ?? "Клиент"}
type="button"
aria-label="Выбрать клиента"
aria-expanded={open}
onClick={toggle}
>
{activeClient?.avatarUrl ? <img src={activeClient.avatarUrl} alt="" className="nodedc-expanded-workspace-avatar" /> : null}
</button>
)}
/>
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
<span>Витрина</span>
</button>
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
<span>Витрина</span>
</button>
{me.permissions.canOpenAdmin ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "admin"}
onClick={onOpenAdmin}
>
<span>Администрирование</span>
</button>
) : null}
{me.permissions.canOpenAdmin ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "admin"}
onClick={onOpenAdmin}
>
<span>Администрирование</span>
</button>
) : null}
{canOpenPlatform ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "platform"}
onClick={onOpenPlatform}
>
<span>Платформа</span>
</button>
) : null}
</nav>
{canOpenPlatform ? (
<button
className="nodedc-expanded-nav-button"
type="button"
data-active={adminOpen && adminMode === "platform"}
onClick={onOpenPlatform}
>
<span>Платформа</span>
</button>
) : null}
</nav>
</>
) : null}
</div>
<div className="nodedc-expanded-toolbar-right">