From b36c9a4784c88537214cba4fd6d36a27f4bcf4ea Mon Sep 17 00:00:00 2001 From: FalkWolsky Date: Tue, 13 Oct 2026 16:28:11 +0200 Subject: [PATCH 1/4] Remove unnecessary Request for License Check --- .../lowcoder/src/api/licenseRequestApi.ts | 41 +++- .../pages/setting/enterpriseHosting.test.tsx | 16 +- .../subscriptions/EnterpriseLicenses.tsx | 4 +- .../src/util/enterpriseLicenseSync.test.tsx | 189 ++++++++++++++++++ .../src/util/enterpriseLicenseSync.ts | 92 +++++++-- deploy/n8n/enterprise-licenses/README.md | 8 +- .../EnterpriseLicenseCapability.java | 6 + .../EnterpriseLicenseCapabilityService.java | 28 ++- ...nterpriseLicenseCapabilityServiceTest.java | 76 +++++++ .../EnterpriseLicenseController.java | 90 ++++++++- .../EnterpriseLicenseControllerTest.java | 137 ++++++++++++- 11 files changed, 642 insertions(+), 45 deletions(-) create mode 100644 client/packages/lowcoder/src/util/enterpriseLicenseSync.test.tsx create mode 100644 server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityServiceTest.java diff --git a/client/packages/lowcoder/src/api/licenseRequestApi.ts b/client/packages/lowcoder/src/api/licenseRequestApi.ts index a4a922b5b..ed38b49fb 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/pages/setting/enterpriseHosting.test.tsx b/client/packages/lowcoder/src/pages/setting/enterpriseHosting.test.tsx index c2a5b90b1..c0f42566c 100644 --- a/client/packages/lowcoder/src/pages/setting/enterpriseHosting.test.tsx +++ b/client/packages/lowcoder/src/pages/setting/enterpriseHosting.test.tsx @@ -18,12 +18,15 @@ jest.mock("util/permissionUtils", () => ({ currentOrgAdmin: () => true })); const post = Api.post as jest.Mock; const location = window.location; -const setHost = (hostname: string) => Object.defineProperty(window, "location", { - configurable: true, value: { hostname, origin: `https://${hostname}` }, -}); +const testLocation = { hostname: "", origin: "" }; +const setHost = (hostname: string) => { + Object.assign(testLocation, { hostname, origin: `https://${hostname}` }); + Object.defineProperty(window, "location", { configurable: true, value: testLocation }); +}; beforeEach(() => { setHost("app.lowcoder.cloud"); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); (useSelector as jest.Mock).mockImplementation(selector => selector === getDeploymentId ? "deployment-1" : { currentOrgId: "org", id: "admin", name: "Admin" }); window.matchMedia = jest.fn().mockImplementation(() => ({ @@ -75,6 +78,7 @@ test.each(["en", "de", "es", "it", "pt", "ru", "zh"])("self-hosting guidance is test("self-hosted administrators can still open the purchase form", () => { setHost("private.lowcoder.cloud"); + expect(isPublicLowcoderCloud()).toBe(false); render(); fireEvent.click(screen.getByRole("button", { name: "Request Enterprise licenses" })); expect(screen.getByRole("dialog")).toHaveTextContent("Enterprise License Request"); @@ -87,7 +91,7 @@ test("cloud API calls cannot create checkout even if the form is bypassed", asyn expect(post).not.toHaveBeenCalled(); }); -test("license polling is skipped on cloud but still runs on a self-hosted installation", async () => { +test("cloud skips licensing and an empty self-hosted account stops after status", async () => { jest.useFakeTimers(); post.mockResolvedValue({ data: { data: { success: true, orders: [], licenses: [] } } }); const cloud = renderHook(() => useEnterpriseLicenseSync("org", "admin", true)); @@ -100,6 +104,8 @@ test("license polling is skipped on cloud but still runs on a self-hosted instal setHost("lowcoder.example"); const selfHosted = renderHook(() => useEnterpriseLicenseSync("org", "admin", true)); await act(async () => {}); - expect(post).toHaveBeenCalledWith("/enterprise-licenses/sync", { orgId: "org", returnOrigin: "https://lowcoder.example" }, undefined, { timeout: 120000 }); + expect(post).toHaveBeenCalledWith("/enterprise-licenses/status", { orgId: "org", returnOrigin: "https://lowcoder.example" }, undefined, { timeout: 120000 }); + await act(async () => { jest.advanceTimersByTime(3600000); }); + expect(post).toHaveBeenCalledTimes(1); selfHosted.unmount(); }); diff --git a/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx b/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx index 905a6a6f3..ccc9ce383 100644 --- a/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx +++ b/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx @@ -50,8 +50,8 @@ function SelfHostedEnterpriseLicenses() { - {state.error && } + {state.error && } {state.data.orders.map(order => {order.companyName}{order.status} {order.deploymentIds.length} instances · {order.billingInterval === "year" ? "Annual" : "Monthly"} diff --git a/client/packages/lowcoder/src/util/enterpriseLicenseSync.test.tsx b/client/packages/lowcoder/src/util/enterpriseLicenseSync.test.tsx new file mode 100644 index 000000000..af5c72a2d --- /dev/null +++ b/client/packages/lowcoder/src/util/enterpriseLicenseSync.test.tsx @@ -0,0 +1,189 @@ +import { act, renderHook } from "@testing-library/react"; +import Api from "api/api"; +import { getLicenseRequestStatus } from "api/licenseRequestApi"; +import { refreshEnterpriseLicenses, useEnterpriseLicenseStatus, useEnterpriseLicenseSync } from "./enterpriseLicenseSync"; + +jest.mock("api/api", () => ({ __esModule: true, default: { post: jest.fn() } })); +const post = Api.post as jest.Mock; +const location = window.location; +const empty = { orders: [], licenses: [] }; +const pending = { orders: [{ requestId: "purchase", status: "awaiting_payment" }], licenses: [] }; +const issued = { orders: [{ requestId: "purchase", status: "active" }], licenses: [{ id: "file", notAfter: "2027-01-01" }] }; +const response = (data: object) => ({ status: 200, data: { data: { success: true, ...data } } }); +const flush = async () => { await act(async () => {}); }; +const advance = async (ms: number) => { await act(async () => { jest.advanceTimersByTime(ms); }); }; +const syncCalls = () => post.mock.calls.filter(([url]) => url.endsWith("/sync")); +const start = () => renderHook(() => useEnterpriseLicenseSync("org", "admin", true)); + +beforeEach(() => { + jest.useFakeTimers(); + Object.defineProperty(window, "location", { configurable: true, value: { hostname: "localhost", origin: "http://localhost:8000" } }); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + post.mockResolvedValue(response(empty)); +}); +afterEach(() => { + Object.defineProperty(window, "location", { configurable: true, value: location }); + jest.useRealTimers(); +}); + +test("Community account makes one status request and never syncs on timers or wake events", async () => { + start(); + await flush(); + await advance(3600000); + await act(async () => { + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("online")); + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][0]).toBe("/enterprise-licenses/status"); + expect(post.mock.calls[0][1].refresh).toBeUndefined(); + expect(syncCalls()).toHaveLength(0); +}); + +test("first checkout can activate before any installed license and continue renewing", async () => { + post.mockResolvedValueOnce(response(pending)).mockResolvedValue(response(issued)); + start(); + await flush(); + expect(post.mock.calls.map(([url]) => url)).toEqual(["/enterprise-licenses/status", "/enterprise-licenses/sync"]); + await advance(60000); + expect(syncCalls()).toHaveLength(2); +}); + +test("returning purchasing admin discovers purchases without browser storage", async () => { + post.mockResolvedValue(response(issued)); + const first = start(); + await flush(); + first.unmount(); + post.mockClear(); + start(); + await flush(); + expect(post.mock.calls.map(([url]) => url)).toEqual(["/enterprise-licenses/status", "/enterprise-licenses/sync"]); +}); + +test("opening the license library discovers purchases after an initially empty status", async () => { + start(); + await flush(); + post.mockResolvedValueOnce(response(pending)).mockResolvedValue(response(issued)); + const library = renderHook(() => useEnterpriseLicenseStatus("org:admin")); + await flush(); + expect(post.mock.calls[1][1]).toMatchObject({ orgId: "org", refresh: true }); + expect(syncCalls()).toHaveLength(1); + expect(library.result.current.data).toMatchObject(issued); +}); + +test("library mounted before the app sync effect still requests discovery", async () => { + renderHook(() => useEnterpriseLicenseStatus("org:admin")); + start(); + await flush(); + expect(post.mock.calls[0][1]).toMatchObject({ refresh: true }); + expect(syncCalls()).toHaveLength(0); +}); + +test("failed status discovery retries with backoff and never issues an unconfirmed sync", async () => { + post.mockRejectedValue(new Error("offline relay")); + start(); + await flush(); + expect(post).toHaveBeenCalledTimes(1); + for (const delay of [60000, 120000, 240000, 480000, 900000, 900000]) { + const previous = post.mock.calls.length; + await advance(delay - 1); + await act(async () => { window.dispatchEvent(new Event("focus")); }); + expect(post).toHaveBeenCalledTimes(previous); + await advance(1); + expect(post).toHaveBeenCalledTimes(previous + 1); + } + expect(syncCalls()).toHaveLength(0); +}); + +test("failed renewal preserves downloads and success resets backoff", async () => { + post.mockResolvedValue(response(issued)); + // Read-only observation here also exercises the initial explicit library lookup. + const library = renderHook(() => useEnterpriseLicenseStatus("org:admin")); + start(); + await flush(); + post.mockRejectedValue(new Error("offline relay")); + await advance(60000); + expect(library.result.current.data).toMatchObject(issued); + expect(library.result.current.error).toMatch(/temporarily unavailable/); + await advance(60000); + const attempts = post.mock.calls.length; + post.mockResolvedValue(response(issued)); + await advance(119999); + expect(post).toHaveBeenCalledTimes(attempts); + await advance(1); + expect(library.result.current.error).toBe(""); + await advance(60000); + expect(post).toHaveBeenCalledTimes(attempts + 2); +}); + +test("resolved HTTP errors respect Retry-After without turning an outage into empty status", async () => { + post.mockResolvedValue({ status: 503, headers: { "retry-after": "300" }, data: { code: 503 } }); + start(); + await flush(); + await advance(299999); + expect(post).toHaveBeenCalledTimes(1); + await advance(1); + expect(post).toHaveBeenCalledTimes(2); + expect(syncCalls()).toHaveLength(0); +}); + +test("manual refresh can recover during backoff and discovers before syncing", async () => { + post.mockRejectedValue(new Error("relay unavailable")); + start(); + await flush(); + post.mockResolvedValueOnce(response(pending)).mockResolvedValue(response(issued)); + await act(async () => { refreshEnterpriseLicenses(); }); + expect(post.mock.calls[1][1]).toMatchObject({ refresh: true }); + expect(syncCalls()).toHaveLength(1); +}); + +test.each([400, 401, 403, 404])("HTTP %s stops automatic retries until a manual refresh or new session", async status => { + post.mockResolvedValue({ status, data: { code: status } }); + start(); + await flush(); + await advance(3600000); + await act(async () => { window.dispatchEvent(new Event("focus")); }); + expect(post).toHaveBeenCalledTimes(1); + post.mockResolvedValue(response(empty)); + await act(async () => { refreshEnterpriseLicenses(); }); + expect(post).toHaveBeenCalledTimes(2); +}); + +test("malformed successful response is retryable and cannot silently stop renewal", async () => { + post.mockResolvedValue(response({ orders: null, licenses: [] })); + await expect(getLicenseRequestStatus("org")).rejects.toMatchObject({ retryable: true }); +}); + +test("hidden and offline sessions wait until visible and online", async () => { + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + start(); + await advance(60000); + expect(post).not.toHaveBeenCalled(); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + await act(async () => { document.dispatchEvent(new Event("visibilitychange")); }); + expect(post).not.toHaveBeenCalled(); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + await act(async () => { window.dispatchEvent(new Event("online")); }); + expect(post).toHaveBeenCalledTimes(1); +}); + +test("switching workspace ignores an old response and never syncs for the old scope", async () => { + let resolve!: (value: unknown) => void; + post.mockReturnValueOnce(new Promise(value => { resolve = value; })).mockResolvedValue(response(empty)); + const hook = renderHook(({ org }) => useEnterpriseLicenseSync(org, "admin", true), { initialProps: { org: "old" } }); + hook.rerender({ org: "new" }); + await flush(); + await act(async () => { resolve(response(pending)); }); + expect(syncCalls()).toHaveLength(0); + expect(post.mock.calls.map(([, body]) => body.orgId)).toEqual(["old", "new"]); +}); + +test("disabled session never contacts the licensing API", async () => { + renderHook(() => useEnterpriseLicenseSync("org", "admin", false)); + await flush(); + await advance(3600000); + expect(post).not.toHaveBeenCalled(); +}); diff --git a/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts b/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts index ff1a0f2b0..594c3bea4 100644 --- a/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts +++ b/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts @@ -1,50 +1,104 @@ import { useEffect, useSyncExternalStore } from "react"; -import { EnterpriseLicenseStatus, synchronizeEnterpriseLicenses } from "api/licenseRequestApi"; +import { EnterpriseLicenseRequestError, EnterpriseLicenseStatus, getLicenseRequestStatus, synchronizeEnterpriseLicenses } from "api/licenseRequestApi"; import { isPublicLowcoderCloud } from "./enterpriseHosting"; const empty: EnterpriseLicenseStatus = { orders: [], licenses: [] }; -let snapshot = { scope: "", data: empty, loading: false, error: false }; +let snapshot = { scope: "", data: empty, loading: false, error: "" }; const listeners = new Set<() => void>(); const publish = (value: typeof snapshot) => { snapshot = value; listeners.forEach(listener => listener()); }; const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; }; -let refresh: (() => void) | undefined; -export const refreshEnterpriseLicenses = () => refresh?.(); +let refresh: { scope: string; run: () => void } | undefined; +const openLibraries = new Set(); +export const refreshEnterpriseLicenses = () => refresh?.run(); export const useEnterpriseLicenseStatus = (scope: string) => { const state = useSyncExternalStore(subscribe, () => snapshot, () => snapshot); - return state.scope === scope ? state : { scope, data: empty, loading: false, error: false }; + useEffect(() => { + // A deliberate visit discovers purchases even after an interrupted checkout or upgrade. + // Remember the open view because child effects can run before the app's sync effect. + openLibraries.add(scope); + if (refresh?.scope === scope) refresh.run(); + return () => { openLibraries.delete(scope); }; + }, [scope]); + return state.scope === scope ? state : { scope, data: empty, loading: false, error: "" }; }; +const intervalMs = 60000; +const maxBackoffMs = 15 * 60000; +const hasPurchase = (data: EnterpriseLicenseStatus) => data.orders.length > 0 || data.licenses.length > 0; + /** Renewal deliberately depends on an active admin UI session, not a background server cron. */ export function useEnterpriseLicenseSync(orgId: string, userId: string, enabled: boolean) { useEffect(() => { const scope = `${orgId}:${userId}`; - publish({ scope, data: empty, loading: false, error: false }); + publish({ scope, data: empty, loading: false, error: "" }); if (isPublicLowcoderCloud() || !enabled || !orgId || !userId) return; - let stopped = false, busy = false, lastAttempt = 0; + let stopped = false, busy = false, initialized = false, purchased = false, terminal = false; + let failures = 0, nextAttempt = 0, discover = openLibraries.has(scope); + let timer: number | undefined; + const schedule = () => { + window.clearTimeout(timer); + if (!stopped && !terminal && (!initialized || purchased || discover)) { + timer = window.setTimeout(wake, Math.max(intervalMs, nextAttempt - Date.now())); + } + }; const run = async (force = false) => { - if (stopped || busy || document.visibilityState === "hidden" || (!force && Date.now() - lastAttempt < 60000)) return; + if (stopped || busy) return; + if (document.visibilityState === "hidden" || !navigator.onLine) { schedule(); return; } + if (!force && (Date.now() < nextAttempt || terminal || (initialized && !purchased && !discover))) return; busy = true; - lastAttempt = Date.now(); publish({ ...snapshot, scope, loading: true }); + const lookup = !initialized || discover; + const explicitDiscovery = discover; + discover = false; try { - const data = await synchronizeEnterpriseLicenses(orgId); - if (!stopped) publish({ scope, data, loading: false, error: false }); - } catch { - if (!stopped) publish({ ...snapshot, scope, loading: false, error: true }); - } finally { busy = false; } + if (lookup) { + const data = await getLicenseRequestStatus(orgId, explicitDiscovery); + if (stopped) return; + initialized = true; + purchased = hasPurchase(data); + publish({ scope, data, loading: purchased, error: "" }); + } + if (purchased) { + const data = await synchronizeEnterpriseLicenses(orgId); + if (stopped) return; + purchased = hasPurchase(data); + publish({ scope, data, loading: false, error: "" }); + } + failures = 0; + terminal = false; + nextAttempt = Date.now() + intervalMs; + } catch (error) { + if (stopped) return; + const failure = error instanceof EnterpriseLicenseRequestError ? error + : new EnterpriseLicenseRequestError("Licenses could not be refreshed. We will retry automatically."); + // Retry failed discovery as discovery, not as an unconfirmed sync. + if (lookup) { initialized = false; discover = discover || explicitDiscovery; } + terminal = !failure.retryable; + failures += 1; + nextAttempt = Date.now() + Math.max(failure.retryAfterMs, + Math.min(maxBackoffMs, intervalMs * 2 ** Math.min(failures - 1, 4))); + publish({ ...snapshot, scope, loading: false, error: failure.message }); + } finally { + busy = false; + schedule(); + } }; const wake = () => { void run(); }; - const manual = () => { void run(true); }; - refresh = manual; + const manual = () => { + discover = true; + terminal = false; + void run(true); + }; + const registration = { scope, run: manual }; + refresh = registration; wake(); - const timer = window.setInterval(wake, 60000); window.addEventListener("focus", wake); window.addEventListener("online", wake); document.addEventListener("visibilitychange", wake); return () => { stopped = true; - if (refresh === manual) refresh = undefined; - window.clearInterval(timer); + if (refresh === registration) refresh = undefined; + window.clearTimeout(timer); window.removeEventListener("focus", wake); window.removeEventListener("online", wake); document.removeEventListener("visibilitychange", wake); diff --git a/deploy/n8n/enterprise-licenses/README.md b/deploy/n8n/enterprise-licenses/README.md index d0a02b6c8..3555bc90f 100644 --- a/deploy/n8n/enterprise-licenses/README.md +++ b/deploy/n8n/enterprise-licenses/README.md @@ -7,7 +7,9 @@ The administrator purchases from Settings → Subscription → Enterprise licens - Lowcoder's authenticated `/api/enterprise-licenses/*` endpoints require the requesting user to be a current administrator of the selected workspace. Files are scoped to the purchasing user, workspace, and installation. - A private random owner capability is stored encrypted in Lowcoder's MongoDB. Its hash scopes n8n database queries. The capability is sent only from the Lowcoder backend to n8n; it is never returned to the browser or stored in Stripe metadata. Preserve MongoDB and the existing encryption key across upgrades. - n8n creates hosted Stripe Checkout subscriptions, validates the configured live prices, and records purchase ownership in the dedicated License Database. -- The UI checks while an administrator is signed in and the page is visible. Only missing/expired license periods trigger Stripe reconciliation. Reconciliation verifies the subscription owner, paid invoice, exact product/price/quantity, successful payment, and absence of refunds or disputes before calling the private license service. +- On admin login, the UI requests status once. With no Enterprise purchase or previous checkout activity, the backend returns an empty library locally without creating an owner capability or contacting the relay. An empty library stops automatic polling. Opening the Enterprise license section or pressing Refresh explicitly discovers purchases through `status` with `refresh: true`. +- A confirmed checkout or an existing order enables sync while the purchasing administrator is signed in and the page is visible and online. This works before an Enterprise plugin/license is installed. Only missing/expired license periods trigger Stripe reconciliation. Reconciliation verifies the subscription owner, paid invoice, exact product/price/quantity, successful payment, and absence of refunds or disputes before calling the private license service. +- Transient failures use 1, 2, 4, 8, then 15-minute retry delays (capped at 15 minutes, honoring a longer `Retry-After`). Success restores the normal one-minute interval. Focus/online events do not bypass backoff. Manual refresh can retry immediately, and existing downloads remain visible during an outage. Authorization/other permanent HTTP errors stop automatic retries. - Stripe payment receipts do not issue licenses independently. A monthly renewal requires the purchasing administrator to return to Lowcoder. Administrators download and install the newly issued files through the normal license installation process. Service can be interrupted if this is delayed. - Each file has a deterministic identity per paid invoice and deployment, a generation lease, an exact paid validity period, and `apiCallsLimit: 1000000000`. Names use `Company_deployment_YYYY-MM-DD_YYYY-MM-DD.lic`. @@ -44,6 +46,10 @@ If the dialog displays a payment error immediately and the API logs show `404 NO ## Recovery +A purchase marker is stored alongside the encrypted owner capability, scoped to deployment, workspace, and purchasing admin. It is independent of browser storage and installed-license validity. Legacy capabilities without a marker receive a status-only discovery before sync is allowed; empty legacy accounts are remembered locally. Checkout attempts are recorded before calling the relay, so an ambiguous response can be reconciled on the next sign-in or explicit refresh. Empty responses cannot erase a confirmed purchase or an in-flight checkout. Existing capability tokens are preserved. + +Relay HTTP failures, DNS/connection errors, timeouts and malformed replies return HTTP 503 with a sanitized licensing message and `Retry-After: 60`. The licensing controller preserves its own HTTP 400/401/403 statuses instead of letting the global exception handler turn them into HTTP 500. Release the updated frontend and backend together; the backend also guards sync requests from older clients, but stopping the browser's repeated requests requires the new frontend. + A retry reuses the immutable purchase request ID and Stripe idempotency keys. After an ambiguous checkout creation older than 23 hours, reconcile the existing Stripe customer/session before retrying to avoid duplicate subscriptions. License generation claims expire after 180 seconds; the next authenticated UI check retries. Preserve database backups, the signing dependency, and the Lowcoder encryption key. Do not reset capabilities to repair an unrelated billing problem, because doing so would break access to existing purchases. ## Verification on 2026-10-10 diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapability.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapability.java index d4e7ffe9a..eba95d1b2 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapability.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapability.java @@ -9,7 +9,13 @@ public class EnterpriseLicenseCapability { @Id private String id; @JsonIgnore private String encryptedToken; + // Confirms an order/checkout exists, never payment or Enterprise entitlement. + // null identifies records created before purchase-scoped polling was introduced. + @JsonIgnore private Boolean purchaseConfirmed; + @JsonIgnore private boolean checkoutAttempted; public String getId() { return id; } @JsonIgnore public String getEncryptedToken() { return encryptedToken; } + @JsonIgnore public Boolean getPurchaseConfirmed() { return purchaseConfirmed; } + @JsonIgnore public boolean isCheckoutAttempted() { return checkoutAttempted; } } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityService.java index 4e6021cb8..811b09b1a 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityService.java @@ -19,6 +19,7 @@ @Service public class EnterpriseLicenseCapabilityService { + public enum PurchaseState { NONE, UNKNOWN, CONFIRMED } private final ReactiveMongoTemplate mongo; private final EncryptionService encryption; private final SecureRandom random = new SecureRandom(); @@ -36,7 +37,8 @@ public Mono token(String hostId, String orgId, String userId) { byte[] bytes = new byte[32]; random.nextBytes(bytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - Update update = new Update().setOnInsert("encryptedToken", encryption.encryptString(token)); + Update update = new Update().setOnInsert("encryptedToken", encryption.encryptString(token)) + .setOnInsert("purchaseConfirmed", false); // Concurrent first visits must all keep the original token, never rotate it. return mongo.findAndModify(query, update, FindAndModifyOptions.options().upsert(true).returnNew(true), EnterpriseLicenseCapability.class) @@ -47,6 +49,30 @@ public Mono token(String hostId, String orgId, String userId) { .map(record -> encryption.decryptString(record.getEncryptedToken())); } + /** Reading Community status must not create credentials or contact the relay. */ + public Mono purchaseState(String hostId, String orgId, String userId) { + return mongo.findById(ownerKey(hostId, orgId, userId), EnterpriseLicenseCapability.class) + .map(record -> Boolean.TRUE.equals(record.getPurchaseConfirmed()) ? PurchaseState.CONFIRMED + : record.getPurchaseConfirmed() == null || record.isCheckoutAttempted() + ? PurchaseState.UNKNOWN : PurchaseState.NONE) + .defaultIfEmpty(PurchaseState.NONE); + } + + /** Persist before contacting checkout so an ambiguous response remains discoverable. */ + public Mono checkoutAttempted(String hostId, String orgId, String userId) { + return mongo.updateFirst(Query.query(Criteria.where("_id").is(ownerKey(hostId, orgId, userId))), + new Update().set("checkoutAttempted", true), EnterpriseLicenseCapability.class).then(); + } + + public Mono recordPurchaseStatus(String hostId, String orgId, String userId, boolean purchased) { + Criteria criteria = Criteria.where("_id").is(ownerKey(hostId, orgId, userId)); + // A concurrent empty/old response must never erase a purchase or an in-flight checkout. + if (!purchased) criteria = criteria.and("purchaseConfirmed").ne(true).and("checkoutAttempted").ne(true); + Update update = new Update().set("purchaseConfirmed", purchased); + if (purchased) update.set("checkoutAttempted", false); + return mongo.updateFirst(Query.query(criteria), update, EnterpriseLicenseCapability.class).then(); + } + static String ownerKey(String hostId, String orgId, String userId) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityServiceTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityServiceTest.java new file mode 100644 index 000000000..dbc1d734a --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/enterprise/EnterpriseLicenseCapabilityServiceTest.java @@ -0,0 +1,76 @@ +package org.lowcoder.domain.enterprise; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.encryption.EncryptionService; +import org.lowcoder.domain.enterprise.EnterpriseLicenseCapabilityService.PurchaseState; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.test.util.ReflectionTestUtils; +import com.mongodb.client.result.UpdateResult; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class EnterpriseLicenseCapabilityServiceTest { + final ReactiveMongoTemplate mongo = mock(ReactiveMongoTemplate.class, withSettings().mockMaker("mock-maker-subclass")); + final EncryptionService encryption = mock(EncryptionService.class, withSettings().mockMaker("mock-maker-subclass")); + final EnterpriseLicenseCapabilityService service = new EnterpriseLicenseCapabilityService(mongo, encryption); + final String owner = EnterpriseLicenseCapabilityService.ownerKey("host", "org", "admin"); + + EnterpriseLicenseCapability record(Boolean purchased, boolean attempted) { + var record = new EnterpriseLicenseCapability(); + ReflectionTestUtils.setField(record, "purchaseConfirmed", purchased); + ReflectionTestUtils.setField(record, "checkoutAttempted", attempted); + return record; + } + + @Test void absentCommunityAccountDoesNotCreateAnOwnershipCredential() { + when(mongo.findById(owner, EnterpriseLicenseCapability.class)).thenReturn(Mono.empty()); + assertEquals(PurchaseState.NONE, service.purchaseState("host", "org", "admin").block()); + verify(mongo).findById(owner, EnterpriseLicenseCapability.class); + verifyNoMoreInteractions(mongo); + verifyNoInteractions(encryption); + } + + @Test void legacyCredentialsNeedDiscoveryButEmptyMigratedAccountsDoNot() { + when(mongo.findById(owner, EnterpriseLicenseCapability.class)).thenReturn(Mono.just(record(null, false))); + assertEquals(PurchaseState.UNKNOWN, service.purchaseState("host", "org", "admin").block()); + when(mongo.findById(owner, EnterpriseLicenseCapability.class)).thenReturn(Mono.just(record(false, false))); + assertEquals(PurchaseState.NONE, service.purchaseState("host", "org", "admin").block()); + } + + @Test void interruptedCheckoutNeedsDiscoveryWithoutClaimingAPurchase() { + when(mongo.findById(owner, EnterpriseLicenseCapability.class)).thenReturn(Mono.just(record(false, true))); + assertEquals(PurchaseState.UNKNOWN, service.purchaseState("host", "org", "admin").block()); + } + + @Test void confirmedPurchaseSurvivesAnotherCheckoutAttempt() { + when(mongo.findById(owner, EnterpriseLicenseCapability.class)).thenReturn(Mono.just(record(true, true))); + assertEquals(PurchaseState.CONFIRMED, service.purchaseState("host", "org", "admin").block()); + } + + @Test void purchaseStateIsScopedToTheExactDeploymentWorkspaceAndAdmin() { + when(mongo.findById(anyString(), eq(EnterpriseLicenseCapability.class))).thenReturn(Mono.empty()); + when(mongo.findById(owner, EnterpriseLicenseCapability.class)).thenReturn(Mono.just(record(true, false))); + assertEquals(PurchaseState.CONFIRMED, service.purchaseState("host", "org", "admin").block()); + assertEquals(PurchaseState.NONE, service.purchaseState("other-host", "org", "admin").block()); + assertEquals(PurchaseState.NONE, service.purchaseState("host", "other-org", "admin").block()); + assertEquals(PurchaseState.NONE, service.purchaseState("host", "org", "other-admin").block()); + } + + @Test void negativeDiscoveryUpdateProtectsConcurrentCheckoutAndConfirmation() { + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(EnterpriseLicenseCapability.class))) + .thenAnswer(call -> { + var query = ((Query) call.getArgument(0)).getQueryObject(); + assertEquals(owner, query.getString("_id")); + assertEquals(true, ((org.bson.Document) query.get("purchaseConfirmed")).get("$ne")); + assertEquals(true, ((org.bson.Document) query.get("checkoutAttempted")).get("$ne")); + return Mono.just(UpdateResult.acknowledged(0, 0L, null)); + }); + service.recordPurchaseStatus("host", "org", "admin", false).block(); + verify(mongo).updateFirst(any(Query.class), any(Update.class), eq(EnterpriseLicenseCapability.class)); + } +} diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java index 6595a7550..f61320021 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java @@ -1,7 +1,9 @@ package org.lowcoder.api.subscription; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import org.lowcoder.domain.enterprise.EnterpriseLicenseCapabilityService; +import org.lowcoder.domain.enterprise.EnterpriseLicenseCapabilityService.PurchaseState; import org.lowcoder.api.home.SessionUserService; import org.lowcoder.sdk.config.dynamic.ConfigCenter; import org.lowcoder.sdk.webclient.WebClientBuildHelper; @@ -9,6 +11,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -60,7 +63,7 @@ public Mono> request(@PathVariable String action, @Reques "Enterprise Edition is self-hosted. Purchase a license from your own Lowcoder installation.")); } String hostId = config.deployment().ofString("id", "").get(); - if (hostId == null || hostId.isBlank() || relayUrl.isBlank()) { + if (hostId == null || hostId.isBlank()) { return Mono.error(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Enterprise licensing is not configured for this installation")); } @@ -69,7 +72,9 @@ public Mono> request(@PathVariable String action, @Reques payload.put("hostId", hostId); payload.put("orgId", member.getOrgId()); payload.put("userId", member.getUserId()); - payload.put("issuedAt", System.currentTimeMillis()); + if (action.equals("status") || action.equals("sync")) { + return library(payload, action.equals("status") && body.path("refresh").asBoolean(false)); + } if (action.equals("checkout")) { JsonNode deployments = body.path("deploymentIds"); if (!deployments.isArray() || deployments.size() < 1 || deployments.size() > 3 || @@ -85,7 +90,11 @@ public Mono> request(@PathVariable String action, @Reques if (action.equals("portal")) payload.put("requestId", body.path("requestId").asText()); if (action.equals("checkout") || action.equals("portal")) { String origin = publicUrl.isBlank() ? body.path("returnOrigin").asText() : publicUrl; - URI base = URI.create(origin); + URI base; + try { base = URI.create(origin); } + catch (IllegalArgumentException error) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid Enterprise return origin"); + } // Local development may return over HTTP. Payment and relay traffic still use HTTPS. boolean loopback = base.getHost() != null && List.of("localhost", "127.0.0.1", "[::1]").contains(base.getHost()); if (!("https".equals(base.getScheme()) || ("http".equals(base.getScheme()) && loopback)) || @@ -98,10 +107,77 @@ public Mono> request(@PathVariable String action, @Reques payload.put("returnUrl", origin.replaceAll("/+$", "") + "/setting/subscription?enterpriseLicense=return"); } return capabilities.token(hostId, member.getOrgId(), member.getUserId()) - .flatMap(token -> send(payload, token)); + .flatMap(token -> { + if (!action.equals("checkout")) return relay(payload, token); + return capabilities.checkoutAttempted(hostId, member.getOrgId(), member.getUserId()) + .then(Mono.defer(() -> relay(payload, token))) + .flatMap(result -> capabilities.recordPurchaseStatus(hostId, member.getOrgId(), member.getUserId(), true) + .thenReturn(result)); + }); }).map(ResponseView::success); } + private Mono library(Map payload, boolean refresh) { + String hostId = (String) payload.get("hostId"), orgId = (String) payload.get("orgId"), userId = (String) payload.get("userId"); + return capabilities.purchaseState(hostId, orgId, userId).flatMap(state -> { + if (state == PurchaseState.NONE && !refresh) { + var empty = JsonNodeFactory.instance.objectNode().put("success", true); + empty.putArray("orders"); + empty.putArray("licenses"); + return Mono.just(empty); + } + return capabilities.token(hostId, orgId, userId).flatMap(token -> { + Map lookup = new LinkedHashMap<>(payload); + // Legacy credentials and ambiguous checkouts are discovered without issuing files. + if (state != PurchaseState.CONFIRMED) lookup.put("action", "status"); + return relay(lookup, token).flatMap(result -> { + boolean purchased = hasPurchase(result); + return capabilities.recordPurchaseStatus(hostId, orgId, userId, purchased) + .then(Mono.defer(() -> "sync".equals(payload.get("action")) && + "status".equals(lookup.get("action")) && purchased + ? relay(payload, token) : Mono.just(result))); + }); + }); + }); + } + + private static boolean hasPurchase(JsonNode result) { + return !result.path("orders").isEmpty() || !result.path("licenses").isEmpty(); + } + + private Mono relay(Map payload, String token) { + return Mono.defer(() -> { + // Database work must not consume the relay's authenticated-request freshness window. + payload.put("issuedAt", System.currentTimeMillis()); + return send(payload, token); + }).filter(result -> { + if (!result.path("success").asBoolean(false)) return false; + String action = (String) payload.get("action"); + if (action.equals("status") || action.equals("sync")) { + return result.path("orders").isArray() && result.path("licenses").isArray(); + } + if (action.equals("checkout")) { + return !result.path("checkoutUrl").asText().isBlank() && + result.path("requestId").equals(payload.get("requestId")); + } + return true; + }).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Enterprise license service returned an invalid response. Please retry shortly."))) + .onErrorMap(error -> !(error instanceof ResponseStatusException), error -> + new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Enterprise license service is temporarily unavailable. Please retry shortly.", error)); + } + + // The global handler converts non-plugin ResponseStatusExceptions to 500. Keep the + // licensing API's authorization, validation and availability statuses intact locally. + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> licensingError(ResponseStatusException error) { + var response = ResponseEntity.status(error.getStatusCode()); + if (error.getStatusCode().value() == 503) response.header("Retry-After", "60"); + return response.body(ResponseView.error(error.getStatusCode().value(), + error.getReason() == null ? "Enterprise licensing request could not be completed" : error.getReason())); + } + private static boolean isPublicCloudUrl(String url) { try { String host = URI.create(url).getHost(); @@ -112,7 +188,11 @@ private static boolean isPublicCloudUrl(String url) { } Mono send(Map payload, String token) { - URI target = URI.create(relayUrl); + URI target; + try { target = URI.create(relayUrl); } + catch (IllegalArgumentException error) { + return Mono.error(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Enterprise relay requires HTTPS")); + } if (!"https".equals(target.getScheme()) || target.getHost() == null || target.getUserInfo() != null) { return Mono.error(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Enterprise relay requires HTTPS")); } diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java index 9f6cdaecf..901c252b8 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java @@ -3,6 +3,15 @@ import org.junit.jupiter.api.*; import org.lowcoder.api.home.SessionUserService; import org.lowcoder.domain.enterprise.EnterpriseLicenseCapabilityService; +import org.lowcoder.domain.enterprise.EnterpriseLicenseCapabilityService.PurchaseState; +import org.lowcoder.api.framework.exception.GlobalExceptionHandler; +import org.lowcoder.api.framework.exception.ApiPerfHelper; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.http.HttpHeaders; +import java.nio.charset.StandardCharsets; import org.lowcoder.domain.organization.model.*; import org.lowcoder.sdk.config.dynamic.ConfigCenter; import org.springframework.web.server.ResponseStatusException; @@ -23,20 +32,40 @@ class EnterpriseLicenseControllerTest { EnterpriseLicenseController controller; JsonNode body; final ServerHttpRequest request = MockServerHttpRequest.post("https://ui.example/api/enterprise-licenses/checkout").build(); + JsonNode relayResult() { + return mapper.valueToTree(Map.of("success", true, "requestId", "00000000-0000-4000-8000-000000000001", + "checkoutUrl", "https://checkout.stripe.com/c/pay/test", "orders", List.of(Map.of("requestId", "existing")), "licenses", List.of())); + } + JsonNode emptyLibrary() { return mapper.valueToTree(Map.of("success", true, "orders", List.of(), "licenses", List.of())); } + final List contexts = new ArrayList<>(); + WebTestClient webClient() { + var context = new AnnotationConfigApplicationContext(); + contexts.add(context); + context.register(DelegatingWebFluxConfiguration.class); + context.registerBean(EnterpriseLicenseController.class, () -> controller); + context.getBeanFactory().registerSingleton("apiPerfHelper", mock(ApiPerfHelper.class, withSettings().mockMaker("mock-maker-subclass"))); + context.registerBean(GlobalExceptionHandler.class); + context.refresh(); + return WebTestClient.bindToApplicationContext(context).build(); + } + @AfterEach void closeContexts() { contexts.forEach(AnnotationConfigApplicationContext::close); } @BeforeEach void setup() { controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass").useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise","https://ui.example").defaultAnswer(CALLS_REAL_METHODS)); + when(capabilities.purchaseState("real-deployment", "org", "real-admin")).thenReturn(Mono.just(PurchaseState.CONFIRMED)); + when(capabilities.checkoutAttempted(anyString(), anyString(), anyString())).thenReturn(Mono.empty()); + when(capabilities.recordPurchaseStatus(anyString(), anyString(), anyString(), anyBoolean())).thenReturn(Mono.empty()); when(capabilities.token("real-deployment","org","real-admin")).thenReturn(Mono.just("private-test-token")); when(sessions.getVisitorOrgMember()).thenReturn(Mono.just(new OrgMember("org","real-admin",MemberRole.ADMIN,"normal",0))); when(config.deployment().ofString("id", "").get()).thenReturn("real-deployment"); body=mapper.valueToTree(Map.of("orgId","org","userId","forged","hostId","forged","requestId","00000000-0000-4000-8000-000000000001", "billingInterval","year","deploymentIds",List.of("real-deployment","other-deployment"),"contactData",Map.of("companyName","Company"))); - doReturn(Mono.just(mapper.valueToTree(Map.of("success",true)))).when(controller).send(anyMap(),eq("private-test-token")); + doReturn(Mono.just(relayResult())).when(controller).send(anyMap(),eq("private-test-token")); } @Test void ownerAndCurrentDeploymentComeOnlyFromSessionAndServerConfig() { doAnswer(call->{ Map p=call.getArgument(0); assertEquals("real-admin",p.get("userId")); assertEquals("real-deployment",p.get("hostId")); assertEquals("org",p.get("orgId")); assertEquals("https://ui.example/setting/subscription?enterpriseLicense=return",p.get("returnUrl")); assertFalse(p.containsKey("priceId")); - return Mono.just(mapper.valueToTree(Map.of("success",true))); }).when(controller).send(anyMap(),eq("private-test-token")); + return Mono.just(relayResult()); }).when(controller).send(anyMap(),eq("private-test-token")); assertNotNull(controller.request("checkout",body,request).block()); } @Test void membersAndFormerAdminsCannotRetrieveOrGenerateFiles() { @@ -60,12 +89,14 @@ class EnterpriseLicenseControllerTest { @Test void downloadScopeCannotBeOverriddenByRequestFields() { doAnswer(call->{ Map p=call.getArgument(0); assertEquals("real-admin",p.get("userId")); assertEquals("real-deployment",p.get("hostId")); assertFalse(p.containsKey("customerId"));assertFalse(p.containsKey("returnUrl")); - return Mono.just(mapper.valueToTree(Map.of("success",true))); }).when(controller).send(anyMap(),eq("private-test-token")); + return Mono.just(relayResult()); }).when(controller).send(anyMap(),eq("private-test-token")); assertNotNull(controller.request("download",body,request).block()); } @Test void missingRelayConfigurationFailsClosed() { controller=mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass").useConstructor(sessions,config,capabilities,"","https://ui.example").defaultAnswer(CALLS_REAL_METHODS)); - assertThrows(ResponseStatusException.class,()->controller.request("checkout",body,request).block());verify(controller,never()).send(anyMap(),eq("private-test-token")); + ResponseStatusException error = assertThrows(ResponseStatusException.class,()->controller.request("checkout",body,request).block()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, error.getStatusCode()); + verify(capabilities, never()).recordPurchaseStatus(anyString(), anyString(), anyString(), anyBoolean()); } @Test void localCheckoutAndPortalCanReturnToTheDevelopmentUi() { controller=mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass").useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise","").defaultAnswer(CALLS_REAL_METHODS)); @@ -73,7 +104,7 @@ class EnterpriseLicenseControllerTest { ((com.fasterxml.jackson.databind.node.ObjectNode)body).put("returnOrigin",origin); doAnswer(call->{ Map p=call.getArgument(0); assertEquals(origin+"/setting/subscription?enterpriseLicense=return",p.get("returnUrl")); - return Mono.just(mapper.valueToTree(Map.of("success",true))); }).when(controller).send(anyMap(),eq("private-test-token")); + return Mono.just(relayResult()); }).when(controller).send(anyMap(),eq("private-test-token")); for (String action:List.of("checkout", "portal")) assertNotNull(controller.request(action,body,request).block()); } } @@ -110,7 +141,7 @@ class EnterpriseLicenseControllerTest { "https://app.lowcoder.cloud.example", "https://my-lowcoder.example")) { controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass") .useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise",origin).defaultAnswer(CALLS_REAL_METHODS)); - doReturn(Mono.just(mapper.valueToTree(Map.of("success",true)))).when(controller).send(anyMap(),anyString()); + doReturn(Mono.just(relayResult())).when(controller).send(anyMap(),anyString()); assertNotNull(controller.request("checkout",body,MockServerHttpRequest.post(origin + "/api/enterprise-licenses/checkout").build()).block()); verify(controller).send(anyMap(),eq("private-test-token")); } @@ -118,7 +149,7 @@ class EnterpriseLicenseControllerTest { @Test void existingCloudPurchasesRemainManageableWithoutNewCheckout() { controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass") .useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise","https://app.lowcoder.cloud").defaultAnswer(CALLS_REAL_METHODS)); - doReturn(Mono.just(mapper.valueToTree(Map.of("success",true)))).when(controller).send(anyMap(),anyString()); + doReturn(Mono.just(relayResult())).when(controller).send(anyMap(),anyString()); for (String action : List.of("status", "download", "portal")) { assertNotNull(controller.request(action,body,request).block()); } @@ -133,4 +164,96 @@ class EnterpriseLicenseControllerTest { } verify(controller,never()).send(anyMap(),anyString()); } + + @Test void communityStatusAndOldClientSyncStayLocalWithoutCreatingCredentials() { + when(capabilities.purchaseState("real-deployment", "org", "real-admin")).thenReturn(Mono.just(PurchaseState.NONE)); + for (String action : List.of("status", "sync")) { + assertEquals(emptyLibrary(), controller.request(action, body, request).block().getData()); + } + verify(capabilities, never()).token(anyString(), anyString(), anyString()); + verify(controller, never()).send(anyMap(), anyString()); + } + + @Test void openingLibraryExplicitlyCanDiscoverAnExistingPurchase() { + when(capabilities.purchaseState("real-deployment", "org", "real-admin")).thenReturn(Mono.just(PurchaseState.NONE)); + ((com.fasterxml.jackson.databind.node.ObjectNode) body).put("refresh", true); + controller.request("status", body, request).block(); + verify(controller).send(argThat(p -> "status".equals(p.get("action"))), anyString()); + verify(capabilities).recordPurchaseStatus("real-deployment", "org", "real-admin", true); + } + + @Test void legacyCommunityCredentialIsCheckedWithoutGeneratingFiles() { + when(capabilities.purchaseState("real-deployment", "org", "real-admin")).thenReturn(Mono.just(PurchaseState.UNKNOWN)); + doReturn(Mono.just(emptyLibrary())).when(controller).send(anyMap(), anyString()); + assertEquals(emptyLibrary(), controller.request("sync", body, request).block().getData()); + verify(controller).send(argThat(p -> "status".equals(p.get("action"))), anyString()); + verify(controller, never()).send(argThat(p -> "sync".equals(p.get("action"))), anyString()); + verify(capabilities).recordPurchaseStatus("real-deployment", "org", "real-admin", false); + } + + @Test void legacyPurchaseOrInterruptedCheckoutCanActivateBeforeAnyLicenseIsInstalled() { + when(capabilities.purchaseState("real-deployment", "org", "real-admin")).thenReturn(Mono.just(PurchaseState.UNKNOWN)); + assertNotNull(controller.request("sync", body, request).block()); + var sequence = inOrder(controller, capabilities); + sequence.verify(controller).send(argThat(p -> "status".equals(p.get("action"))), anyString()); + sequence.verify(capabilities).recordPurchaseStatus("real-deployment", "org", "real-admin", true); + sequence.verify(controller).send(argThat(p -> "sync".equals(p.get("action"))), anyString()); + } + + @Test void confirmedPurchaseContinuesToSyncForRenewals() { + controller.request("sync", body, request).block(); + verify(controller).send(argThat(p -> "sync".equals(p.get("action"))), anyString()); + verify(controller, never()).send(argThat(p -> "status".equals(p.get("action"))), anyString()); + } + + @Test void checkoutIsRecordedBeforeRelayAndConfirmedOnlyAfterSuccess() { + controller.request("checkout", body, request).block(); + var sequence = inOrder(controller, capabilities); + sequence.verify(capabilities).checkoutAttempted("real-deployment", "org", "real-admin"); + sequence.verify(controller).send(anyMap(), anyString()); + sequence.verify(capabilities).recordPurchaseStatus("real-deployment", "org", "real-admin", true); + } + + @Test void ambiguousCheckoutRemainsDiscoverableWithoutClaimingItSucceeded() { + doReturn(Mono.error(new java.util.concurrent.TimeoutException("private relay detail"))).when(controller).send(anyMap(), anyString()); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.request("checkout", body, request).block()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, error.getStatusCode()); + verify(capabilities).checkoutAttempted("real-deployment", "org", "real-admin"); + verify(capabilities, never()).recordPurchaseStatus(anyString(), anyString(), anyString(), anyBoolean()); + } + + @Test void invalidRelayResultsCannotDisableRenewalsOrConfirmPurchases() { + for (JsonNode result : List.of(mapper.createObjectNode(), mapper.valueToTree(Map.of("success", true)), + mapper.valueToTree(Map.of("success", false, "orders", List.of(), "licenses", List.of())))) { + doReturn(Mono.just(result)).when(controller).send(anyMap(), anyString()); + for (String action : List.of("status", "sync", "checkout")) { + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.request(action, body, request).block()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, error.getStatusCode()); + } + } + verify(capabilities, never()).recordPurchaseStatus(anyString(), anyString(), anyString(), anyBoolean()); + } + + @Test void relayFailuresReturn503WithRetryAfterInsteadOfGlobal500() { + WebTestClient client = webClient(); + for (Throwable cause : List.of(new java.net.UnknownHostException("private relay detail"), + new java.io.IOException("Connection prematurely closed"), new java.util.concurrent.TimeoutException(), + WebClientResponseException.create(500, "upstream", HttpHeaders.EMPTY, "private detail".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))) { + doReturn(Mono.error(cause)).when(controller).send(anyMap(), anyString()); + client.post().uri("/api/enterprise-licenses/sync").bodyValue(body).exchange() + .expectStatus().isEqualTo(503).expectHeader().valueEquals("Retry-After", "60") + .expectBody().jsonPath("$.code").isEqualTo(503) + .jsonPath("$.message").isEqualTo("Enterprise license service is temporarily unavailable. Please retry shortly."); + } + } + + @Test void authorizationAndValidationKeepTheirHttpStatus() { + WebTestClient client = webClient(); + ((com.fasterxml.jackson.databind.node.ObjectNode) body).set("deploymentIds", mapper.valueToTree(List.of("foreign"))); + client.post().uri("/api/enterprise-licenses/checkout").bodyValue(body).exchange().expectStatus().isBadRequest(); + when(sessions.getVisitorOrgMember()).thenReturn(Mono.just(new OrgMember("org", "real-admin", MemberRole.MEMBER, "normal", 0))); + client.post().uri("/api/enterprise-licenses/status").bodyValue(body).exchange().expectStatus().isForbidden(); + when(sessions.getVisitorOrgMember()).thenReturn(Mono.empty()); + client.post().uri("/api/enterprise-licenses/status").bodyValue(body).exchange().expectStatus().isUnauthorized(); + } } From c93e8b12471425ed23a7953afa361f8ab303d157 Mon Sep 17 00:00:00 2001 From: FalkWolsky Date: Tue, 13 Oct 2026 17:22:09 +0200 Subject: [PATCH 2/4] Branding for refreshed UI --- .../src/components/GlobalInstances.tsx | 32 +++-- .../src/components/adminThemeVariables.ts | 8 ++ .../lowcoder-design/src/components/button.tsx | 112 +++++++++--------- .../lowcoder/admin-branding-preview.html | 5 + .../src/components/layout/AdminBrand.tsx | 24 ++++ .../src/components/layout/AdminSidebar.tsx | 24 ++++ .../src/components/layout/BRANDING.md | 32 +++++ .../lowcoder/src/components/layout/Layout.tsx | 43 +------ .../src/components/layout/SideBarItem.tsx | 10 +- .../src/components/layout/SubSideBar.tsx | 12 +- .../components/layout/adminAppearance.test.ts | 43 +++++++ .../src/components/layout/adminAppearance.ts | 99 ++++++++++++++++ .../src/components/layout/adminTheme.test.tsx | 60 ++++++++++ .../src/components/layout/adminTheme.tsx | 101 ++++++---------- .../src/dev-utils/admin-branding-preview.tsx | 88 ++++++++++++++ .../packages/lowcoder/src/i18n/locales/de.ts | 6 +- .../packages/lowcoder/src/i18n/locales/en.ts | 6 +- .../packages/lowcoder/src/i18n/locales/es.ts | 6 +- .../packages/lowcoder/src/i18n/locales/it.ts | 6 +- .../packages/lowcoder/src/i18n/locales/pt.ts | 6 +- .../packages/lowcoder/src/i18n/locales/ru.ts | 6 +- .../packages/lowcoder/src/i18n/locales/zh.ts | 6 +- .../src/pages/ApplicationV2/HomeLayout.tsx | 4 +- .../src/pages/ApplicationV2/HomeResCard.tsx | 22 ++-- .../src/pages/ApplicationV2/HomeTableView.tsx | 6 +- .../pages/ApplicationV2/MarketplaceView.tsx | 8 +- .../src/pages/ApplicationV2/NewsLayout.tsx | 5 +- .../pages/ApplicationV2/TrashTableView.tsx | 2 +- .../pages/ApplicationV2/UserProfileLayout.tsx | 12 +- .../marketplace/MarketplacePreview.tsx | 4 +- .../marketplace/marketplaceStyles.tsx | 28 ++--- .../lowcoder/src/pages/common/header.tsx | 12 +- .../lowcoder/src/pages/common/help.tsx | 8 +- .../src/pages/datasource/datasourceList.tsx | 2 +- .../src/pages/queryLibrary/LeftNav.tsx | 16 +-- .../src/pages/setting/settingHome.tsx | 1 - .../setting/subscriptions/productCard.tsx | 4 +- .../reducers/uiReducers/enterpriseReducer.ts | 8 +- .../redux/sagas/enterpriseBranding.test.ts | 74 ++++++++++++ .../src/redux/sagas/enterpriseSagas.ts | 41 +++---- .../redux/selectors/enterpriseSelectors.ts | 6 +- client/packages/lowcoder/vite.config.mts | 1 + 42 files changed, 713 insertions(+), 286 deletions(-) create mode 100644 client/packages/lowcoder-design/src/components/adminThemeVariables.ts create mode 100644 client/packages/lowcoder/admin-branding-preview.html create mode 100644 client/packages/lowcoder/src/components/layout/AdminBrand.tsx create mode 100644 client/packages/lowcoder/src/components/layout/AdminSidebar.tsx create mode 100644 client/packages/lowcoder/src/components/layout/BRANDING.md create mode 100644 client/packages/lowcoder/src/components/layout/adminAppearance.test.ts create mode 100644 client/packages/lowcoder/src/components/layout/adminAppearance.ts create mode 100644 client/packages/lowcoder/src/components/layout/adminTheme.test.tsx create mode 100644 client/packages/lowcoder/src/dev-utils/admin-branding-preview.tsx create mode 100644 client/packages/lowcoder/src/redux/sagas/enterpriseBranding.test.ts diff --git a/client/packages/lowcoder-design/src/components/GlobalInstances.tsx b/client/packages/lowcoder-design/src/components/GlobalInstances.tsx index 19b2ec8e8..2683f1813 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 000000000..f9358c852 --- /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 469c79f84..26183a255 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 000000000..388cd6258 --- /dev/null +++ b/client/packages/lowcoder/admin-branding-preview.html @@ -0,0 +1,5 @@ + + + Admin branding preview +
+ 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 000000000..abe558300 --- /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 000000000..df9c455dd --- /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 000000000..9ddf52dcf --- /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 cfd72de1d..d459ec018 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 1e052307e..17b417dc8 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 37cf49b0d..a26ad53ed 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 000000000..73ef92383 --- /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 000000000..05c1e59da --- /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 000000000..509419c37 --- /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 ce3efe963..247a3237d 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 000000000..59237e30d --- /dev/null +++ b/client/packages/lowcoder/src/dev-utils/admin-branding-preview.tsx @@ -0,0 +1,88 @@ +// 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, Table } 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"; + +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 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" } } }, + })), [brand]); + return + {brands[brand].font && } +
+ Branding previewSample data · no settings are saved + } placeholder="Search apps, creators and categories" />} placeholder="Search apps, creators and categories" />