UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единая шапка и OIDC вход launcher

This commit is contained in:
DCCONSTRUCTIONS
2026-05-05 15:00:23 +03:00
parent 09400f7db8
commit fd921cc400
10 changed files with 365 additions and 55 deletions
+44
View File
@@ -25,6 +25,11 @@ const appRoles = new Set(["viewer", "member", "admin", "owner"]);
const grantStatuses = new Set(["active", "disabled"]);
const exceptionTypes = new Set(["deny", "allow"]);
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
const defaultSettings = {
brand: {
logoLinkUrl: "/",
},
};
export function createControlPlaneStore({ projectRoot }) {
const publicStorageRoot = join(projectRoot, "public", "storage");
@@ -179,6 +184,32 @@ export function createControlPlaneStore({ projectRoot }) {
return { client, data };
}
async function updateSettings(payload, identity) {
const data = readData();
const actor = resolveActor(data, identity);
const patch = typeof payload === "object" && payload !== null ? payload : {};
const settings = normalizeSettings({
...data.settings,
...patch,
brand: {
...(data.settings?.brand ?? {}),
...(patch.brand ?? {}),
},
});
data.settings = settings;
addAuditEvent(data, actor, {
action: "Обновлены системные настройки",
objectType: "settings",
objectName: "Brand settings",
result: "success",
details: `Logo link: ${settings.brand.logoLinkUrl}`,
});
await writeData(data);
return { settings, data };
}
async function updateUserProfile(userId, payload, identity) {
const data = readData();
const actor = resolveActor(data, identity);
@@ -882,6 +913,7 @@ export function createControlPlaneStore({ projectRoot }) {
updateInvite,
updateMembership,
updateService,
updateSettings,
updateUserProfile,
upsertException,
upsertGrant,
@@ -898,9 +930,21 @@ function normalizeData(payload) {
}
}
data.settings = normalizeSettings(data.settings);
return data;
}
function normalizeSettings(payload) {
const settings = typeof payload === "object" && payload !== null ? payload : {};
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
return {
brand: {
logoLinkUrl: optionalString(brand.logoLinkUrl, defaultSettings.brand.logoLinkUrl),
},
};
}
function resolveActor(data, identity) {
const user = data.users.find(
(item) =>
+75 -23
View File
@@ -57,46 +57,52 @@ app.get("/healthz", (_req, res) => {
});
});
app.get("/api/public/brand", (_req, res) => {
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC public brand" });
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", noStoreCacheControl);
res.json(buildPublicBrandResponse(snapshot.data.settings));
});
app.get("/auth/login", asyncRoute(async (req, res) => {
ensureOidcConfigured();
const discovery = await getOidcDiscovery();
const returnTo = sanitizeReturnTo(req.query.returnTo);
const prompt = sanitizePrompt(req.query.prompt);
const cookieStates = getValidOidcCookieStates(req);
const reusableState = cookieStates.find((cookieState) => {
const pendingLogin = pendingLogins.get(cookieState);
return pendingLogin?.returnTo === returnTo && pendingLogin?.prompt === prompt;
});
if (reusableState) {
const pendingLogin = pendingLogins.get(reusableState);
if (pendingLogin) {
res.redirect(buildOidcAuthorizationUrl(discovery, { state: reusableState, prompt, pendingLogin }).toString());
return;
}
}
const state = randomBase64Url(32);
const nonce = randomBase64Url(32);
const codeVerifier = randomBase64Url(64);
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const returnTo = sanitizeReturnTo(req.query.returnTo);
pruneExpiredState();
pendingLogins.set(state, {
codeVerifier,
nonce,
returnTo,
prompt,
expiresAt: Date.now() + pendingLoginTtlMs,
});
setOidcStateCookie(res, [state, ...getValidOidcCookieStates(req)].slice(0, maxOidcStateCookieEntries));
const authorizationUrl = new URL(discovery.authorization_endpoint);
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("client_id", config.clientId);
authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
authorizationUrl.searchParams.set("scope", config.scope);
authorizationUrl.searchParams.set("state", state);
authorizationUrl.searchParams.set("nonce", nonce);
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
authorizationUrl.searchParams.set("code_challenge_method", "S256");
const prompt = sanitizePrompt(req.query.prompt);
if (prompt) {
authorizationUrl.searchParams.set("prompt", prompt);
}
if (prompt === "login") {
authorizationUrl.searchParams.set("max_age", "0");
}
setOidcStateCookie(res, [state, ...cookieStates].slice(0, maxOidcStateCookieEntries));
const pendingLogin = pendingLogins.get(state);
const authorizationUrl = buildOidcAuthorizationUrl(discovery, { state, prompt, pendingLogin });
res.redirect(authorizationUrl.toString());
}));
@@ -351,6 +357,12 @@ app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
});
app.patch("/api/admin/settings", requireLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.updateSettings(req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.settings.updated");
res.json(result);
}));
app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
res.json({ clients: snapshot.data.clients });
@@ -631,6 +643,30 @@ async function getOidcDiscovery() {
return discovery;
}
function buildOidcAuthorizationUrl(discovery, { state, prompt, pendingLogin }) {
const codeChallenge = createHash("sha256").update(pendingLogin.codeVerifier).digest("base64url");
const authorizationUrl = new URL(discovery.authorization_endpoint);
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("client_id", config.clientId);
authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
authorizationUrl.searchParams.set("scope", config.scope);
authorizationUrl.searchParams.set("state", state);
authorizationUrl.searchParams.set("nonce", pendingLogin.nonce);
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
authorizationUrl.searchParams.set("code_challenge_method", "S256");
if (prompt) {
authorizationUrl.searchParams.set("prompt", prompt);
}
if (prompt === "login") {
authorizationUrl.searchParams.set("max_age", "0");
}
return authorizationUrl;
}
async function exchangeCodeForTokens(discovery, code, codeVerifier) {
const body = new URLSearchParams({
grant_type: "authorization_code",
@@ -1132,6 +1168,22 @@ function readLauncherData() {
}
}
function buildPublicBrandResponse(settings) {
const configuredLogoLinkUrl = settings?.brand?.logoLinkUrl || "/";
return {
logoLinkUrl: resolvePublicUrl(configuredLogoLinkUrl, config.appBaseUrl),
};
}
function resolvePublicUrl(value, baseUrl) {
try {
return new URL(value || "/", baseUrl).toString();
} catch {
return new URL("/", baseUrl).toString();
}
}
async function saveUploadedFile(payload) {
if (!isUploadPayload(payload)) {
throw new Error("Некорректный payload загрузки");