SECURITY - LAUNCHER: harden storage and access lifecycle

This commit is contained in:
DCCONSTRUCTIONS 2026-05-12 12:50:52 +03:00
parent 2b34cf9f1b
commit b3915a851c
12 changed files with 459 additions and 4326 deletions

View File

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

5
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { isAbsolute, join, resolve } from "node:path";
const collectionKeys = [
"clients",
@ -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 } : {};

View File

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

0
server/storage/.gitkeep Normal file
View File

View File

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

View File

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

View File

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

View File

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

View File

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