Compare commits
15 Commits
2b34cf9f1b
...
39843b7737
| Author | SHA1 | Date |
|---|---|---|
|
|
39843b7737 | |
|
|
17406d570f | |
|
|
0782e13c77 | |
|
|
34917e007a | |
|
|
5e18a7f3c2 | |
|
|
dc5f36e5f4 | |
|
|
712e9224c0 | |
|
|
5898b94875 | |
|
|
784195f747 | |
|
|
0ba6dc7115 | |
|
|
6e47e12f2d | |
|
|
b698741687 | |
|
|
95225280e7 | |
|
|
06a6160a46 | |
|
|
b3915a851c |
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -36,3 +36,17 @@ export interface EffectiveAccessResult {
|
|||
source?: ServiceGrantTargetType | "exception";
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
export type ServiceModuleId = "codex_agents";
|
||||
|
||||
export interface ServiceModuleEntitlement {
|
||||
id: string;
|
||||
clientId: string;
|
||||
serviceId: string;
|
||||
userId: string;
|
||||
moduleId: ServiceModuleId;
|
||||
enabled: boolean;
|
||||
createdByUserId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AccessRequest } from "../../entities/access-request/types";
|
||||
import type { ServiceAccessException, ServiceAppRole, ServiceGrant } from "../../entities/access/types";
|
||||
import type { ServiceAccessException, ServiceAppRole, ServiceGrant, ServiceModuleEntitlement, ServiceModuleId } from "../../entities/access/types";
|
||||
import type { Client, TaskManagerWorkspaceManagedBy } from "../../entities/client/types";
|
||||
import type { Invite } from "../../entities/invite/types";
|
||||
import type { Service } from "../../entities/service/types";
|
||||
|
|
@ -404,6 +404,19 @@ export async function setAdminUserServiceAccess(payload: {
|
|||
});
|
||||
}
|
||||
|
||||
export async function setAdminServiceModuleEntitlement(payload: {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
serviceId: string;
|
||||
moduleId: ServiceModuleId;
|
||||
enabled: boolean;
|
||||
}): Promise<ControlPlaneMutationResult & { entitlement?: ServiceModuleEntitlement | null }> {
|
||||
return requestJson<ControlPlaneMutationResult & { entitlement?: ServiceModuleEntitlement | null }>("/api/admin/access/service-modules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertAdminGrant(payload: Partial<ServiceGrant>): Promise<ControlPlaneMutationResult> {
|
||||
return requestJson<ControlPlaneMutationResult>("/api/admin/access/grants", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue