SECURITY - LAUNCHER: harden storage and access lifecycle
This commit is contained in:
parent
2b34cf9f1b
commit
b3915a851c
|
|
@ -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/
|
||||
|
|
|
|||
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",
|
||||
|
|
@ -62,16 +62,18 @@ 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");
|
||||
|
||||
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) {
|
||||
|
|
@ -1192,6 +1194,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
throw new Error("Этот инвайт выписан на другую почту");
|
||||
}
|
||||
|
||||
if (invite.status === "accepted") {
|
||||
throw new Error("Инвайт уже принят");
|
||||
}
|
||||
|
||||
if (invite.status === "revoked") {
|
||||
throw new Error("Инвайт отозван");
|
||||
}
|
||||
|
|
@ -1711,13 +1717,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) {
|
||||
|
|
@ -1799,6 +1799,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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -249,7 +249,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 +275,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) => {
|
||||
|
|
@ -456,9 +470,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;
|
||||
}
|
||||
|
|
@ -518,9 +543,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 +655,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 +694,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");
|
||||
|
|
@ -1019,8 +1072,10 @@ app.patch("/api/admin/users/:userId/profile", requireLauncherAdmin, asyncRoute(a
|
|||
|
||||
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);
|
||||
publishControlPlaneEvent("admin.user.updated", syncResult.userIds);
|
||||
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||
res.json({ ...scopeAdminMutationResult(req, { ...result, data: syncResult.data }), taskManagerProfile });
|
||||
}));
|
||||
|
||||
app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||
|
|
@ -1038,9 +1093,10 @@ app.delete("/api/admin/users/:userId", requireLauncherAdmin, requireRootLauncher
|
|||
authentik = await authentikSyncClient.deleteUser({ data: snapshot.data, userId: req.params.userId });
|
||||
}
|
||||
|
||||
const taskManagerCleanup = await cleanupTaskManagerUserAccess(user);
|
||||
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) => {
|
||||
|
|
@ -1371,7 +1427,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,9 +1435,14 @@ 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 });
|
||||
}));
|
||||
|
||||
app.get("/storage/launcher-data.json", (_req, res) => {
|
||||
setNoStore(res);
|
||||
res.status(404).json({ error: "not_found" });
|
||||
});
|
||||
|
||||
const vite = await createViteServer({
|
||||
root: projectRoot,
|
||||
appType: "spa",
|
||||
|
|
@ -1433,7 +1494,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 ??
|
||||
|
|
@ -1771,7 +1831,15 @@ function getRuntimeSessionContext(session) {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
const groups = resolveRequiredGroups(snapshot.data, user);
|
||||
return buildRuntimeSessionContext(session, snapshot.data, user);
|
||||
} catch (error) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRuntimeSessionContext(session, data, user) {
|
||||
const groups = resolveRequiredGroups(data, user);
|
||||
|
||||
return {
|
||||
groups,
|
||||
|
|
@ -1783,14 +1851,58 @@ function getRuntimeSessionContext(session) {
|
|||
groups,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getAppsForSession(session) {
|
||||
return getAppsForUser(getRuntimeSessionContext(session).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) {
|
||||
|
|
@ -1809,7 +1921,7 @@ function getAppsForUser(userGroups) {
|
|||
hasAccess,
|
||||
accessReason: hasAccess ? "Доступ подтверждён" : "Нет доступа",
|
||||
};
|
||||
});
|
||||
}).filter((app) => app.hasAccess);
|
||||
}
|
||||
|
||||
function getAppCatalog() {
|
||||
|
|
@ -1904,6 +2016,54 @@ async function requestTaskManagerInternalJson(pathname, init = {}) {
|
|||
return payload;
|
||||
}
|
||||
|
||||
async function syncTaskManagerUserProfile(user) {
|
||||
if (!user?.email || !config.internalAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestTaskManagerInternalJson("/api/internal/nodedc/users/profile-sync/", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: user.email,
|
||||
subject: user.authentikUserId ?? undefined,
|
||||
name: user.name,
|
||||
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) {
|
||||
if (!user?.email || !config.internalAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestTaskManagerInternalJson("/api/internal/nodedc/logout/", {
|
||||
method: "POST",
|
||||
body: {
|
||||
source: "launcher-user-hard-delete",
|
||||
subject: user.authentikUserId ?? undefined,
|
||||
email: user.email,
|
||||
revokeIdentityLinks: true,
|
||||
revokeTaskerAccess: true,
|
||||
},
|
||||
});
|
||||
} 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);
|
||||
|
|
@ -2037,7 +2197,7 @@ function pruneExpiredServiceHandoffs() {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user) {
|
||||
function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, user, workspaceSlug = null) {
|
||||
const mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
|
||||
const groupSet = new Set(groups);
|
||||
const isSuperAdmin = groupSet.has("nodedc:superadmin");
|
||||
|
|
@ -2049,6 +2209,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 {
|
||||
|
|
@ -2077,6 +2241,32 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
|||
}
|
||||
|
||||
if (hasLauncherManagedWorkspace && !isSuperAdmin) {
|
||||
if (workspaceAssignment?.managedBy === "launcher") {
|
||||
return {
|
||||
mode,
|
||||
managedBy: "launcher",
|
||||
defaultManagedBy: "launcher",
|
||||
inviteApproval: "launcher",
|
||||
defaultInviteApproval: "launcher",
|
||||
workspaces,
|
||||
canCreateWorkspace: false,
|
||||
reason: "Рабочие пространства этого пользователя управляются через Launcher.",
|
||||
};
|
||||
}
|
||||
|
||||
if (workspaceSlug) {
|
||||
return {
|
||||
mode,
|
||||
managedBy: "tasker",
|
||||
defaultManagedBy: "launcher",
|
||||
inviteApproval: defaultInviteApproval,
|
||||
defaultInviteApproval,
|
||||
workspaces,
|
||||
canCreateWorkspace: false,
|
||||
reason: "Self-service workspace работает через Tasker, approve инвайтов выполняет Launcher.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
managedBy: "launcher",
|
||||
|
|
@ -2306,11 +2496,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 +2620,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 +2703,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 +2740,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();
|
||||
}
|
||||
|
|
@ -2761,6 +2977,64 @@ 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: [],
|
||||
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),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -108,15 +108,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 +154,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) => {
|
||||
|
|
@ -167,32 +171,34 @@ export function LauncherApp() {
|
|||
effectiveAccess: {
|
||||
...service.effectiveAccess,
|
||||
allowed: false,
|
||||
visible: true,
|
||||
visible: false,
|
||||
openEnabled: false,
|
||||
reason: "Нет доступа",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const openEnabled = app.hasAccess && app.status === "active";
|
||||
const appVisible = app.hasAccess && app.status !== "hidden" && app.status !== "disabled";
|
||||
const allowed = appVisible && service.effectiveAccess.allowed;
|
||||
const openEnabled = appVisible && 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 && service.effectiveAccess.visible,
|
||||
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(() => {
|
||||
|
|
@ -846,7 +852,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"}
|
||||
|
|
@ -1191,6 +1197,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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -261,7 +261,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue