diff --git a/public/storage/launcher-data.json b/public/storage/launcher-data.json index 9a050fd..96345be 100644 --- a/public/storage/launcher-data.json +++ b/public/storage/launcher-data.json @@ -1134,6 +1134,23 @@ "clientId": null, "result": "warning", "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/" + } + } } diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 45903f1..63f7e4e 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -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) => diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 714fffd..e4ebe5c 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -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 загрузки"); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index f502cae..6e54e36 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -24,6 +24,7 @@ import { updateAdminInvite, updateAdminMembership, updateAdminService, + updateAdminSettings, updateAdminUserProfile, type ControlPlaneMutationResult, } from "../shared/api/adminApi"; @@ -31,8 +32,10 @@ import { buildLauncherServices, buildMe, initialLauncherData, + normalizeLauncherData, profileOptions, type LauncherData, + type LauncherSettings, } from "../shared/api/mockApi"; import { fetchAuthSession, @@ -55,6 +58,8 @@ import { ServiceRail } from "../widgets/service-rail/ServiceRail"; import { ServiceStage } from "../widgets/service-stage/ServiceStage"; import { TopBar } from "../widgets/top-bar/TopBar"; +let lastAuthRedirect: { url: string; startedAt: number } | null = null; + export function LauncherApp() { const [data, setData] = useState(() => syncLauncherServiceLinks(initialLauncherData)); const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId); @@ -188,7 +193,7 @@ export function LauncherApp() { useEffect(() => { if (!authSession || authSession.authenticated) return; - window.location.replace(buildLoginRedirectUrl(authSession.loginUrl)); + redirectToLogin(authSession.loginUrl); }, [authSession]); useEffect(() => { @@ -198,7 +203,7 @@ export function LauncherApp() { if (isRedirecting) return; 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 (!session.authenticated) { - window.location.replace(buildLoginRedirectUrl(session.loginUrl)); + redirectToLogin(session.loginUrl); return; } @@ -221,7 +226,7 @@ export function LauncherApp() { }) .catch(() => { if (isMounted) { - window.location.replace(buildLoginRedirectUrl("/auth/login")); + redirectToLogin("/auth/login"); } }); }; @@ -418,6 +423,10 @@ export function LauncherApp() { applyControlPlaneMutation(retryAdminSync(syncId)); } + function handleUpdateSettings(patch: Partial) { + applyControlPlaneMutation(updateAdminSettings(patch)); + } + function handleUpdateService(serviceId: string, patch: Partial) { applyControlPlaneMutation(updateAdminService(serviceId, patch)); } @@ -542,6 +551,7 @@ export function LauncherApp() { onOpenShowcase={() => setAdminOpen(false)} onOpenProfileSettings={() => setProfileSettingsOpen(true)} onLogout={handleLogout} + brandLinkUrl={data.settings.brand.logoLinkUrl} />
@@ -578,6 +588,7 @@ export function LauncherApp() { onReorderServices={handleReorderServices} onCreateService={handleCreateService} onDeleteService={handleDeleteService} + onUpdateSettings={handleUpdateSettings} /> ) : null} {profileSettingsOpen && activeProfileUser ? ( @@ -594,10 +605,12 @@ export function LauncherApp() { ); } -function syncLauncherServiceLinks(data: LauncherData): LauncherData { +function syncLauncherServiceLinks(data: Partial): LauncherData { + const normalizedData = normalizeLauncherData(data); + return { - ...data, - services: data.services.map(syncServiceLaunchLink), + ...normalizedData, + services: normalizedData.services.map(syncServiceLaunchLink), }; } @@ -697,7 +710,7 @@ function AuthStateScreen({

{description}

{error ?

{error}

: null} {loginUrl ? ( - ) : null} @@ -720,3 +733,15 @@ function buildLoginRedirectUrl(loginUrl?: string) { 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); +} diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index f0e7eea..02c3bff 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -4,7 +4,7 @@ import type { Invite } from "../../entities/invite/types"; import type { Service } from "../../entities/service/types"; import type { SyncStatus } from "../../entities/sync/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 | "deny" | "unset"; @@ -15,7 +15,7 @@ export interface ControlPlaneSnapshot { email: string | null; source: string; }; - counts: Record; + counts: Record; data: LauncherData; } @@ -192,6 +192,13 @@ export async function retryAdminSync(syncId: string): Promise(`/api/admin/sync/${encodeURIComponent(syncId)}/retry`, { method: "POST" }); } +export async function updateAdminSettings(patch: Partial): Promise { + return requestJson("/api/admin/settings", { + method: "PATCH", + body: JSON.stringify(patch), + }); +} + async function requestJson(url: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); if (!headers.has("Content-Type")) { diff --git a/src/shared/api/mockApi.ts b/src/shared/api/mockApi.ts index 4a7399f..72ecb0f 100644 --- a/src/shared/api/mockApi.ts +++ b/src/shared/api/mockApi.ts @@ -60,6 +60,13 @@ export interface LauncherData { invites: Invite[]; syncStatuses: SyncStatus[]; auditEvents: typeof mockAuditEvents; + settings: LauncherSettings; +} + +export interface LauncherSettings { + brand: { + logoLinkUrl: string; + }; } export interface ProfileOption { @@ -83,7 +90,13 @@ export interface AccessMatrix { cells: AccessMatrixCell[]; } -export const initialLauncherData: LauncherData = { +export const defaultLauncherSettings: LauncherSettings = { + brand: { + logoLinkUrl: "/", + }, +}; + +export const initialLauncherData: LauncherData = normalizeLauncherData({ clients: mockClients, users: mockUsers, memberships: mockMemberships, @@ -94,7 +107,40 @@ export const initialLauncherData: LauncherData = { invites: mockInvites, syncStatuses: mockSyncStatuses, auditEvents: mockAuditEvents, -}; + settings: defaultLauncherSettings, +}); + +export function normalizeLauncherSettings(settings?: Partial | null): LauncherSettings { + const brand = + typeof settings?.brand === "object" && settings.brand !== null + ? settings.brand + : ({} as Partial); + const logoLinkUrl = typeof brand.logoLinkUrl === "string" && brand.logoLinkUrl.trim() ? brand.logoLinkUrl.trim() : "/"; + + return { + brand: { + logoLinkUrl, + }, + }; +} + +export function normalizeLauncherData(data: Partial | 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[] = [ { diff --git a/src/shared/api/storageApi.ts b/src/shared/api/storageApi.ts index e26388f..9638962 100644 --- a/src/shared/api/storageApi.ts +++ b/src/shared/api/storageApi.ts @@ -1,4 +1,4 @@ -import type { LauncherData } from "./mockApi"; +import { normalizeLauncherData, type LauncherData } from "./mockApi"; export interface StoredFileResponse { ok: true; @@ -34,7 +34,7 @@ export async function loadPersistedLauncherData(): Promise if (!response.ok) return null; - return (await response.json()) as LauncherData; + return normalizeLauncherData((await response.json()) as Partial); } catch { return null; } diff --git a/src/styles/globals.css b/src/styles/globals.css index b0abdb6..5c63a3b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -11,6 +11,17 @@ --launcher-radius-control: 1.25rem; --launcher-radius-circle: 999px; --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-strong: rgba(8, 8, 11, 0.9); --surface-soft: rgba(255, 255, 255, 0.08); @@ -164,14 +175,15 @@ code { .nodedc-expanded-toolbar-shell { position: relative; z-index: 80; - width: 100%; + width: min(100%, calc(100vw - var(--nodedc-shell-frame-gutter-x))); + margin-inline: auto; 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 { display: flex; - min-height: 4.25rem; + min-height: var(--nodedc-shell-height); width: 100%; flex-direction: column; gap: 0; @@ -180,7 +192,7 @@ code { .nodedc-expanded-toolbar-top { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - min-height: 3rem; + min-height: var(--nodedc-shell-row-height); width: 100%; align-items: center; gap: 1rem; @@ -199,6 +211,16 @@ code { 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 { justify-content: center; } @@ -209,9 +231,9 @@ code { .nodedc-expanded-brand-logo { display: block; - width: 7.25rem; - height: auto; - max-height: 2.2rem; + width: 100%; + height: 100%; + max-height: none; object-fit: contain; } @@ -259,19 +281,19 @@ code { .nodedc-expanded-user-group { display: inline-flex; - height: 3.45rem; - min-height: 3.45rem; + height: var(--nodedc-shell-pill-height); + min-height: var(--nodedc-shell-pill-height); align-items: center; gap: 0.22rem; border-radius: 999px; background: rgba(64, 64, 64, 0.48); - padding: 0.32rem; + padding: var(--nodedc-shell-pill-padding); cursor: pointer; user-select: none; } .nodedc-expanded-user-group .nodedc-expanded-nav-button { - min-height: 2.78rem; + min-height: var(--nodedc-shell-control-height); padding-inline: 1.2rem; } @@ -281,13 +303,13 @@ code { .nodedc-expanded-nav-group { display: inline-flex; - min-height: 3.45rem; + min-height: var(--nodedc-shell-pill-height); align-items: center; gap: 0.18rem; border: 0; border-radius: 999px; background: rgba(64, 64, 64, 0.48); - padding: 0.32rem; + padding: var(--nodedc-shell-pill-padding); box-shadow: none; } @@ -297,7 +319,7 @@ code { align-items: center; justify-content: center; gap: 0; - min-height: 2.78rem; + min-height: var(--nodedc-shell-control-height); border: 0; outline: none; box-shadow: none; @@ -368,8 +390,8 @@ code { } .nodedc-expanded-notification-button { - height: 2.78rem; - width: 2.78rem; + height: var(--nodedc-shell-control-height); + width: var(--nodedc-shell-control-height); background: transparent; color: rgba(255, 255, 255, 0.68); } @@ -2903,6 +2925,41 @@ code { 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 { max-width: 42rem; } diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index e365ef0..c5788b1 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -32,6 +32,7 @@ import { Save, SearchCheck, ShieldCheck, + SlidersHorizontal, Trash2, UsersRound, Video, @@ -59,6 +60,7 @@ import { getUser, type AccessMatrixCell, type LauncherData, + type LauncherSettings, type MeResponse, } from "../../shared/api/mockApi"; import { uploadStorageFile } from "../../shared/api/storageApi"; @@ -78,6 +80,7 @@ type AdminSection = | "invites" | "sync" | "audit" + | "misc" | "company"; type AccessAssignmentRole = Exclude; @@ -109,6 +112,7 @@ const rootSections: Array<{ id: AdminSection; label: string; icon: React.ReactNo { id: "invites", label: "Инвайты", icon: }, { id: "sync", label: "Синхронизация", icon: }, { id: "audit", label: "Аудит", icon: }, + { id: "misc", label: "Разное", icon: }, ]; const clientSections: Array<{ id: AdminSection; label: string; icon: React.ReactNode }> = [ @@ -146,6 +150,7 @@ export function AdminOverlay({ onReorderServices, onCreateService, onDeleteService, + onUpdateSettings, }: { data: LauncherData; me: MeResponse; @@ -171,6 +176,7 @@ export function AdminOverlay({ onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; onDeleteService: (serviceId: string) => void; + onUpdateSettings: (patch: Partial) => void; }) { const isRoot = me.launcherRole === "root_admin"; const sections = isRoot ? rootSections : clientSections; @@ -335,6 +341,7 @@ export function AdminOverlay({ ) : null} {activeSection === "sync" ? : null} {activeSection === "audit" && isRoot ? : null} + {activeSection === "misc" && isRoot ? : null} {activeSection === "company" ? : null} @@ -2327,6 +2334,58 @@ function AuditSection({ data }: { data: LauncherData }) { ); } +function MiscSection({ + data, + onUpdateSettings, +}: { + data: LauncherData; + onUpdateSettings: (patch: Partial) => 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 ( + +
+
+

Разное

+

+ Общие настройки платформы, которые должны применяться в лаунчере, Task Manager и системных auth-экранах. +

+
+ +
+ +
+ +
+
+ ); +} + function CompanySection({ data, clientId }: { data: LauncherData; clientId: string }) { const client = getClient(data, clientId); @@ -2386,6 +2445,7 @@ function sectionTitle(section: AdminSection): string { invites: "Инвайты", sync: "Синхронизация", audit: "Аудит", + misc: "Разное", company: "Профиль компании", }; return labels[section]; diff --git a/src/widgets/top-bar/TopBar.tsx b/src/widgets/top-bar/TopBar.tsx index 70dc09e..8956713 100644 --- a/src/widgets/top-bar/TopBar.tsx +++ b/src/widgets/top-bar/TopBar.tsx @@ -17,6 +17,7 @@ export function TopBar({ onOpenShowcase, onOpenProfileSettings, onLogout, + brandLinkUrl = "/", }: { me: MeResponse; clients: Client[]; @@ -30,6 +31,7 @@ export function TopBar({ onOpenShowcase: () => void; onOpenProfileSettings: () => void; onLogout?: () => void; + brandLinkUrl?: string; }) { const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId)); const availableClients = clients.filter((client) => availableClientIds.has(client.id)); @@ -51,7 +53,7 @@ export function TopBar({