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
|
PORT=5173
|
||||||
LAUNCHER_BASE_URL=http://launcher.local.nodedc
|
LAUNCHER_BASE_URL=http://launcher.local.nodedc
|
||||||
TASK_BASE_URL=http://task.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*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
server/storage/*
|
||||||
|
!server/storage/.gitkeep
|
||||||
|
public/storage/launcher-data.json
|
||||||
|
dist/storage/launcher-data.json
|
||||||
|
public/storage/uploads/
|
||||||
public/storage/backups/
|
public/storage/backups/
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,14 +1,15 @@
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
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";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
const publicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
const serverStorageRoot = resolveStorageRoot(projectRoot);
|
||||||
const distDataPath = join(projectRoot, "dist", "storage", "launcher-data.json");
|
const dataPath = join(serverStorageRoot, "launcher-data.json");
|
||||||
|
const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
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 services = Array.isArray(existingData.services) ? existingData.services : [];
|
||||||
const existingUsersByEmail = new Map(
|
const existingUsersByEmail = new Map(
|
||||||
(Array.isArray(existingData.users) ? existingData.users : []).map((user) => [String(user.email || "").toLowerCase(), user])
|
(Array.isArray(existingData.users) ? existingData.users : []).map((user) => [String(user.email || "").toLowerCase(), user])
|
||||||
|
|
@ -214,11 +215,7 @@ const liveData = {
|
||||||
services,
|
services,
|
||||||
};
|
};
|
||||||
|
|
||||||
await writeJson(publicDataPath, liveData);
|
await writeJson(dataPath, liveData);
|
||||||
|
|
||||||
if (existsSync(join(projectRoot, "dist"))) {
|
|
||||||
await writeJson(distDataPath, liveData);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Seeded ${liveData.users.length} users, ${liveData.clients.length} client, ${liveData.groups.length} groups.`);
|
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 mkdir(dirname(path), { recursive: true });
|
||||||
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
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 { randomUUID } from "node:crypto";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
const collectionKeys = [
|
const collectionKeys = [
|
||||||
"clients",
|
"clients",
|
||||||
|
|
@ -62,16 +62,18 @@ const defaultSettings = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createControlPlaneStore({ projectRoot }) {
|
export function createControlPlaneStore({ projectRoot }) {
|
||||||
const publicStorageRoot = join(projectRoot, "public", "storage");
|
const serverStorageRoot = resolveStorageRoot(projectRoot);
|
||||||
const distStorageRoot = join(projectRoot, "dist", "storage");
|
const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
||||||
const dataPath = join(publicStorageRoot, "launcher-data.json");
|
const dataPath = join(serverStorageRoot, "launcher-data.json");
|
||||||
|
|
||||||
function readData() {
|
function readData() {
|
||||||
if (!existsSync(dataPath)) {
|
const readablePath = existsSync(dataPath) ? dataPath : legacyPublicDataPath;
|
||||||
|
|
||||||
|
if (!existsSync(readablePath)) {
|
||||||
return normalizeData({});
|
return normalizeData({});
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeData(JSON.parse(readFileSync(dataPath, "utf8")));
|
return normalizeData(JSON.parse(readFileSync(readablePath, "utf8")));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeData(data) {
|
async function writeData(data) {
|
||||||
|
|
@ -1192,6 +1194,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
throw new Error("Этот инвайт выписан на другую почту");
|
throw new Error("Этот инвайт выписан на другую почту");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invite.status === "accepted") {
|
||||||
|
throw new Error("Инвайт уже принят");
|
||||||
|
}
|
||||||
|
|
||||||
if (invite.status === "revoked") {
|
if (invite.status === "revoked") {
|
||||||
throw new Error("Инвайт отозван");
|
throw new Error("Инвайт отозван");
|
||||||
}
|
}
|
||||||
|
|
@ -1711,13 +1717,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWritableStorageRoots() {
|
function getWritableStorageRoots() {
|
||||||
const roots = [publicStorageRoot];
|
return [serverStorageRoot];
|
||||||
|
|
||||||
if (existsSync(join(projectRoot, "dist"))) {
|
|
||||||
roots.push(distStorageRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLoginAccountStatus(email) {
|
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) {
|
function normalizeData(payload) {
|
||||||
const data = typeof payload === "object" && payload !== null ? { ...payload } : {};
|
const data = typeof payload === "object" && payload !== null ? { ...payload } : {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,14 @@ app.get("/api/me", (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeContext = getRuntimeSessionContext(session);
|
const sessionAccess = getSessionAccessState(session);
|
||||||
|
|
||||||
|
if (!sessionAccess.ok) {
|
||||||
|
rejectInactiveSession(res, session, sessionAccess);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeContext = sessionAccess.runtimeContext;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|
@ -268,7 +275,14 @@ app.get("/api/apps", (req, res) => {
|
||||||
return;
|
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) => {
|
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 groups = resolveRequiredGroups(snapshot.data, inviter);
|
||||||
const app = getAppsForUser(groups).find((candidate) => candidate.slug === "task-manager");
|
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 });
|
res.status(403).json({ ok: false, error: "nodedc_tasker_invite_approval_not_allowed", workspacePolicy });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -518,9 +543,10 @@ app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
|
||||||
userId: actor.id,
|
userId: actor.id,
|
||||||
});
|
});
|
||||||
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
|
const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(actor.id, provisionedUser, req.nodedcSession.user);
|
||||||
|
const taskManagerProfile = await syncTaskManagerUserProfile(storeResult.user);
|
||||||
|
|
||||||
publishControlPlaneEvent("profile.updated", [actor.id]);
|
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) => {
|
app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => {
|
||||||
|
|
@ -629,7 +655,15 @@ app.get("/tasker-workspace-invite/:taskerInviteRequestId", (req, res) => {
|
||||||
return;
|
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
|
const request = controlPlaneStore
|
||||||
.getSnapshot({ name: "NODE.DC tasker invite redirect" })
|
.getSnapshot({ name: "NODE.DC tasker invite redirect" })
|
||||||
.data.taskerInviteRequests.find((candidate) => candidate.id === req.params.taskerInviteRequestId);
|
.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));
|
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) => {
|
app.patch("/api/admin/settings", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
|
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.settings.updated");
|
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 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 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);
|
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) => {
|
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 });
|
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);
|
const result = await controlPlaneStore.deleteUser(req.params.userId, req.nodedcSession.user);
|
||||||
publishControlPlaneEvent("admin.user.deleted", [req.params.userId]);
|
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) => {
|
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());
|
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);
|
const result = await saveUploadedFile(req.body);
|
||||||
res.json(result);
|
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) => {
|
app.post("/api/storage/data", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
|
||||||
await saveLauncherData(req.body);
|
await saveLauncherData(req.body);
|
||||||
publishControlPlaneEvent("storage.data.updated");
|
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({
|
const vite = await createViteServer({
|
||||||
root: projectRoot,
|
root: projectRoot,
|
||||||
appType: "spa",
|
appType: "spa",
|
||||||
|
|
@ -1433,7 +1494,6 @@ function readConfig() {
|
||||||
internalAccessToken:
|
internalAccessToken:
|
||||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ??
|
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ??
|
||||||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ??
|
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ??
|
||||||
process.env.PLANE_OIDC_CLIENT_SECRET ??
|
|
||||||
"",
|
"",
|
||||||
taskLogoutUrl:
|
taskLogoutUrl:
|
||||||
process.env.TASK_LOGOUT_URL ??
|
process.env.TASK_LOGOUT_URL ??
|
||||||
|
|
@ -1771,7 +1831,15 @@ function getRuntimeSessionContext(session) {
|
||||||
return fallback;
|
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 {
|
return {
|
||||||
groups,
|
groups,
|
||||||
|
|
@ -1783,14 +1851,58 @@ function getRuntimeSessionContext(session) {
|
||||||
groups,
|
groups,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.warn(error instanceof Error ? error.message : "Не удалось рассчитать runtime контекст Launcher");
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppsForSession(session) {
|
function getSessionAccessState(session) {
|
||||||
return getAppsForUser(getRuntimeSessionContext(session).groups);
|
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) {
|
function getAppsForUser(userGroups) {
|
||||||
|
|
@ -1809,7 +1921,7 @@ function getAppsForUser(userGroups) {
|
||||||
hasAccess,
|
hasAccess,
|
||||||
accessReason: hasAccess ? "Доступ подтверждён" : "Нет доступа",
|
accessReason: hasAccess ? "Доступ подтверждён" : "Нет доступа",
|
||||||
};
|
};
|
||||||
});
|
}).filter((app) => app.hasAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppCatalog() {
|
function getAppCatalog() {
|
||||||
|
|
@ -1904,6 +2016,54 @@ async function requestTaskManagerInternalJson(pathname, init = {}) {
|
||||||
return payload;
|
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) {
|
function parseJsonResponse(text, url) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
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 mode = data.settings?.taskManager?.workspaceCreationPolicy ?? "any_authorized_user";
|
||||||
const groupSet = new Set(groups);
|
const groupSet = new Set(groups);
|
||||||
const isSuperAdmin = groupSet.has("nodedc:superadmin");
|
const isSuperAdmin = groupSet.has("nodedc:superadmin");
|
||||||
|
|
@ -2049,6 +2209,10 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
||||||
);
|
);
|
||||||
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
|
const defaultManagedBy = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : "tasker";
|
||||||
const defaultInviteApproval = hasLauncherManagedWorkspace && !isSuperAdmin ? "launcher" : isPublicPoolUser ? "nodedc" : "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) {
|
if (!hasTaskManagerAccess) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -2077,6 +2241,32 @@ function resolveTaskManagerWorkspacePolicy(data, groups, hasTaskManagerAccess, u
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasLauncherManagedWorkspace && !isSuperAdmin) {
|
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 {
|
return {
|
||||||
mode,
|
mode,
|
||||||
managedBy: "launcher",
|
managedBy: "launcher",
|
||||||
|
|
@ -2306,11 +2496,8 @@ function getSessionSyncAllowedOrigins() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLauncherData() {
|
function readLauncherData() {
|
||||||
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!existsSync(dataPath)) return null;
|
return controlPlaneStore.readData();
|
||||||
return JSON.parse(readFileSync(dataPath, "utf8"));
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -2433,6 +2620,21 @@ function getCurrentSession(req) {
|
||||||
return session;
|
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() {
|
function pruneExpiredSessions() {
|
||||||
for (const [sessionId, session] of sessions) {
|
for (const [sessionId, session] of sessions) {
|
||||||
if (session.expiresAt < Date.now()) {
|
if (session.expiresAt < Date.now()) {
|
||||||
|
|
@ -2501,7 +2703,14 @@ function requireLauncherAdmin(req, res, next) {
|
||||||
return;
|
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);
|
const adminScope = resolveAdminScope(runtimeContext.user, runtimeContext.groups);
|
||||||
|
|
||||||
if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
|
if (!adminScope.isRoot && adminScope.clientIds.size === 0) {
|
||||||
|
|
@ -2531,7 +2740,14 @@ function requireSession(req, res, next) {
|
||||||
return;
|
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 };
|
req.nodedcSession = { ...session, user: runtimeContext.user };
|
||||||
next();
|
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) {
|
function cookieOptions(maxAgeMs) {
|
||||||
const options = {
|
const options = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|
|
||||||
|
|
@ -108,15 +108,19 @@ export function LauncherApp() {
|
||||||
const runtimeDataRef = useRef(data);
|
const runtimeDataRef = useRef(data);
|
||||||
const runtimeProfileIdRef = useRef(activeProfileId);
|
const runtimeProfileIdRef = useRef(activeProfileId);
|
||||||
const runtimeClientIdRef = useRef(activeClientId);
|
const runtimeClientIdRef = useRef(activeClientId);
|
||||||
|
const resolvedProfileId = useMemo(
|
||||||
|
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
|
||||||
|
[activeProfileId, authSession, data]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runtimeDataRef.current = data;
|
runtimeDataRef.current = data;
|
||||||
runtimeProfileIdRef.current = activeProfileId;
|
runtimeProfileIdRef.current = resolvedProfileId;
|
||||||
runtimeClientIdRef.current = activeClientId;
|
runtimeClientIdRef.current = activeClientId;
|
||||||
}, [activeClientId, activeProfileId, data]);
|
}, [activeClientId, data, resolvedProfileId]);
|
||||||
|
|
||||||
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
const me = useMemo(() => buildMe(data, resolvedProfileId, activeClientId), [data, resolvedProfileId, activeClientId]);
|
||||||
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
|
const activeProfileUser = data.users.find((user) => user.id === resolvedProfileId) ?? data.users[0];
|
||||||
const currentAccessRequest = useMemo(() => {
|
const currentAccessRequest = useMemo(() => {
|
||||||
if (!authSession?.authenticated || !authSession.user.email) return null;
|
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 authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
|
||||||
const launcherServices = useMemo(
|
const launcherServices = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const services = buildLauncherServices(data, activeProfileId, resolvedClientId);
|
const services = buildLauncherServices(data, resolvedProfileId, resolvedClientId);
|
||||||
|
|
||||||
if (!authSession?.authenticated || authApps === null) {
|
if (!authSession?.authenticated || authApps === null) {
|
||||||
return services;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return services.map((service) => {
|
return services.map((service) => {
|
||||||
|
|
@ -167,32 +171,34 @@ export function LauncherApp() {
|
||||||
effectiveAccess: {
|
effectiveAccess: {
|
||||||
...service.effectiveAccess,
|
...service.effectiveAccess,
|
||||||
allowed: false,
|
allowed: false,
|
||||||
visible: true,
|
visible: false,
|
||||||
openEnabled: false,
|
openEnabled: false,
|
||||||
reason: "Нет доступа",
|
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 {
|
return {
|
||||||
...service,
|
...service,
|
||||||
title: app.title || service.title,
|
title: app.title || service.title,
|
||||||
description: app.description || service.description,
|
description: app.description || service.description,
|
||||||
openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null,
|
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: {
|
effectiveAccess: {
|
||||||
...service.effectiveAccess,
|
...service.effectiveAccess,
|
||||||
allowed: app.hasAccess,
|
allowed,
|
||||||
visible: true,
|
visible: appVisible && service.effectiveAccess.visible,
|
||||||
openEnabled,
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -846,7 +852,7 @@ export function LauncherApp() {
|
||||||
me={runtimeMe}
|
me={runtimeMe}
|
||||||
clients={data.clients}
|
clients={data.clients}
|
||||||
profileOptions={profileOptions}
|
profileOptions={profileOptions}
|
||||||
activeProfileId={activeProfileId}
|
activeProfileId={resolvedProfileId}
|
||||||
activeClientId={resolvedClientId}
|
activeClientId={resolvedClientId}
|
||||||
adminOpen={adminOpen}
|
adminOpen={adminOpen}
|
||||||
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
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 {
|
function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string {
|
||||||
const user = data.users.find((item) => item.id === userId);
|
const user = data.users.find((item) => item.id === userId);
|
||||||
const isRoot = user?.id === "user_root";
|
const isRoot = user?.id === "user_root";
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export interface LauncherAuthApp {
|
||||||
export async function fetchAuthSession(): Promise<AuthSession> {
|
export async function fetchAuthSession(): Promise<AuthSession> {
|
||||||
const response = await fetch("/api/me", { cache: "no-store" });
|
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;
|
return (await response.json()) as UnauthenticatedSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,9 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?:
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fallbackClientId =
|
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 canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
|
||||||
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
|
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
|
||||||
const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);
|
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> {
|
export async function loadPersistedLauncherData(): Promise<LauncherData | null> {
|
||||||
try {
|
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;
|
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 {
|
} catch {
|
||||||
return null;
|
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 { KeyRound, Save, Upload, X } from "lucide-react";
|
||||||
import type { LauncherUser } from "../../entities/user/types";
|
import type { LauncherUser } from "../../entities/user/types";
|
||||||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||||
|
|
@ -17,13 +17,42 @@ export function ProfileSettingsPanel({
|
||||||
onChangePassword: (newPassword: string) => Promise<void>;
|
onChangePassword: (newPassword: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<LauncherUser>(user);
|
const [draft, setDraft] = useState<LauncherUser>(user);
|
||||||
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(user.avatarUrl ?? null);
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [savingPassword, setSavingPassword] = useState(false);
|
const [savingPassword, setSavingPassword] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
@ -35,12 +64,20 @@ export function ProfileSettingsPanel({
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
|
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) {
|
async function handleAvatarUpload(file: File | undefined) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
const localPreviewUrl = URL.createObjectURL(file);
|
||||||
|
hasUnsavedProfileChangesRef.current = true;
|
||||||
|
setAvatarPreviewUrl(localPreviewUrl);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
|
|
@ -49,6 +86,7 @@ export function ProfileSettingsPanel({
|
||||||
update("avatarUrl", result.url);
|
update("avatarUrl", result.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
|
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
|
||||||
|
setAvatarPreviewUrl(draftRef.current.avatarUrl ?? null);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -59,13 +97,16 @@ export function ProfileSettingsPanel({
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const profileDraft = draftRef.current;
|
||||||
await onSaveProfile({
|
await onSaveProfile({
|
||||||
name: draft.name,
|
name: profileDraft.name,
|
||||||
email: draft.email,
|
email: profileDraft.email,
|
||||||
phone: draft.phone ?? null,
|
phone: profileDraft.phone ?? null,
|
||||||
position: draft.position ?? null,
|
position: profileDraft.position ?? null,
|
||||||
avatarUrl: draft.avatarUrl ?? null,
|
avatarUrl: profileDraft.avatarUrl ?? null,
|
||||||
});
|
});
|
||||||
|
hasUnsavedProfileChangesRef.current = false;
|
||||||
|
setAvatarPreviewUrl(profileDraft.avatarUrl ?? null);
|
||||||
setMessage("Профиль сохранён");
|
setMessage("Профиль сохранён");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
|
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
|
||||||
|
|
@ -109,8 +150,8 @@ export function ProfileSettingsPanel({
|
||||||
|
|
||||||
<div className="profile-settings-panel__body">
|
<div className="profile-settings-panel__body">
|
||||||
<div className="profile-settings-avatar-card">
|
<div className="profile-settings-avatar-card">
|
||||||
{draft.avatarUrl ? (
|
{avatarPreviewUrl ? (
|
||||||
<img className="profile-settings-avatar-card__image" src={draft.avatarUrl} alt="" />
|
<img className="profile-settings-avatar-card__image" src={avatarPreviewUrl} alt="" />
|
||||||
) : (
|
) : (
|
||||||
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
|
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -121,7 +162,11 @@ export function ProfileSettingsPanel({
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
disabled={uploading}
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue