UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единая шапка и OIDC вход launcher
This commit is contained in:
parent
09400f7db8
commit
fd921cc400
|
|
@ -1134,6 +1134,23 @@
|
||||||
"clientId": null,
|
"clientId": null,
|
||||||
"result": "warning",
|
"result": "warning",
|
||||||
"details": null
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_brand_settings",
|
||||||
|
"at": "2026-05-05T10:04:34.052Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "DC SUDO",
|
||||||
|
"action": "Обновлены системные настройки",
|
||||||
|
"objectType": "settings",
|
||||||
|
"objectName": "Brand settings",
|
||||||
|
"clientId": null,
|
||||||
|
"result": "success",
|
||||||
|
"details": "Logo link: http://launcher.local.nodedc/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"brand": {
|
||||||
|
"logoLinkUrl": "http://launcher.local.nodedc/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ const appRoles = new Set(["viewer", "member", "admin", "owner"]);
|
||||||
const grantStatuses = new Set(["active", "disabled"]);
|
const grantStatuses = new Set(["active", "disabled"]);
|
||||||
const exceptionTypes = new Set(["deny", "allow"]);
|
const exceptionTypes = new Set(["deny", "allow"]);
|
||||||
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
|
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
|
||||||
|
const defaultSettings = {
|
||||||
|
brand: {
|
||||||
|
logoLinkUrl: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function createControlPlaneStore({ projectRoot }) {
|
export function createControlPlaneStore({ projectRoot }) {
|
||||||
const publicStorageRoot = join(projectRoot, "public", "storage");
|
const publicStorageRoot = join(projectRoot, "public", "storage");
|
||||||
|
|
@ -179,6 +184,32 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
return { client, data };
|
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) {
|
async function updateUserProfile(userId, payload, identity) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
|
|
@ -882,6 +913,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
updateInvite,
|
updateInvite,
|
||||||
updateMembership,
|
updateMembership,
|
||||||
updateService,
|
updateService,
|
||||||
|
updateSettings,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
upsertException,
|
upsertException,
|
||||||
upsertGrant,
|
upsertGrant,
|
||||||
|
|
@ -898,9 +930,21 @@ function normalizeData(payload) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.settings = normalizeSettings(data.settings);
|
||||||
return data;
|
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) {
|
function resolveActor(data, identity) {
|
||||||
const user = data.users.find(
|
const user = data.users.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
app.get("/auth/login", asyncRoute(async (req, res) => {
|
||||||
ensureOidcConfigured();
|
ensureOidcConfigured();
|
||||||
|
|
||||||
const discovery = await getOidcDiscovery();
|
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 state = randomBase64Url(32);
|
||||||
const nonce = randomBase64Url(32);
|
const nonce = randomBase64Url(32);
|
||||||
const codeVerifier = randomBase64Url(64);
|
const codeVerifier = randomBase64Url(64);
|
||||||
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
||||||
const returnTo = sanitizeReturnTo(req.query.returnTo);
|
|
||||||
|
|
||||||
pruneExpiredState();
|
pruneExpiredState();
|
||||||
pendingLogins.set(state, {
|
pendingLogins.set(state, {
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
nonce,
|
nonce,
|
||||||
returnTo,
|
returnTo,
|
||||||
|
prompt,
|
||||||
expiresAt: Date.now() + pendingLoginTtlMs,
|
expiresAt: Date.now() + pendingLoginTtlMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOidcStateCookie(res, [state, ...getValidOidcCookieStates(req)].slice(0, maxOidcStateCookieEntries));
|
setOidcStateCookie(res, [state, ...cookieStates].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");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const pendingLogin = pendingLogins.get(state);
|
||||||
|
const authorizationUrl = buildOidcAuthorizationUrl(discovery, { state, prompt, pendingLogin });
|
||||||
res.redirect(authorizationUrl.toString());
|
res.redirect(authorizationUrl.toString());
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -351,6 +357,12 @@ app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
|
||||||
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
|
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) => {
|
app.get("/api/admin/clients", requireLauncherAdmin, (req, res) => {
|
||||||
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
|
||||||
res.json({ clients: snapshot.data.clients });
|
res.json({ clients: snapshot.data.clients });
|
||||||
|
|
@ -631,6 +643,30 @@ async function getOidcDiscovery() {
|
||||||
return discovery;
|
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) {
|
async function exchangeCodeForTokens(discovery, code, codeVerifier) {
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
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) {
|
async function saveUploadedFile(payload) {
|
||||||
if (!isUploadPayload(payload)) {
|
if (!isUploadPayload(payload)) {
|
||||||
throw new Error("Некорректный payload загрузки");
|
throw new Error("Некорректный payload загрузки");
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
updateAdminInvite,
|
updateAdminInvite,
|
||||||
updateAdminMembership,
|
updateAdminMembership,
|
||||||
updateAdminService,
|
updateAdminService,
|
||||||
|
updateAdminSettings,
|
||||||
updateAdminUserProfile,
|
updateAdminUserProfile,
|
||||||
type ControlPlaneMutationResult,
|
type ControlPlaneMutationResult,
|
||||||
} from "../shared/api/adminApi";
|
} from "../shared/api/adminApi";
|
||||||
|
|
@ -31,8 +32,10 @@ import {
|
||||||
buildLauncherServices,
|
buildLauncherServices,
|
||||||
buildMe,
|
buildMe,
|
||||||
initialLauncherData,
|
initialLauncherData,
|
||||||
|
normalizeLauncherData,
|
||||||
profileOptions,
|
profileOptions,
|
||||||
type LauncherData,
|
type LauncherData,
|
||||||
|
type LauncherSettings,
|
||||||
} from "../shared/api/mockApi";
|
} from "../shared/api/mockApi";
|
||||||
import {
|
import {
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
|
|
@ -55,6 +58,8 @@ import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||||
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||||
import { TopBar } from "../widgets/top-bar/TopBar";
|
import { TopBar } from "../widgets/top-bar/TopBar";
|
||||||
|
|
||||||
|
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
||||||
|
|
||||||
export function LauncherApp() {
|
export function LauncherApp() {
|
||||||
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
|
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
|
||||||
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
||||||
|
|
@ -188,7 +193,7 @@ export function LauncherApp() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession || authSession.authenticated) return;
|
if (!authSession || authSession.authenticated) return;
|
||||||
|
|
||||||
window.location.replace(buildLoginRedirectUrl(authSession.loginUrl));
|
redirectToLogin(authSession.loginUrl);
|
||||||
}, [authSession]);
|
}, [authSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -198,7 +203,7 @@ export function LauncherApp() {
|
||||||
if (isRedirecting) return;
|
if (isRedirecting) return;
|
||||||
|
|
||||||
isRedirecting = true;
|
isRedirecting = true;
|
||||||
window.location.replace(buildLoginRedirectUrl("/auth/login?prompt=login"));
|
redirectToLogin("/auth/login?prompt=login");
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -213,7 +218,7 @@ export function LauncherApp() {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
if (!session.authenticated) {
|
if (!session.authenticated) {
|
||||||
window.location.replace(buildLoginRedirectUrl(session.loginUrl));
|
redirectToLogin(session.loginUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,7 +226,7 @@ export function LauncherApp() {
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
window.location.replace(buildLoginRedirectUrl("/auth/login"));
|
redirectToLogin("/auth/login");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -418,6 +423,10 @@ export function LauncherApp() {
|
||||||
applyControlPlaneMutation(retryAdminSync(syncId));
|
applyControlPlaneMutation(retryAdminSync(syncId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUpdateSettings(patch: Partial<LauncherSettings>) {
|
||||||
|
applyControlPlaneMutation(updateAdminSettings(patch));
|
||||||
|
}
|
||||||
|
|
||||||
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
||||||
applyControlPlaneMutation(updateAdminService(serviceId, patch));
|
applyControlPlaneMutation(updateAdminService(serviceId, patch));
|
||||||
}
|
}
|
||||||
|
|
@ -542,6 +551,7 @@ export function LauncherApp() {
|
||||||
onOpenShowcase={() => setAdminOpen(false)}
|
onOpenShowcase={() => setAdminOpen(false)}
|
||||||
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
brandLinkUrl={data.settings.brand.logoLinkUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="launcher-main">
|
<main className="launcher-main">
|
||||||
|
|
@ -578,6 +588,7 @@ export function LauncherApp() {
|
||||||
onReorderServices={handleReorderServices}
|
onReorderServices={handleReorderServices}
|
||||||
onCreateService={handleCreateService}
|
onCreateService={handleCreateService}
|
||||||
onDeleteService={handleDeleteService}
|
onDeleteService={handleDeleteService}
|
||||||
|
onUpdateSettings={handleUpdateSettings}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{profileSettingsOpen && activeProfileUser ? (
|
{profileSettingsOpen && activeProfileUser ? (
|
||||||
|
|
@ -594,10 +605,12 @@ export function LauncherApp() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncLauncherServiceLinks(data: LauncherData): LauncherData {
|
function syncLauncherServiceLinks(data: Partial<LauncherData>): LauncherData {
|
||||||
|
const normalizedData = normalizeLauncherData(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...normalizedData,
|
||||||
services: data.services.map(syncServiceLaunchLink),
|
services: normalizedData.services.map(syncServiceLaunchLink),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -697,7 +710,7 @@ function AuthStateScreen({
|
||||||
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
|
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
|
||||||
{error ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{error}</p> : null}
|
{error ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{error}</p> : null}
|
||||||
{loginUrl ? (
|
{loginUrl ? (
|
||||||
<button className="button button--primary" type="button" onClick={() => window.location.assign(loginUrl)}>
|
<button className="button button--primary" type="button" onClick={() => redirectToLogin(loginUrl)}>
|
||||||
Войти
|
Войти
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -720,3 +733,15 @@ function buildLoginRedirectUrl(loginUrl?: string) {
|
||||||
|
|
||||||
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : url.toString();
|
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redirectToLogin(loginUrl?: string) {
|
||||||
|
const redirectUrl = buildLoginRedirectUrl(loginUrl);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (lastAuthRedirect && now - lastAuthRedirect.startedAt < 1500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAuthRedirect = { url: redirectUrl, startedAt: now };
|
||||||
|
window.location.replace(redirectUrl);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Invite } from "../../entities/invite/types";
|
||||||
import type { Service } from "../../entities/service/types";
|
import type { Service } from "../../entities/service/types";
|
||||||
import type { SyncStatus } from "../../entities/sync/types";
|
import type { SyncStatus } from "../../entities/sync/types";
|
||||||
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
|
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||||
import type { LauncherData } from "./mockApi";
|
import type { LauncherData, LauncherSettings } from "./mockApi";
|
||||||
|
|
||||||
export type AdminAccessAssignmentValue = Exclude<ServiceAppRole, "owner"> | "deny" | "unset";
|
export type AdminAccessAssignmentValue = Exclude<ServiceAppRole, "owner"> | "deny" | "unset";
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ export interface ControlPlaneSnapshot {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
counts: Record<keyof LauncherData, number>;
|
counts: Record<string, number>;
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +192,13 @@ export async function retryAdminSync(syncId: string): Promise<ControlPlaneMutati
|
||||||
return requestJson<ControlPlaneMutationResult>(`/api/admin/sync/${encodeURIComponent(syncId)}/retry`, { method: "POST" });
|
return requestJson<ControlPlaneMutationResult>(`/api/admin/sync/${encodeURIComponent(syncId)}/retry`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAdminSettings(patch: Partial<LauncherSettings>): Promise<ControlPlaneMutationResult> {
|
||||||
|
return requestJson<ControlPlaneMutationResult>("/api/admin/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
if (!headers.has("Content-Type")) {
|
if (!headers.has("Content-Type")) {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,13 @@ export interface LauncherData {
|
||||||
invites: Invite[];
|
invites: Invite[];
|
||||||
syncStatuses: SyncStatus[];
|
syncStatuses: SyncStatus[];
|
||||||
auditEvents: typeof mockAuditEvents;
|
auditEvents: typeof mockAuditEvents;
|
||||||
|
settings: LauncherSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LauncherSettings {
|
||||||
|
brand: {
|
||||||
|
logoLinkUrl: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileOption {
|
export interface ProfileOption {
|
||||||
|
|
@ -83,7 +90,13 @@ export interface AccessMatrix {
|
||||||
cells: AccessMatrixCell[];
|
cells: AccessMatrixCell[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialLauncherData: LauncherData = {
|
export const defaultLauncherSettings: LauncherSettings = {
|
||||||
|
brand: {
|
||||||
|
logoLinkUrl: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialLauncherData: LauncherData = normalizeLauncherData({
|
||||||
clients: mockClients,
|
clients: mockClients,
|
||||||
users: mockUsers,
|
users: mockUsers,
|
||||||
memberships: mockMemberships,
|
memberships: mockMemberships,
|
||||||
|
|
@ -94,7 +107,40 @@ export const initialLauncherData: LauncherData = {
|
||||||
invites: mockInvites,
|
invites: mockInvites,
|
||||||
syncStatuses: mockSyncStatuses,
|
syncStatuses: mockSyncStatuses,
|
||||||
auditEvents: mockAuditEvents,
|
auditEvents: mockAuditEvents,
|
||||||
};
|
settings: defaultLauncherSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function normalizeLauncherSettings(settings?: Partial<LauncherSettings> | null): LauncherSettings {
|
||||||
|
const brand =
|
||||||
|
typeof settings?.brand === "object" && settings.brand !== null
|
||||||
|
? settings.brand
|
||||||
|
: ({} as Partial<LauncherSettings["brand"]>);
|
||||||
|
const logoLinkUrl = typeof brand.logoLinkUrl === "string" && brand.logoLinkUrl.trim() ? brand.logoLinkUrl.trim() : "/";
|
||||||
|
|
||||||
|
return {
|
||||||
|
brand: {
|
||||||
|
logoLinkUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLauncherData(data: Partial<LauncherData> | null | undefined): LauncherData {
|
||||||
|
const payload = data ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients: Array.isArray(payload.clients) ? payload.clients : mockClients,
|
||||||
|
users: Array.isArray(payload.users) ? payload.users : mockUsers,
|
||||||
|
memberships: Array.isArray(payload.memberships) ? payload.memberships : mockMemberships,
|
||||||
|
groups: Array.isArray(payload.groups) ? payload.groups : mockGroups,
|
||||||
|
services: Array.isArray(payload.services) ? payload.services : mockServices,
|
||||||
|
grants: Array.isArray(payload.grants) ? payload.grants : mockGrants,
|
||||||
|
exceptions: Array.isArray(payload.exceptions) ? payload.exceptions : mockExceptions,
|
||||||
|
invites: Array.isArray(payload.invites) ? payload.invites : mockInvites,
|
||||||
|
syncStatuses: Array.isArray(payload.syncStatuses) ? payload.syncStatuses : mockSyncStatuses,
|
||||||
|
auditEvents: Array.isArray(payload.auditEvents) ? payload.auditEvents : mockAuditEvents,
|
||||||
|
settings: normalizeLauncherSettings(payload.settings),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const profileOptions: ProfileOption[] = [
|
export const profileOptions: ProfileOption[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { LauncherData } from "./mockApi";
|
import { normalizeLauncherData, type LauncherData } from "./mockApi";
|
||||||
|
|
||||||
export interface StoredFileResponse {
|
export interface StoredFileResponse {
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|
@ -34,7 +34,7 @@ export async function loadPersistedLauncherData(): Promise<LauncherData | null>
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
return (await response.json()) as LauncherData;
|
return normalizeLauncherData((await response.json()) as Partial<LauncherData>);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,17 @@
|
||||||
--launcher-radius-control: 1.25rem;
|
--launcher-radius-control: 1.25rem;
|
||||||
--launcher-radius-circle: 999px;
|
--launcher-radius-circle: 999px;
|
||||||
--launcher-modal-button-height: 2.75rem;
|
--launcher-modal-button-height: 2.75rem;
|
||||||
|
--nodedc-shell-padding-x: 1.25rem;
|
||||||
|
--nodedc-shell-padding-top: 1rem;
|
||||||
|
--nodedc-shell-padding-bottom: 0.75rem;
|
||||||
|
--nodedc-shell-frame-gutter-x: 18px;
|
||||||
|
--nodedc-shell-height: 4.25rem;
|
||||||
|
--nodedc-shell-row-height: 3rem;
|
||||||
|
--nodedc-shell-logo-width: 7.25rem;
|
||||||
|
--nodedc-shell-logo-height: 1.79rem;
|
||||||
|
--nodedc-shell-pill-height: 3.45rem;
|
||||||
|
--nodedc-shell-pill-padding: 0.32rem;
|
||||||
|
--nodedc-shell-control-height: 2.78rem;
|
||||||
--surface-base: rgba(8, 8, 11, 0.78);
|
--surface-base: rgba(8, 8, 11, 0.78);
|
||||||
--surface-strong: rgba(8, 8, 11, 0.9);
|
--surface-strong: rgba(8, 8, 11, 0.9);
|
||||||
--surface-soft: rgba(255, 255, 255, 0.08);
|
--surface-soft: rgba(255, 255, 255, 0.08);
|
||||||
|
|
@ -164,14 +175,15 @@ code {
|
||||||
.nodedc-expanded-toolbar-shell {
|
.nodedc-expanded-toolbar-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 80;
|
z-index: 80;
|
||||||
width: 100%;
|
width: min(100%, calc(100vw - var(--nodedc-shell-frame-gutter-x)));
|
||||||
|
margin-inline: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 1rem 1.25rem 0.75rem;
|
padding: var(--nodedc-shell-padding-top) var(--nodedc-shell-padding-x) var(--nodedc-shell-padding-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-expanded-toolbar {
|
.nodedc-expanded-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 4.25rem;
|
min-height: var(--nodedc-shell-height);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|
@ -180,7 +192,7 @@ code {
|
||||||
.nodedc-expanded-toolbar-top {
|
.nodedc-expanded-toolbar-top {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
min-height: 3rem;
|
min-height: var(--nodedc-shell-row-height);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -199,6 +211,16 @@ code {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-expanded-brand-link {
|
||||||
|
display: inline-flex;
|
||||||
|
width: var(--nodedc-shell-logo-width);
|
||||||
|
height: var(--nodedc-shell-logo-height);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-expanded-toolbar-center {
|
.nodedc-expanded-toolbar-center {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
@ -209,9 +231,9 @@ code {
|
||||||
|
|
||||||
.nodedc-expanded-brand-logo {
|
.nodedc-expanded-brand-logo {
|
||||||
display: block;
|
display: block;
|
||||||
width: 7.25rem;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
max-height: 2.2rem;
|
max-height: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,19 +281,19 @@ code {
|
||||||
|
|
||||||
.nodedc-expanded-user-group {
|
.nodedc-expanded-user-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 3.45rem;
|
height: var(--nodedc-shell-pill-height);
|
||||||
min-height: 3.45rem;
|
min-height: var(--nodedc-shell-pill-height);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.22rem;
|
gap: 0.22rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(64, 64, 64, 0.48);
|
background: rgba(64, 64, 64, 0.48);
|
||||||
padding: 0.32rem;
|
padding: var(--nodedc-shell-pill-padding);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-expanded-user-group .nodedc-expanded-nav-button {
|
.nodedc-expanded-user-group .nodedc-expanded-nav-button {
|
||||||
min-height: 2.78rem;
|
min-height: var(--nodedc-shell-control-height);
|
||||||
padding-inline: 1.2rem;
|
padding-inline: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,13 +303,13 @@ code {
|
||||||
|
|
||||||
.nodedc-expanded-nav-group {
|
.nodedc-expanded-nav-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-height: 3.45rem;
|
min-height: var(--nodedc-shell-pill-height);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.18rem;
|
gap: 0.18rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(64, 64, 64, 0.48);
|
background: rgba(64, 64, 64, 0.48);
|
||||||
padding: 0.32rem;
|
padding: var(--nodedc-shell-pill-padding);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,7 +319,7 @@ code {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: 2.78rem;
|
min-height: var(--nodedc-shell-control-height);
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
@ -368,8 +390,8 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-expanded-notification-button {
|
.nodedc-expanded-notification-button {
|
||||||
height: 2.78rem;
|
height: var(--nodedc-shell-control-height);
|
||||||
width: 2.78rem;
|
width: var(--nodedc-shell-control-height);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.68);
|
color: rgba(255, 255, 255, 0.68);
|
||||||
}
|
}
|
||||||
|
|
@ -2903,6 +2925,41 @@ code {
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-settings-panel {
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-settings-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-settings-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-settings-field span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-settings-field small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-settings-field__input {
|
||||||
|
min-height: 2.55rem;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.company-panel {
|
.company-panel {
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
Save,
|
Save,
|
||||||
SearchCheck,
|
SearchCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
Video,
|
Video,
|
||||||
|
|
@ -59,6 +60,7 @@ import {
|
||||||
getUser,
|
getUser,
|
||||||
type AccessMatrixCell,
|
type AccessMatrixCell,
|
||||||
type LauncherData,
|
type LauncherData,
|
||||||
|
type LauncherSettings,
|
||||||
type MeResponse,
|
type MeResponse,
|
||||||
} from "../../shared/api/mockApi";
|
} from "../../shared/api/mockApi";
|
||||||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||||
|
|
@ -78,6 +80,7 @@ type AdminSection =
|
||||||
| "invites"
|
| "invites"
|
||||||
| "sync"
|
| "sync"
|
||||||
| "audit"
|
| "audit"
|
||||||
|
| "misc"
|
||||||
| "company";
|
| "company";
|
||||||
|
|
||||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||||
|
|
@ -109,6 +112,7 @@ const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNo
|
||||||
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
{ id: "invites", label: "Инвайты", icon: <MailPlus size={16} /> },
|
||||||
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
{ id: "sync", label: "Синхронизация", icon: <RefreshCw size={16} /> },
|
||||||
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
{ id: "audit", label: "Аудит", icon: <ClipboardList size={16} /> },
|
||||||
|
{ id: "misc", label: "Разное", icon: <SlidersHorizontal size={16} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [
|
||||||
|
|
@ -146,6 +150,7 @@ export function AdminOverlay({
|
||||||
onReorderServices,
|
onReorderServices,
|
||||||
onCreateService,
|
onCreateService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
|
onUpdateSettings,
|
||||||
}: {
|
}: {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
me: MeResponse;
|
me: MeResponse;
|
||||||
|
|
@ -171,6 +176,7 @@ export function AdminOverlay({
|
||||||
onReorderServices: (orderedServiceIds: string[]) => void;
|
onReorderServices: (orderedServiceIds: string[]) => void;
|
||||||
onCreateService: () => void;
|
onCreateService: () => void;
|
||||||
onDeleteService: (serviceId: string) => void;
|
onDeleteService: (serviceId: string) => void;
|
||||||
|
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||||||
}) {
|
}) {
|
||||||
const isRoot = me.launcherRole === "root_admin";
|
const isRoot = me.launcherRole === "root_admin";
|
||||||
const sections = isRoot ? rootSections : clientSections;
|
const sections = isRoot ? rootSections : clientSections;
|
||||||
|
|
@ -335,6 +341,7 @@ export function AdminOverlay({
|
||||||
) : null}
|
) : null}
|
||||||
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
{activeSection === "sync" ? <SyncSection data={data} clientId={scopedClientId} isRoot={isRoot} onRetrySync={onRetrySync} /> : null}
|
||||||
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
|
{activeSection === "audit" && isRoot ? <AuditSection data={data} /> : null}
|
||||||
|
{activeSection === "misc" && isRoot ? <MiscSection data={data} onUpdateSettings={onUpdateSettings} /> : null}
|
||||||
{activeSection === "company" ? <CompanySection data={data} clientId={scopedClientId} /> : null}
|
{activeSection === "company" ? <CompanySection data={data} clientId={scopedClientId} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -2327,6 +2334,58 @@ function AuditSection({ data }: { data: LauncherData }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MiscSection({
|
||||||
|
data,
|
||||||
|
onUpdateSettings,
|
||||||
|
}: {
|
||||||
|
data: LauncherData;
|
||||||
|
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||||||
|
}) {
|
||||||
|
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogoLinkUrl(data.settings.brand.logoLinkUrl);
|
||||||
|
}, [data.settings.brand.logoLinkUrl]);
|
||||||
|
|
||||||
|
const normalizedLogoLinkUrl = logoLinkUrl.trim() || "/";
|
||||||
|
const hasChanges = normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassSurface className="table-shell admin-settings-panel">
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<div>
|
||||||
|
<h3>Разное</h3>
|
||||||
|
<p className="admin-helper-note">
|
||||||
|
Общие настройки платформы, которые должны применяться в лаунчере, Task Manager и системных auth-экранах.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
type="button"
|
||||||
|
icon={<Save size={16} />}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
onClick={() => onUpdateSettings({ brand: { logoLinkUrl: normalizedLogoLinkUrl } })}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-settings-grid">
|
||||||
|
<label className="admin-settings-field">
|
||||||
|
<span>Ссылка логотипа</span>
|
||||||
|
<input
|
||||||
|
className="admin-table-input admin-settings-field__input"
|
||||||
|
value={logoLinkUrl}
|
||||||
|
placeholder="/"
|
||||||
|
onChange={(event) => setLogoLinkUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CompanySection({ data, clientId }: { data: LauncherData; clientId: string }) {
|
function CompanySection({ data, clientId }: { data: LauncherData; clientId: string }) {
|
||||||
const client = getClient(data, clientId);
|
const client = getClient(data, clientId);
|
||||||
|
|
||||||
|
|
@ -2386,6 +2445,7 @@ function sectionTitle(section: AdminSection): string {
|
||||||
invites: "Инвайты",
|
invites: "Инвайты",
|
||||||
sync: "Синхронизация",
|
sync: "Синхронизация",
|
||||||
audit: "Аудит",
|
audit: "Аудит",
|
||||||
|
misc: "Разное",
|
||||||
company: "Профиль компании",
|
company: "Профиль компании",
|
||||||
};
|
};
|
||||||
return labels[section];
|
return labels[section];
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export function TopBar({
|
||||||
onOpenShowcase,
|
onOpenShowcase,
|
||||||
onOpenProfileSettings,
|
onOpenProfileSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
brandLinkUrl = "/",
|
||||||
}: {
|
}: {
|
||||||
me: MeResponse;
|
me: MeResponse;
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
|
@ -30,6 +31,7 @@ export function TopBar({
|
||||||
onOpenShowcase: () => void;
|
onOpenShowcase: () => void;
|
||||||
onOpenProfileSettings: () => void;
|
onOpenProfileSettings: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
brandLinkUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||||
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
||||||
|
|
@ -51,7 +53,7 @@ export function TopBar({
|
||||||
<div className="nodedc-expanded-toolbar">
|
<div className="nodedc-expanded-toolbar">
|
||||||
<div className="nodedc-expanded-toolbar-top">
|
<div className="nodedc-expanded-toolbar-top">
|
||||||
<div className="nodedc-expanded-toolbar-left">
|
<div className="nodedc-expanded-toolbar-left">
|
||||||
<a href="/" aria-label="NODE.DC">
|
<a href={brandLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
|
||||||
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
|
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue