diff --git a/client/packages/lowcoder-design/src/components/GlobalInstances.tsx b/client/packages/lowcoder-design/src/components/GlobalInstances.tsx index 19b2ec8e87..2683f18130 100644 --- a/client/packages/lowcoder-design/src/components/GlobalInstances.tsx +++ b/client/packages/lowcoder-design/src/components/GlobalInstances.tsx @@ -1,3 +1,4 @@ +import { useLayoutEffect } from "react"; import { default as App } from "antd/es/app"; import type { MessageInstance } from "antd/es/message/interface"; import type { ModalStaticFunctions } from "antd/es/modal/confirm"; @@ -7,12 +8,29 @@ let messageInstance: MessageInstance; let notificationInstance: NotificationInstance; let modalInstance: Omit; -export default () => { - const staticFunction = App.useApp(); - messageInstance = staticFunction.message; - modalInstance = staticFunction.modal; - notificationInstance = staticFunction.notification; +// Nested application shells can supply a themed API without leaking it after exit. +const scopes = new Map }>(); +function updateInstances() { + const active = Array.from(scopes.values()).sort((a, b) => b.priority - a.priority)[0]?.api; + if (active) { + messageInstance = active.message; + modalInstance = active.modal; + notificationInstance = active.notification; + } +} + +export default function GlobalInstances({ priority = 0 }: { priority?: number }) { + const api = App.useApp(); + useLayoutEffect(() => { + const key = Symbol(); + scopes.set(key, { priority, api }); + updateInstances(); + return () => { + scopes.delete(key); + updateInstances(); + }; + }, [api, priority]); return null; -}; +} -export { messageInstance, notificationInstance, modalInstance }; \ No newline at end of file +export { messageInstance, notificationInstance, modalInstance }; diff --git a/client/packages/lowcoder-design/src/components/adminThemeVariables.ts b/client/packages/lowcoder-design/src/components/adminThemeVariables.ts new file mode 100644 index 0000000000..f9358c852d --- /dev/null +++ b/client/packages/lowcoder-design/src/components/adminThemeVariables.ts @@ -0,0 +1,8 @@ +import type { DefaultTheme } from "styled-components"; + +type AdminTheme = DefaultTheme & { admin?: { variables: Record } }; + +// React theme context survives portals; inherited DOM variables do not. +// Outside the Admin shell this contributes no styles. +export const adminThemeVariables = ({ theme }: { theme: DefaultTheme }) => + (theme as AdminTheme).admin?.variables; diff --git a/client/packages/lowcoder-design/src/components/button.tsx b/client/packages/lowcoder-design/src/components/button.tsx index 469c79f84b..26183a255f 100644 --- a/client/packages/lowcoder-design/src/components/button.tsx +++ b/client/packages/lowcoder-design/src/components/button.tsx @@ -5,6 +5,7 @@ import styled, { css } from "styled-components"; import { Loading } from "./Loading"; import * as React from "react"; import { CSSProperties, forwardRef } from "react"; +import { adminThemeVariables } from "./adminThemeVariables"; const buttonStyleConfig = { normal: css` @@ -28,95 +29,95 @@ const buttonStyleConfig = { `, primary: css` &&& { - background: #4965f2; - border: 1px solid #4965f2; - color: #ffffff; + background: var(--admin-accent, #4965f2); + border: 1px solid var(--admin-accent, #4965f2); + color: var(--admin-on-accent, #ffffff); &.ant-btn-background-ghost { - background-color: #fafbff; - color: #4965f2; - border-color: #c9d1fc; + background-color: var(--admin-tint, #fafbff); + color: var(--admin-link, #4965f2); + border-color: var(--admin-line-hover, #c9d1fc); &:hover { - color: #315efb; - background-color: #f5faff; - border-color: #c2d6ff; + color: var(--admin-link-hover, #315efb); + background-color: var(--admin-hover, #f5faff); + border-color: var(--admin-line-hover, #c2d6ff); } &:focus { - color: #315efb; - background-color: #f5faff; - border-color: #c2d6ff; + color: var(--admin-link-hover, #315efb); + background-color: var(--admin-hover, #f5faff); + border-color: var(--admin-line-hover, #c2d6ff); } } &:focus { - background: #4965f2; - border: 1px solid #4965f2; - color: #ffffff; + background: var(--admin-accent, #4965f2); + border: 1px solid var(--admin-accent, #4965f2); + color: var(--admin-on-accent, #ffffff); } &:hover { - border: 1px solid #315efb; - background: #315efb; - color: #ffffff; + border: 1px solid var(--admin-accent-hover, #315efb); + background: var(--admin-accent-hover, #315efb); + color: var(--admin-on-accent, #ffffff); } :disabled { &:hover { - border: 1px solid #dbe1fd; - background: #dbe1fd; - color: #ffffff; + border: 1px solid var(--admin-line, #dbe1fd); + background: var(--admin-line, #dbe1fd); + color: var(--admin-on-accent, #ffffff); opacity: 1; } - border: 1px solid #dbe1fd; - background: #dbe1fd; - color: #ffffff; + border: 1px solid var(--admin-line, #dbe1fd); + background: var(--admin-line, #dbe1fd); + color: var(--admin-on-accent, #ffffff); opacity: 1; } } `, blue: css` - background-color: #fafbff; - color: #4965f2; - border-color: #c9d1fc; + background-color: var(--admin-tint, #fafbff); + color: var(--admin-link, #4965f2); + border-color: var(--admin-line-hover, #c9d1fc); &:hover { - color: #315efb; - background-color: #f5faff; - border-color: #c2d6ff; + color: var(--admin-link-hover, #315efb); + background-color: var(--admin-hover, #f5faff); + border-color: var(--admin-line-hover, #c2d6ff); } &:focus { - color: #315efb; - background-color: #f5faff; - border-color: #c2d6ff; + color: var(--admin-link-hover, #315efb); + background-color: var(--admin-hover, #f5faff); + border-color: var(--admin-line-hover, #c2d6ff); } :disabled, :disabled:hover { - background: #f9fbff; + background: var(--admin-hover, #f9fbff); border: 1px solid #dee9ff; border-radius: 4px; } `, link: css` &&& { - color: #4955f2; - border-color: #c9d1fc; - background-color: #fafbff; + color: var(--admin-link, #4955f2); + border-color: var(--admin-line-hover, #c9d1fc); + background-color: var(--admin-tint, #fafbff); &:hover { - color: #315efb; - border-color: #c2d6ff; - background-color: #f9fbff; + color: var(--admin-link-hover, #315efb); + border-color: var(--admin-line-hover, #c2d6ff); + background-color: var(--admin-hover, #f9fbff); } &:focus { - color: #315efb; - border-color: #c2d6ff; - background-color: #f9fbff; + color: var(--admin-link-hover, #315efb); + border-color: var(--admin-line-hover, #c2d6ff); + background-color: var(--admin-hover, #f9fbff); } } `, @@ -143,6 +144,7 @@ const buttonStyleConfig = { export type TacoButtonType = keyof typeof buttonStyleConfig; const StyledAntdButton = styled(Button)<{ $buttonType: TacoButtonType; $loading: boolean }>` + ${adminThemeVariables}; ${(props) => buttonStyleConfig[props.$buttonType]}; display: flex; align-items: center; @@ -189,8 +191,8 @@ const TacoButton = forwardRef( let loadingBackground; let loadingColor; if (buttonType === "link") { - loadingBackground = "#fafbff"; - loadingColor = "#3377FF"; + loadingBackground = "var(--admin-tint, #fafbff)"; + loadingColor = "var(--admin-accent-hover, #3377FF)"; } else if (buttonType === "delete") { loadingBackground = "#fef4f4"; loadingColor = "#f73131"; @@ -227,9 +229,9 @@ export const BigButtonStyle = styled.button` height: 30px; width: 280px; margin-right: 2px; - color: #4965f2; - border: 1px solid #c9d1fc; - background: #fafbff; + color: var(--admin-accent, #4965f2); + border: 1px solid var(--admin-line-hover, #c9d1fc); + background: var(--admin-tint, #fafbff); border-radius: 4px; font-size: 13px; @@ -239,9 +241,9 @@ export const BigButtonStyle = styled.button` cursor: pointer; &:hover { - color: #3377ff; - border: 1px solid #c2d6ff; - background: #f9fbff; + color: var(--admin-accent-hover, #3377ff); + border: 1px solid var(--admin-line-hover, #c2d6ff); + background: var(--admin-hover, #f9fbff); transition: all 0.4s ease; } `; @@ -270,7 +272,7 @@ export const RedButton = styled(BigButtonStyle)` const StyledAddButton = styled.button` display: flex; align-items: center; - color: #4965f2; + color: var(--admin-accent, #4965f2); font-size: 13px; line-height: 13px; gap: 4px; @@ -281,17 +283,17 @@ const StyledAddButton = styled.button` padding: 0; &:hover { - color: #315efb; + color: var(--admin-accent-hover, #315efb); background: white; svg g { - stroke: #315efb; + stroke: var(--admin-accent-hover, #315efb); } } &:focus { background: white; - color: #315efb; + color: var(--admin-accent-hover, #315efb); } svg { diff --git a/client/packages/lowcoder/admin-branding-preview.html b/client/packages/lowcoder/admin-branding-preview.html new file mode 100644 index 0000000000..388cd6258d --- /dev/null +++ b/client/packages/lowcoder/admin-branding-preview.html @@ -0,0 +1,5 @@ + + + Admin branding preview +
+ diff --git a/client/packages/lowcoder/src/api/licenseRequestApi.ts b/client/packages/lowcoder/src/api/licenseRequestApi.ts index a4a922b5b7..ed38b49fb1 100644 --- a/client/packages/lowcoder/src/api/licenseRequestApi.ts +++ b/client/packages/lowcoder/src/api/licenseRequestApi.ts @@ -1,5 +1,6 @@ import Api from "api/api"; import { isPublicLowcoderCloud } from "util/enterpriseHosting"; +import { ERROR_CODES } from "constants/apiConstants"; export interface LicenseContactData { companyName: string; @@ -38,19 +39,49 @@ export interface EnterpriseLicenseStatus { licenses: EnterpriseLicenseFile[]; } +export class EnterpriseLicenseRequestError extends Error { + constructor(message: string, readonly retryable = true, readonly retryAfterMs = 0) { + super(message); + this.name = "EnterpriseLicenseRequestError"; + } +} + +function requestError(response?: { status?: number; headers?: Record }) { + const status = response?.status; + const retryable = !status || status === 408 || status === 429 || status >= 500; + const retryAfter = response?.headers?.["retry-after"]; + const retryAfterMs = retryAfter ? Math.max(0, Number.isFinite(Number(retryAfter)) + ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now()) : 0; + return new EnterpriseLicenseRequestError(status === 401 || status === 403 + ? "Your session no longer has permission to manage these licenses. Sign in as the purchasing workspace admin." + : retryable ? "The license service is temporarily unavailable. We will retry automatically; you can also refresh manually." + : "Licenses could not be refreshed. Check your installation's licensing configuration and try again.", + retryable, Number.isFinite(retryAfterMs) ? retryAfterMs : 0); +} + async function request(action: string, body: object): Promise { if (isPublicLowcoderCloud()) { throw new Error("Enterprise licenses are available from your self-hosted Lowcoder installation."); } - const response = await Api.post(`/enterprise-licenses/${action}`, { ...body, returnOrigin: window.location.origin }, undefined, { timeout: 120000 }); - const result = response.data?.data; - if (!result || result.success !== true) throw new Error("Enterprise licensing request was not confirmed"); - return result as T; + try { + const response = await Api.post(`/enterprise-licenses/${action}`, { ...body, returnOrigin: window.location.origin }, undefined, { timeout: 120000 }); + // The shared interceptor resolves some HTTP failures instead of rejecting them. + if (response?.status >= 400) throw requestError(response); + const result = response?.data?.data; + if (!result || result.success !== true || ((action === "status" || action === "sync") && + (!Array.isArray(result.orders) || !Array.isArray(result.licenses)))) throw requestError(); + return result as T; + } catch (error) { + if (error instanceof EnterpriseLicenseRequestError) throw error; + const failure = error as { response?: { status?: number; headers?: Record }; code?: string }; + throw requestError(failure?.code === ERROR_CODES.REQUEST_NOT_AUTHORISED ? { status: 401 } : failure?.response); + } } export const submitLicenseRequest = (data: LicenseRequestData) => request<{ success: true; requestId: string; checkoutUrl: string }>("checkout", data); -export const getLicenseRequestStatus = (orgId: string) => request("status", { orgId }); +export const getLicenseRequestStatus = (orgId: string, refresh = false) => + request("status", { orgId, ...(refresh ? { refresh: true } : {}) }); export const synchronizeEnterpriseLicenses = (orgId: string) => request("sync", { orgId }); export const getEnterpriseLicenseFile = (orgId: string, licenseId: string) => request<{ filename: string; license: string }>("download", { orgId, licenseId }); diff --git a/client/packages/lowcoder/src/components/layout/AdminBrand.tsx b/client/packages/lowcoder/src/components/layout/AdminBrand.tsx new file mode 100644 index 0000000000..abe5583004 --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/AdminBrand.tsx @@ -0,0 +1,24 @@ +import type { BrandingConfig } from "api/enterpriseApi"; +import { Logo, LogoHome } from "assets/images"; +import { trans } from "i18n"; +import { buildMaterialPreviewURL } from "util/materialUtils"; +import styled from "styled-components"; + +const BrandLogo = styled.img` + height: 28px; + max-width: 100%; + object-fit: contain; +`; + +export function AdminBrand({ branding, showName }: { branding?: BrandingConfig; showName: boolean }) { + const { logo, standardTitle } = branding?.config_set || {}; + const name = standardTitle || trans("productName"); + if (logo) return ; + if (!showName && !standardTitle) return ; + return ( + + {!standardTitle && } + {name} + + ); +} diff --git a/client/packages/lowcoder/src/components/layout/AdminSidebar.tsx b/client/packages/lowcoder/src/components/layout/AdminSidebar.tsx new file mode 100644 index 0000000000..df9c455dde --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/AdminSidebar.tsx @@ -0,0 +1,24 @@ +import styled from "styled-components"; +import SideBar from "./SideBar"; +import { adminThemeVariables } from "lowcoder-design/src/components/adminThemeVariables"; + +export const AdminSidebar = styled(SideBar)` + ${adminThemeVariables}; + background: var(--admin-sidebar, #f8f3fb); + color: var(--admin-sidebar-text, #281a30); + font-family: var(--admin-font); + padding: 20px 14px; + overflow-y: auto; + flex-shrink: 0; + border-right: 1px solid var(--admin-line); + + .sidebar-section:not(:last-child)::after { + content: ""; + display: block; + width: calc(100% - 24px); + height: 1px; + background: var(--admin-line); + margin: 12px auto; + } +`; + diff --git a/client/packages/lowcoder/src/components/layout/BRANDING.md b/client/packages/lowcoder/src/components/layout/BRANDING.md new file mode 100644 index 0000000000..9ddf52dcfc --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/BRANDING.md @@ -0,0 +1,32 @@ +# Admin branding + +The Admin shell uses the existing Enterprise branding API and Redux state. It does +not introduce another branding API, setting or database representation. + +- `getBrandingSetting` selects the current workspace configuration, then the global + configuration. These are complete configurations, not a field-by-field merge. + This preserves explicit `false` settings and the origin of logo references + (workspace material IDs versus global URLs). +- `getAdminAppearance` translates the settings into Ant Design tokens and + `--admin-*` CSS variables. New Admin components should use these instead of fixed + Lowcoder colors. Semantic error/success colors and app artwork remain independent. +- Header and sidebar settings override the generated palette. The primary color + remains exact; text on that color and links get readable foreground colors. +- Stored font names such as `Open+Sans` are normalized for CSS. The existing app + font loader loads the selected font. No custom font is required for the default + locally bundled Ubuntu font. +- The scoped styled-components theme carries variables into custom portal content. + The nested Ant App supplies themed imperative dialogs/messages and restores the + outer instances when leaving Admin. The app editor retains its own theme. +- Existing custom CSS hooks (`top-header`, `sidebar`, `sidebar-item`, + `sidebar-section`, `main-content`) and the workspace preload CSS path remain. + +With the Vite development server running, `/admin-branding-preview.html` exercises +the real shell, navigation, cards, tables and dialogs with sample company settings. +It never saves account settings or calls the branding API, and is not a production +build entry. Use it to check different brands, responsive navigation and custom CSS. + +Regression coverage is in `adminAppearance.test.ts`, `adminTheme.test.tsx` and +`redux/sagas/enterpriseBranding.test.ts`, including workspace switches and API +failures. Branding API persistence still requires an Enterprise backend for a live +end-to-end check. diff --git a/client/packages/lowcoder/src/components/layout/Layout.tsx b/client/packages/lowcoder/src/components/layout/Layout.tsx index cfd72de1d9..d459ec018a 100644 --- a/client/packages/lowcoder/src/components/layout/Layout.tsx +++ b/client/packages/lowcoder/src/components/layout/Layout.tsx @@ -5,42 +5,18 @@ import * as React from "react"; import { ReactElement, useState, useEffect } from "react"; import { HelpDropdown } from "pages/common/help"; import MainContent from "components/layout/MainContent"; -import SideBar from "components/layout/SideBar"; +import { AdminSidebar } from "./AdminSidebar"; import { CNMainContent, CNSidebar } from "constants/styleSelectors"; import { SideBarSection, SideBarSectionProps } from "./SideBarSection"; import styled from "styled-components"; -import { useSelector } from "react-redux"; import { MenuOutlined } from "@ant-design/icons"; import { Drawer, Button } from "antd"; import { trans } from "i18n"; -import { getBrandingSetting } from "@lowcoder-ee/redux/selectors/enterpriseSelectors"; type LayoutProps = { sections: SideBarSectionProps[]; }; -const SideBarV2 = styled(SideBar)<{ - $bgColor?: string, - $fontColor?: string, - $activeBgColor?: string, - $activeFontColor?: string, -}>` - background: ${props => props.$bgColor ? props.$bgColor : '#f8f3fb'} !important; - ${props => props.$fontColor && `color: ${props.$fontColor}`}; - padding: 20px 14px; - overflow-y: auto; - flex-shrink: 0; - border-right: 1px solid #e9e0ee; - - .sidebar-section:not(:last-child)::after { - content: ""; - display: block; - width: calc(100% - 24px); - height: 1px; - background: #e9e0ee; - margin: 12px auto; - } -`; const MobileMenuButton = styled(Button)` display: none; @@ -65,7 +41,6 @@ const DrawerContentWrapper = styled.div` export function Layout(props: LayoutProps) { const [drawerVisible, setDrawerVisible] = useState(false); const [isMobile, setIsMobile] = useState(() => window.innerWidth <= 720); - const brandingSettings = useSelector(getBrandingSetting); const toggleDrawer = () => { setDrawerVisible(!drawerVisible); @@ -134,14 +109,10 @@ export function Layout(props: LayoutProps) { destroyOnHidden // Ensure drawer content is removed when closed > - {mobileSections .filter((section) => section.items.length > 0) @@ -152,26 +123,22 @@ export function Layout(props: LayoutProps) { onItemClick={handleMenuClick} // Pass handler to close the drawer /> ))} - + {/* Desktop Layout */} {!isMobile && ( - {desktopSections .filter((section) => section.items.length > 0) .map((section, index) => ( ))} - + )} {routes} diff --git a/client/packages/lowcoder/src/components/layout/SideBarItem.tsx b/client/packages/lowcoder/src/components/layout/SideBarItem.tsx index 1e052307e0..17b417dc8a 100644 --- a/client/packages/lowcoder/src/components/layout/SideBarItem.tsx +++ b/client/packages/lowcoder/src/components/layout/SideBarItem.tsx @@ -30,11 +30,11 @@ const Wrapper = styled.button<{ &:hover { background: ${(props) => (props.$selected ? ( - `${props.$selectedBgColor ? props.$selectedBgColor : '#eadcf3'}` + `${props.$selectedBgColor ? props.$selectedBgColor : 'var(--admin-sidebar-active, #eadcf3)'}` ) : ( - `${props.$selectedBgColor ? props.$selectedBgColor : '#f0e6f6'}` + `${props.$selectedBgColor ? props.$selectedBgColor : 'var(--admin-sidebar-active, #f0e6f6)'}` ))}; - color: ${(props) => props.$selectedFontColor ? props.$selectedFontColor : '#683e86'} + color: ${(props) => props.$selectedFontColor ? props.$selectedFontColor : 'var(--admin-sidebar-active-text, #683e86)'} } svg { @@ -44,8 +44,8 @@ const Wrapper = styled.button<{ ${(props) => props.$selected && css` - color: ${props.$selectedFontColor ? props.$selectedFontColor : '#683e86'}; - background: ${props.$selectedBgColor ? props.$selectedBgColor : '#eadcf3'}; + color: ${props.$selectedFontColor ? props.$selectedFontColor : 'var(--admin-sidebar-active-text, #683e86)'}; + background: ${props.$selectedBgColor ? props.$selectedBgColor : 'var(--admin-sidebar-active, #eadcf3)'}; `} `; diff --git a/client/packages/lowcoder/src/components/layout/SubSideBar.tsx b/client/packages/lowcoder/src/components/layout/SubSideBar.tsx index 37cf49b0d3..a26ad53edb 100644 --- a/client/packages/lowcoder/src/components/layout/SubSideBar.tsx +++ b/client/packages/lowcoder/src/components/layout/SubSideBar.tsx @@ -7,7 +7,8 @@ const Wrapper = styled.div` flex-shrink: 0; overflow-y: auto; height: 100%; - background: #fdfbfe; + background: var(--admin-sidebar, #fdfbfe); + color: var(--admin-sidebar-text, #222222); border-right: 1px solid var(--admin-line, #e9e0ee); box-sizing: border-box; border-radius: 2px; @@ -16,13 +17,18 @@ const Wrapper = styled.div` h2 { font-weight: 500; font-size: 20px; - color: #222222; + color: inherit; margin: 0 0 20px 14px; } summary { list-style: none; pointer-events: none; } summary::-webkit-details-marker { display: none; } - .ant-menu { background: transparent; } + .ant-menu { background: transparent; color: inherit; } + .ant-menu .ant-menu-item { color: var(--admin-sidebar-text); } + .ant-menu .ant-menu-item-selected, .ant-menu .ant-menu-item:hover { + background: var(--admin-sidebar-active); + color: var(--admin-sidebar-active-text); + } && .ant-menu-inline .ant-menu-item { display: flex; align-items: center; diff --git a/client/packages/lowcoder/src/components/layout/adminAppearance.test.ts b/client/packages/lowcoder/src/components/layout/adminAppearance.test.ts new file mode 100644 index 0000000000..73ef923837 --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/adminAppearance.test.ts @@ -0,0 +1,43 @@ +import { colord } from "colord"; +import { getAdminAppearance } from "./adminAppearance"; + +describe("Admin branding", () => { + it("uses Lowcoder defaults when no company brand is configured", () => { + const appearance = getAdminAppearance(); + expect(appearance.colors.accent).toBe("#8050a0"); + expect(appearance.fontFamily).toContain("Lowcoder Ubuntu"); + expect(getAdminAppearance({ mainBrandingColor: "invalid" })).toEqual(appearance); + }); + + it.each(["#007f68", "#2356c4", "#ffdc00", "#888888"])("carries %s into controls, links and page decoration", (mainBrandingColor) => { + const { colors, theme, variables } = getAdminAppearance({ mainBrandingColor }); + expect(theme.token?.colorPrimary).toBe(mainBrandingColor); + expect(variables["--admin-accent"]).toBe(mainBrandingColor); + expect(colors.tint).not.toBe(getAdminAppearance().colors.tint); + expect(theme.components?.Menu?.itemSelectedColor).toBe(colors.link); + expect(theme.components?.Table?.rowHoverBg).toBe(colors.hover); + expect(colord(colors.link).contrast("#fff")).toBeGreaterThanOrEqual(4.5); + expect(colord(colors.onAccent).contrast(mainBrandingColor)).toBeGreaterThanOrEqual(4.5); + expect(colord(colors.onAccent).contrast(colors.accentHover)).toBeGreaterThanOrEqual(4.5); + expect(colord(colors.linkHover).contrast("#fff")).toBeGreaterThanOrEqual(4.5); + }); + + it("honors every explicit Admin navigation color independently of the accent", () => { + const { colors } = getAdminAppearance({ + mainBrandingColor: "#007f68", appHeaderColor: "#ffffff", + adminSidebarColor: "#101928", adminSidebarFontColor: "#eeeeee", + adminSidebarActiveBgColor: "#ffdc00", adminSidebarActiveFontColor: "#222222", + }); + expect(colors).toMatchObject({ + accent: "#007f68", header: "#ffffff", headerText: "#000000", + sidebar: "#101928", sidebarText: "#eeeeee", + sidebarActive: "#ffdc00", sidebarActiveText: "#222222", + }); + }); + + it.each(["Open+Sans", "Playfair+Display"])("normalizes the saved font %s", (font) => { + const { fontFamily, theme } = getAdminAppearance({ font }); + expect(fontFamily).toBe(`"${font.replace(/\+/g, " ")}", sans-serif`); + expect(theme.token?.fontFamily).toBe(fontFamily); + }); +}); diff --git a/client/packages/lowcoder/src/components/layout/adminAppearance.ts b/client/packages/lowcoder/src/components/layout/adminAppearance.ts new file mode 100644 index 0000000000..05c1e59da0 --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/adminAppearance.ts @@ -0,0 +1,99 @@ +import type { ThemeConfig } from "antd"; +import type { BrandingSettings } from "api/enterpriseApi"; +import { colord, extend } from "colord"; +import a11y from "colord/plugins/a11y"; +import mix from "colord/plugins/mix"; +import names from "colord/plugins/names"; + +extend([a11y, mix, names]); + +const defaultFont = "'Lowcoder Ubuntu', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; +const onColor = (background: string) => + colord(background).contrast("#fff") >= colord(background).contrast("#000000") ? "#ffffff" : "#000000"; + +// Keep the configured primary color exact. Links need a readable variant on white, +// including when a company's primary color is yellow or another very light color. +function linkColor(color: string) { + let result = colord(color); + for (let i = 0; i < 20 && result.contrast("#fff") < 4.5; i++) result = result.darken(0.05); + return result.toHex(); +} + +export function getAdminAppearance(branding: BrandingSettings = {}) { + const customAccent = colord(branding.mainBrandingColor || "").isValid(); + const accent = customAccent ? branding.mainBrandingColor! : "#8050a0"; + const foreground = onColor(accent); + const link = customAccent ? linkColor(accent) : "#754394"; + const tint = (white: number) => colord(accent).mix("#fff", white).toHex(); + const font = branding.font?.trim().replace(/\+/g, " "); + const fontFamily = font ? `${JSON.stringify(font)}, sans-serif` : defaultFont; + const palette = { + accent, + accentHover: (foreground === "#ffffff" ? colord(accent).darken(0.08) : colord(accent).lighten(0.08)).toHex(), + onAccent: foreground, + link, + linkHover: colord(link).darken(0.06).toHex(), + ink: customAccent ? "#24252a" : "#281a30", + muted: customAccent ? "#6e7078" : "#786a80", + line: customAccent ? tint(0.84) : "#e9e0ee", + lineHover: customAccent ? tint(0.55) : "#c7a8dc", + paper: customAccent ? tint(0.98) : "#faf7fc", + surface: "#ffffff", + tint: customAccent ? tint(0.92) : "#f3eaf9", + hover: customAccent ? tint(0.96) : "#faf6fd", + focus: colord(link).alpha(0.6).toRgbString(), + header: branding.appHeaderColor || "#201329", + sidebar: branding.adminSidebarColor || (customAccent ? tint(0.97) : "#f8f3fb"), + sidebarActive: branding.adminSidebarActiveBgColor || (customAccent ? tint(0.87) : "#eadcf3"), + cover: customAccent ? colord(accent).mix("#171717", 0.65).toHex() : "#231629", + }; + const colors = { + ...palette, + headerText: onColor(palette.header), + sidebarText: branding.adminSidebarFontColor || (colord(palette.sidebar).isDark() ? "#ffffff" : palette.ink), + sidebarActiveText: branding.adminSidebarActiveFontColor || + (branding.adminSidebarActiveBgColor ? onColor(palette.sidebarActive) : palette.link), + coverText: onColor(palette.cover), + }; + const variables: Record = { "--admin-font": fontFamily, "--admin-radius": "16px" }; + Object.entries(colors).forEach(([key, value]) => { + variables[`--admin-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`] = value; + }); + const theme: ThemeConfig = { + // Scope Ant styles too: a nested Admin provider must not recolor the editor. + hashed: true, + token: { + colorPrimary: colors.accent, + colorLink: colors.link, + colorLinkHover: colors.linkHover, + colorTextLightSolid: colors.onAccent, + colorPrimaryText: colors.link, + colorPrimaryTextHover: colors.linkHover, + colorText: colors.ink, + colorTextSecondary: colors.muted, + colorBorder: colors.line, + colorBgLayout: colors.paper, + borderRadius: 10, + fontFamily, + }, + components: { + Button: { + primaryShadow: "0 3px 8px #00000012", primaryColor: colors.onAccent, + defaultHoverColor: colors.link, defaultActiveColor: colors.linkHover, + colorPrimaryHover: colors.accentHover, + }, + Pagination: { colorPrimary: colors.link, colorPrimaryHover: colors.linkHover }, + Table: { headerBg: colors.paper, headerColor: colors.muted, rowHoverBg: colors.hover, cellPaddingBlock: 16 }, + Menu: { + itemColor: colors.ink, + itemSelectedBg: colors.tint, + itemSelectedColor: colors.link, + itemHoverBg: colors.hover, + itemHoverColor: colors.link, + itemBorderRadius: 10, + }, + Card: { borderRadiusLG: 16 }, + }, + }; + return { colors, variables, theme, fontFamily }; +} diff --git a/client/packages/lowcoder/src/components/layout/adminTheme.test.tsx b/client/packages/lowcoder/src/components/layout/adminTheme.test.tsx new file mode 100644 index 0000000000..509419c373 --- /dev/null +++ b/client/packages/lowcoder/src/components/layout/adminTheme.test.tsx @@ -0,0 +1,60 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import App from "antd/es/app"; +import ConfigProvider from "antd/es/config-provider"; +import theme from "antd/es/theme"; +import { createPortal } from "react-dom"; +import { Provider } from "react-redux"; +import { createStore } from "redux"; +import GlobalInstances, { modalInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { TacoButton } from "lowcoder-design/src/components/button"; +import { AdminTheme } from "./adminTheme"; +import { AdminBrand } from "./AdminBrand"; + +const company = { + id: "company", orgId: "a", + config_set: { mainBrandingColor: "#007f68", font: "Open+Sans", standardTitle: "Acme Operations" }, +}; +const store = createStore(() => ({ + ui: { users: { user: { currentOrgId: "a" } }, enterprise: { workspaceBranding: company } }, +})); +function Tokens({ label }: { label: string }) { + const { token } = theme.useToken(); + return {token.colorPrimary}|{token.fontFamily}; +} +function Shell({ admin }: { admin: boolean }) { + return + + + + {admin ? + + {createPortal(<>Portal action, document.body)} + : } + + + ; +} + +it("brands portaled controls and imperative dialogs, then restores the outer theme on exit", async () => { + const view = render(); + expect(screen.getByText("Acme Operations")).toBeInTheDocument(); + expect(screen.queryByText("Lowcoder")).not.toBeInTheDocument(); + expect(screen.getByLabelText("portal")).toHaveTextContent('#007f68|"Open Sans", sans-serif'); + expect(getComputedStyle(screen.getByRole("button", { name: "Portal action" })).getPropertyValue("--admin-accent")).toBe("#007f68"); + let dialog: ReturnType; + act(() => { dialog = modalInstance.confirm({ title: "Company dialog", content: }); }); + await waitFor(() => expect(screen.getByLabelText("dialog")).toHaveTextContent('#007f68|"Open Sans", sans-serif')); + act(() => dialog.destroy()); + view.rerender(); + expect(screen.getByLabelText("editor")).toHaveTextContent("#123456|Outer font"); + act(() => { dialog = modalInstance.confirm({ title: "Outer dialog", content: }); }); + await waitFor(() => expect(screen.getByLabelText("outer-dialog")).toHaveTextContent("#123456|Outer font")); + act(() => dialog.destroy()); +}); + +it("resolves workspace material logos and global URL logos without mixing their origins", () => { + const view = render(); + expect(screen.getByRole("img")).toHaveAttribute("src", expect.stringContaining("/api/materials/material-id?type=preview")); + view.rerender(); + expect(screen.getByRole("img")).toHaveAttribute("src", "https://example.com/logo.svg"); +}); diff --git a/client/packages/lowcoder/src/components/layout/adminTheme.tsx b/client/packages/lowcoder/src/components/layout/adminTheme.tsx index ce3efe963b..247a3237d0 100644 --- a/client/packages/lowcoder/src/components/layout/adminTheme.tsx +++ b/client/packages/lowcoder/src/components/layout/adminTheme.tsx @@ -1,57 +1,38 @@ -import { ConfigProvider, ThemeConfig } from "antd"; -import { CSSProperties, PropsWithChildren, useMemo } from "react"; +import App from "antd/es/app"; +import ConfigProvider from "antd/es/config-provider"; +import { PropsWithChildren, useMemo } from "react"; import { useSelector } from "react-redux"; import { getBrandingSetting } from "redux/selectors/enterpriseSelectors"; -import styled from "styled-components"; +import styled, { ThemeProvider } from "styled-components"; +import GlobalInstances from "lowcoder-design/src/components/GlobalInstances"; +import { getAdminAppearance } from "./adminAppearance"; +import { adminThemeVariables } from "lowcoder-design/src/components/adminThemeVariables"; import ubuntuRegular from "assets/fonts/ubuntu-400.woff2"; import ubuntuMedium from "assets/fonts/ubuntu-500.woff2"; import ubuntuBold from "assets/fonts/ubuntu-700.woff2"; -// Website brand tokens, scoped to the management area. The app canvas and editor -// keep their own theme; enterprise branding continues to override the shell. -export const adminTheme: ThemeConfig = { - token: { - colorPrimary: "#8050a0", - colorLink: "#754394", - colorText: "#281a30", - colorTextSecondary: "#786a80", - colorBorder: "#e5dce9", - colorBgLayout: "#faf7fc", - borderRadius: 10, - fontFamily: "'Lowcoder Ubuntu', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", - }, - components: { - Button: { primaryShadow: "0 3px 8px #683e8618" }, - Table: { headerBg: "#faf7fc", headerColor: "#786a80", rowHoverBg: "#faf6fd", cellPaddingBlock: 16 }, - Menu: { itemSelectedBg: "#efe3f7", itemSelectedColor: "#683e86", itemBorderRadius: 10 }, - Card: { borderRadiusLG: 16 }, - }, -}; - const ThemeScope = styled.div` + ${adminThemeVariables}; @font-face { font-family: 'Lowcoder Ubuntu'; src: url('${ubuntuRegular}') format('woff2'); font-weight: 400; font-display: swap; } @font-face { font-family: 'Lowcoder Ubuntu'; src: url('${ubuntuMedium}') format('woff2'); font-weight: 500; font-display: swap; } @font-face { font-family: 'Lowcoder Ubuntu'; src: url('${ubuntuBold}') format('woff2'); font-weight: 700; font-display: swap; } - --admin-ink: #281a30; - --admin-muted: #786a80; - --admin-accent: #8050a0; - --admin-line: #e9e0ee; - --admin-paper: #faf7fc; - --admin-tint: #f3eaf9; - --admin-radius: 16px; - font-family: 'Lowcoder Ubuntu', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: var(--admin-font); color: var(--admin-ink); min-width: 0; height: 100%; .main-content { background: var(--admin-paper); min-width: 0; scrollbar-gutter: stable; } - .top-header { background-color: #201329; border-bottom: 1px solid #ffffff14; } + .top-header { background-color: var(--admin-header); border-bottom: 1px solid #ffffff14; } @media (max-width: 720px) { .top-header { padding-left: 60px; padding-right: 16px; } .top-header .ant-row { gap: 12px; margin: 0 !important; flex-wrap: nowrap; } .top-header .ant-col { padding: 0 !important; } } - .admin-brand-mark { display: flex; } + .admin-brand-name { display: flex; align-items: center; gap: 10px; color: var(--admin-header-text); font-size: 20px; font-weight: 500; min-width: 0; } + .admin-brand-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .admin-brand-mark { display: flex; flex-shrink: 0; } + .top-header > div:first-child, .top-header > div:first-child > a { min-width: 0; } + .top-header > div:last-child { flex-shrink: 0; margin-left: 16px; } .admin-brand-mark svg { width: 24px; height: 26px; } @media (max-width: 360px) { .top-header { padding-left: 54px; padding-right: 12px; } @@ -59,24 +40,18 @@ const ThemeScope = styled.div` .admin-brand-name { font-size: 16px !important; gap: 6px !important; } .admin-brand-mark svg { width: 20px; height: 24px; } } - .ant-card { box-shadow: 0 4px 20px #39204e04; } + .ant-card { box-shadow: 0 4px 20px #00000004; } .ant-card-head { border-color: var(--admin-line); } .ant-table { color: var(--admin-ink); } - && .ant-table-thead > tr > th { background: #faf7fc; color: var(--admin-muted); font-size: 12px; font-weight: 500; height: 44px; } + && .ant-table-thead > tr > th { background: var(--admin-paper); color: var(--admin-muted); font-size: 12px; font-weight: 500; height: 44px; } && .ant-table-column-title { font-size: 12px; font-weight: 500; } - && .ant-table-tbody > tr:hover > td { background: #faf6fd; } - .ant-table-tbody > tr > td { border-color: #f0eaf3; } + && .ant-table-tbody > tr:hover > td { background: var(--admin-hover); } + .ant-table-tbody > tr > td { border-color: var(--admin-line); } && .ant-table-tbody button:not(:disabled) { opacity: 1; } .ant-table-row:focus-within .home-datasource-edit-button { opacity: 1; } - &&& [data-button-type='primary']:not(:disabled) { - background: var(--admin-accent); - border-color: var(--admin-accent); - color: white; - } - &&& [data-button-type='primary']:not(:disabled):hover { filter: brightness(0.92); } .ant-pagination { flex-wrap: wrap; row-gap: 8px; } :where(button, a, [role='button'], [tabindex='0']):focus-visible { - outline: 3px solid #b480de99; + outline: 3px solid var(--admin-focus); outline-offset: 3px; } @media (prefers-reduced-motion: reduce) { @@ -86,17 +61,19 @@ const ThemeScope = styled.div` export function AdminTheme({ children }: PropsWithChildren) { const branding = useSelector(getBrandingSetting)?.config_set; - const theme = useMemo(() => ({ - ...adminTheme, - token: { ...adminTheme.token, - ...(branding?.font ? { fontFamily: branding.font } : {}), - ...(branding?.mainBrandingColor ? { colorPrimary: branding.mainBrandingColor } : {}), - }, - }), [branding?.font, branding?.mainBrandingColor]); - return {children}; + const appearance = useMemo(() => getAdminAppearance(branding), [branding]); + return ( + + ({ ...outer, admin: appearance })}> + + + + {children} + + + + + ); } export const AdminPage = styled.div` @@ -123,9 +100,9 @@ export const AdminPageHeader = styled.div` export const AdminSurface = styled.div` border: 1px solid var(--admin-line); border-radius: var(--admin-radius); - background: white; + background: var(--admin-surface); min-width: 0; - box-shadow: 0 6px 30px #39204e04; + box-shadow: 0 6px 30px #00000004; `; export const AdminCover = styled.div` @@ -134,9 +111,9 @@ export const AdminCover = styled.div` padding: 28px 32px; margin-bottom: 24px; border-radius: 18px; - color: white; - background: radial-gradient(ellipse at 95% 10%, #754394 0%, transparent 60%), #231629; - h1 { position: relative; color: white; font-weight: 500; font-size: clamp(26px, 2.5vw, 36px); line-height: 1.2; letter-spacing: -0.035em; margin: 0; } - p { color: #e0cde9; margin: 10px 0 0; max-width: 670px; line-height: 1.6; } + color: var(--admin-cover-text); + background: var(--admin-cover); + h1 { position: relative; color: var(--admin-cover-text); font-weight: 500; font-size: clamp(26px, 2.5vw, 36px); line-height: 1.2; letter-spacing: -0.035em; margin: 0; } + p { color: var(--admin-cover-text); opacity: 0.8; margin: 10px 0 0; max-width: 670px; line-height: 1.6; } @media (max-width: 720px) { padding: 24px; } `; diff --git a/client/packages/lowcoder/src/dev-utils/admin-branding-preview.tsx b/client/packages/lowcoder/src/dev-utils/admin-branding-preview.tsx new file mode 100644 index 0000000000..6b93bff6a5 --- /dev/null +++ b/client/packages/lowcoder/src/dev-utils/admin-branding-preview.tsx @@ -0,0 +1,101 @@ +// Local visual regression fixture. Not included in the production build inputs. +// Uses the real components with sample branding; never reads or writes an account. +import "antd/dist/reset.css"; +import { useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; +import { createStore } from "redux"; +import { MemoryRouter } from "react-router-dom"; +import { Button, Card, Drawer, Input, Menu, Select, Space } from "antd"; +import { AppstoreOutlined, SearchOutlined, SettingOutlined, UserOutlined } from "@ant-design/icons"; +import type { BrandingSettings } from "api/enterpriseApi"; +import { AdminTheme } from "components/layout/adminTheme"; +import { AdminBrand } from "components/layout/AdminBrand"; +import { AdminSidebar } from "components/layout/AdminSidebar"; +import { SideBarItem } from "components/layout/SideBarItem"; +import SubSideBar from "components/layout/SubSideBar"; +import Header from "components/layout/Header"; +import { TacoButton } from "lowcoder-design/src/components/button"; +import { modalInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { MarketplaceShell, MarketplaceGrid } from "pages/ApplicationV2/marketplace/marketplaceStyles"; +import { HomeLayout } from "pages/ApplicationV2/HomeLayout"; +import type { FolderMeta } from "constants/applicationConstants"; + +const folders: FolderMeta[] = ["Sales dashboards", "Customer support", "Project trackers", "Operations"].map((name, i) => ({ + folderId: `sample-${i}`, orgId: "sample", name, createBy: "Alex Morgan", createAt: 1791849600000, + manageable: true, lastViewTime: 0, folder: true, +})); + +const brands: Record = { + Lowcoder: {}, + "Acme Green": { + standardTitle: "Acme Operations", mainBrandingColor: "#007f68", font: "Open+Sans", + appHeaderColor: "#102a26", adminSidebarColor: "#163a32", adminSidebarFontColor: "#e5f5ef", + adminSidebarActiveBgColor: "#ccebdd", adminSidebarActiveFontColor: "#123b2d", + }, + "OEM Blue": { + standardTitle: "Northstar Company Operations", mainBrandingColor: "#2356c4", font: "Inter", + appHeaderColor: "#ffffff", adminSidebarColor: "#eef3fc", adminSidebarFontColor: "#293e61", + adminSidebarActiveBgColor: "#2356c4", adminSidebarActiveFontColor: "#ffffff", + }, + "Bright Amber": { standardTitle: "Amber Studio", mainBrandingColor: "#ffdc00", font: "Playfair+Display" }, +}; + +function Example({ page, setPage }: { page: string; setPage: (page: string) => void }) { + const names = ["Your apps", "Marketplace", "Settings"]; + return <>{names.map((name) => setPage(name)} icon={({ style }) => name === "Settings" ? : } />)}; +} + +function Preview() { + const [brand, setBrand] = useState("Acme Green"); + const [page, setPage] = useState("Marketplace"); + const [drawer, setDrawer] = useState(false); + const [customCss, setCustomCss] = useState(false); + const [modified, setModified] = useState(false); + const branding = { id: "sample", orgId: "sample", config_set: brands[brand] }; + const store = useMemo(() => createStore(() => ({ + ui: { + enterprise: { workspaceBranding: { id: "sample", orgId: "sample", config_set: brands[brand] } }, + users: { user: { currentOrgId: "sample", username: "Alex Morgan", orgDev: true, userStatus: { newUserGuidance: true } } }, + application: { applicationList: [], loadingStatus: { isApplicationCreating: false } }, + folder: { folders }, + }, + })), [brand]); + return + {brands[brand].font && } +
+ Branding previewSample data · no settings are saved + } placeholder="Search apps, creators and categories" />