UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единая шапка и OIDC вход launcher
This commit is contained in:
@@ -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
@@ -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 загрузки");
|
||||
|
||||
Reference in New Issue
Block a user