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

View File

@ -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/"
}
}
} }

View File

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

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) => { 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 загрузки");

View File

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

View File

@ -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")) {

View File

@ -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[] = [
{ {

View File

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

View File

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

View File

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

View File

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