From ab328365ea834f4cd40f99ef71fa2e5f4c40a367 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 17:50:32 -0300 Subject: [PATCH 1/4] feat(config): let a consumer hand uicore its settings via setConfig Settings registered through setConfig win over the window globals uicore has always read; unset keys keep the window fallback, so existing consumers are unaffected. A key set to undefined or an empty string is unset; null, false and 0 are values (oauth2UseRefreshToken: false turns the refresh flow off). The state lives on globalThis under Symbol.for('openstack-uicore-foundation.config'), so every copy of the module shares it: bundles that inline it, nested installs, and symlinked dev checkouts. --- src/components/clock.js | 2 +- src/components/exclusive-wrapper.js | 3 +- .../security/__tests__/get-user-info.test.js | 3 +- .../abstract-auth-callback-route-v2.js | 2 +- .../security/abstract-auth-callback-route.js | 2 +- src/components/security/actions.js | 2 +- src/components/security/methods.js | 46 ++----- src/components/security/reducers.js | 3 +- src/utils/__tests__/config-no-window.test.js | 54 ++++++++ src/utils/__tests__/config-reads.test.js | 109 +++++++++++++++ src/utils/__tests__/config.test.js | 127 ++++++++++++++++++ src/utils/config.js | 71 ++++++++++ src/utils/methods.js | 21 +-- src/utils/query-actions.js | 2 +- webpack.common.js | 1 + 15 files changed, 383 insertions(+), 65 deletions(-) create mode 100644 src/utils/__tests__/config-no-window.test.js create mode 100644 src/utils/__tests__/config-reads.test.js create mode 100644 src/utils/__tests__/config.test.js create mode 100644 src/utils/config.js diff --git a/src/components/clock.js b/src/components/clock.js index e5fc4fa7..2b6adb47 100644 --- a/src/components/clock.js +++ b/src/components/clock.js @@ -13,7 +13,7 @@ import React from 'react'; import moment from "moment-timezone"; import FragmentParser from "./fragment-parser"; -import {getTimeServiceUrl} from '../utils/methods'; +import {getTimeServiceUrl} from '../utils/config'; /** * class Clock diff --git a/src/components/exclusive-wrapper.js b/src/components/exclusive-wrapper.js index 5b3a4b7b..7da5e502 100644 --- a/src/components/exclusive-wrapper.js +++ b/src/components/exclusive-wrapper.js @@ -12,6 +12,7 @@ **/ import React from 'react' +import { getExclusiveSections } from '../utils/config'; export default class Exclusive extends React.Component { @@ -24,7 +25,7 @@ export default class Exclusive extends React.Component { showField() { let {name} = this.props; - let exclusiveSections = window.EXCLUSIVE_SECTIONS; + let exclusiveSections = getExclusiveSections(); return exclusiveSections ? exclusiveSections.includes(name) : false; } diff --git a/src/components/security/__tests__/get-user-info.test.js b/src/components/security/__tests__/get-user-info.test.js index 669fb02e..9e50baf1 100644 --- a/src/components/security/__tests__/get-user-info.test.js +++ b/src/components/security/__tests__/get-user-info.test.js @@ -11,7 +11,8 @@ jest.mock("../../../utils/actions", () => ({ startLoading: jest.fn(() => ({ type: "START_LOADING" })), stopLoading: jest.fn(() => ({ type: "STOP_LOADING" })), })); -jest.mock("../../../utils/methods", () => ({ +jest.mock("../../../utils/config", () => ({ + ...jest.requireActual("../../../utils/config"), buildAPIBaseUrl: jest.fn((p) => `BASE${p}`), getAllowedUserGroups: jest.fn(() => ""), })); diff --git a/src/components/security/abstract-auth-callback-route-v2.js b/src/components/security/abstract-auth-callback-route-v2.js index 5374001f..5ff63453 100644 --- a/src/components/security/abstract-auth-callback-route-v2.js +++ b/src/components/security/abstract-auth-callback-route-v2.js @@ -17,12 +17,12 @@ import URI from "urijs"; import { doLogin, emitAccessToken, - getOAuth2Flow, RESPONSE_TYPE_IMPLICIT, RESPONSE_TYPE_CODE, validateIdToken, getAuthInfo } from "./methods"; import {getCurrentPathName, getCurrentHref} from '../../utils/methods'; +import {getOAuth2Flow} from '../../utils/config'; const AbstractAuthorizationCallbackRouteV2 = ({issuer, audience, location, callback, redirectToError, onUserAuth}) => { // we only use this for the redirectToError, so its initial state should be true diff --git a/src/components/security/abstract-auth-callback-route.js b/src/components/security/abstract-auth-callback-route.js index 880e811b..624fd0eb 100644 --- a/src/components/security/abstract-auth-callback-route.js +++ b/src/components/security/abstract-auth-callback-route.js @@ -15,12 +15,12 @@ import URI from "urijs"; import { doLogin, emitAccessToken, - getOAuth2Flow, RESPONSE_TYPE_IMPLICIT, RESPONSE_TYPE_CODE, validateIdToken } from "./methods"; import {getCurrentPathName, getCurrentHref} from '../../utils/methods'; +import {getOAuth2Flow} from '../../utils/config'; class AbstractAuthorizationCallbackRoute extends React.Component { diff --git a/src/components/security/actions.js b/src/components/security/actions.js index 65a84d8d..64009564 100644 --- a/src/components/security/actions.js +++ b/src/components/security/actions.js @@ -13,7 +13,7 @@ import T from "i18n-react/dist/i18n-react"; import {authErrorHandler, createAction, getRequest, showMessage, startLoading, stopLoading} from "../../utils/actions"; -import {buildAPIBaseUrl, getAllowedUserGroups} from '../../utils/methods'; +import {buildAPIBaseUrl, getAllowedUserGroups} from '../../utils/config'; import { getAccessToken, storeAuthInfo, initLogOut} from './methods'; /** diff --git a/src/components/security/methods.js b/src/components/security/methods.js index 729a2772..7cc55e14 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -18,6 +18,13 @@ import URI from "urijs"; import IdTokenVerifier from "idtoken-verifier"; import {SET_LOGGED_USER} from "./actions"; import {getRandomBytes, getSHA256} from "../../utils/crypto"; +import { + getOAuth2ClientId, + getOAuth2Flow, + useOAuth2RefreshToken, + getOAuth2IDPBaseUrl, + getOAuth2Scopes, +} from "../../utils/config"; import { AUTH_ERROR_ACCESS_TOKEN_EXPIRED, @@ -32,6 +39,8 @@ import { AUTH_ERROR_MISSING_NONCE_PARAM, } from "./constants"; +export { getOAuth2ClientId, getOAuth2Flow, useOAuth2RefreshToken, getOAuth2IDPBaseUrl, getOAuth2Scopes } from "../../utils/config"; + /** * @ignore */ @@ -51,7 +60,6 @@ const PKCE = 'pkce'; const ID_TOKEN = 'idToken'; const BACK_ULR_PARAM_NAME = 'BackUrl'; - /** * * @param backUrl @@ -448,7 +456,6 @@ export const clearAccessToken = async () => { } } - export const refreshAccessToken = async (refresh_token) => { let baseUrl = getOAuth2IDPBaseUrl(); @@ -569,41 +576,6 @@ export const getIdToken = () => { return null; }; -export const getOAuth2ClientId = () => { - if (typeof window !== 'undefined') { - return window.OAUTH2_CLIENT_ID; - } - return null; -}; - -export const getOAuth2Flow = () => { - if (typeof window !== 'undefined') { - return window.OAUTH2_FLOW || "token id_token"; - } - return "token id_token"; -} - -export const useOAuth2RefreshToken = () => { - if (typeof window !== 'undefined') { - return new Boolean(window.OAUTH2_USE_REFRESH_TOKEN || true); - } - return true; -} - -export const getOAuth2IDPBaseUrl = () => { - if (typeof window !== 'undefined') { - return window.IDP_BASE_URL; - } - return null; -}; - -export const getOAuth2Scopes = () => { - if (typeof window !== 'undefined') { - return window.SCOPES; - } - return null; -}; - export const initLogOut = () => { let location = getCurrentLocation(); location.replace(getLogoutUrl(getIdToken()).toString()); diff --git a/src/components/security/reducers.js b/src/components/security/reducers.js index bfffbcca..6285651d 100644 --- a/src/components/security/reducers.js +++ b/src/components/security/reducers.js @@ -23,7 +23,8 @@ import { import IdTokenVerifier from 'idtoken-verifier'; -import {clearAuthInfo, getIdToken, getOAuth2ClientId, getOAuth2IDPBaseUrl} from './methods'; +import {clearAuthInfo, getIdToken} from './methods'; +import {getOAuth2ClientId, getOAuth2IDPBaseUrl} from '../../utils/config'; const DEFAULT_STATE = { isLoggedUser: false, diff --git a/src/utils/__tests__/config-no-window.test.js b/src/utils/__tests__/config-no-window.test.js new file mode 100644 index 00000000..d8649e6d --- /dev/null +++ b/src/utils/__tests__/config-no-window.test.js @@ -0,0 +1,54 @@ +/** + * @jest-environment node + * + * With no window (server rendering) every getter returns its documented + * default, and a configured value is still honored. + */ +import { + setConfig, + buildAPIBaseUrl, + getTimeServiceUrl, + getAllowedUserGroups, + getOAuth2ClientId, + getOAuth2Flow, + useOAuth2RefreshToken, + getOAuth2IDPBaseUrl, + getOAuth2Scopes, + getExclusiveSections, +} from "../config"; + +describe("config getters without a window", () => { + // The jest config plants a `window` global even in the node environment; + // the getters check `typeof window` at call time, so remove it here. + let savedWindow; + beforeAll(() => { + savedWindow = global.window; + delete global.window; + }); + afterAll(() => { + global.window = savedWindow; + }); + afterEach(() => setConfig({})); + + test("there is no window in this environment", () => { + expect(typeof window).toBe("undefined"); + }); + + test("each getter returns its no-window default", () => { + expect(buildAPIBaseUrl("/x")).toBe(null); + expect(getTimeServiceUrl()).toBe(null); + expect(getAllowedUserGroups()).toBe(null); + expect(getOAuth2ClientId()).toBe(null); + expect(getOAuth2Flow()).toBe("token id_token"); + expect(useOAuth2RefreshToken()).toBe(true); + expect(getOAuth2IDPBaseUrl()).toBe(null); + expect(getOAuth2Scopes()).toBe(null); + expect(getExclusiveSections()).toBe(undefined); + }); + + test("a configured value is returned without a window", () => { + setConfig({ apiBaseUrl: "https://cfg.test", oauth2Flow: "code" }); + expect(buildAPIBaseUrl("/x")).toBe("https://cfg.test/x"); + expect(getOAuth2Flow()).toBe("code"); + }); +}); diff --git a/src/utils/__tests__/config-reads.test.js b/src/utils/__tests__/config-reads.test.js new file mode 100644 index 00000000..8337b77a --- /dev/null +++ b/src/utils/__tests__/config-reads.test.js @@ -0,0 +1,109 @@ +/** + * Every app-level setting uicore needs is read from a window global. These + * tests pin which global each getter reads and what it returns when the + * global is unset. + */ +import React from "react"; +import Enzyme, { mount } from "enzyme"; +import Adapter from "enzyme-adapter-react-16"; +import { + buildAPIBaseUrl, + getTimeServiceUrl, + getAllowedUserGroups, + getOAuth2ClientId, + getOAuth2Flow, + useOAuth2RefreshToken, + getOAuth2IDPBaseUrl, + getOAuth2Scopes, +} from "../config"; +import * as utilsMethods from "../methods"; +import * as securityMethods from "../../components/security/methods"; +import Exclusive from "../../components/exclusive-wrapper"; + +Enzyme.configure({ adapter: new Adapter() }); + +const GLOBALS = [ + "API_BASE_URL", "TIMEINTERVALSINCE1970_API_URL", "ALLOWED_USER_GROUPS", + "OAUTH2_CLIENT_ID", "OAUTH2_FLOW", "OAUTH2_USE_REFRESH_TOKEN", "IDP_BASE_URL", + "SCOPES", "EXCLUSIVE_SECTIONS", +]; + +describe("config reads from window globals", () => { + let origEnvTime; + + beforeEach(() => { + origEnvTime = process.env.TIMEINTERVALSINCE1970_API_URL; + delete process.env.TIMEINTERVALSINCE1970_API_URL; + GLOBALS.forEach((k) => delete window[k]); + }); + + afterEach(() => { + if (origEnvTime === undefined) delete process.env.TIMEINTERVALSINCE1970_API_URL; + else process.env.TIMEINTERVALSINCE1970_API_URL = origEnvTime; + GLOBALS.forEach((k) => delete window[k]); + }); + + test("buildAPIBaseUrl prepends window.API_BASE_URL", () => { + window.API_BASE_URL = "https://api.test"; + expect(buildAPIBaseUrl("/api/v1/summits")).toBe("https://api.test/api/v1/summits"); + }); + + test("getTimeServiceUrl reads window.TIMEINTERVALSINCE1970_API_URL, else the env var", () => { + window.TIMEINTERVALSINCE1970_API_URL = "https://time.test"; + expect(getTimeServiceUrl()).toBe("https://time.test"); + delete window.TIMEINTERVALSINCE1970_API_URL; + process.env.TIMEINTERVALSINCE1970_API_URL = "https://time.env"; + expect(getTimeServiceUrl()).toBe("https://time.env"); + }); + + test("getAllowedUserGroups reads window.ALLOWED_USER_GROUPS, else ''", () => { + expect(getAllowedUserGroups()).toBe(""); + window.ALLOWED_USER_GROUPS = "admins"; + expect(getAllowedUserGroups()).toBe("admins"); + }); + + test("getOAuth2ClientId reads window.OAUTH2_CLIENT_ID", () => { + window.OAUTH2_CLIENT_ID = "cid"; + expect(getOAuth2ClientId()).toBe("cid"); + }); + + test("getOAuth2Flow reads window.OAUTH2_FLOW, else 'token id_token'", () => { + expect(getOAuth2Flow()).toBe("token id_token"); + window.OAUTH2_FLOW = "code"; + expect(getOAuth2Flow()).toBe("code"); + }); + + test("useOAuth2RefreshToken is truthy from the window global, even when it is false", () => { + expect(useOAuth2RefreshToken()).toBeTruthy(); + window.OAUTH2_USE_REFRESH_TOKEN = false; + expect(useOAuth2RefreshToken()).toBeTruthy(); + }); + + test("getOAuth2IDPBaseUrl reads window.IDP_BASE_URL", () => { + window.IDP_BASE_URL = "https://idp.test"; + expect(getOAuth2IDPBaseUrl()).toBe("https://idp.test"); + }); + + test("getOAuth2Scopes reads window.SCOPES", () => { + window.SCOPES = "openid profile"; + expect(getOAuth2Scopes()).toBe("openid profile"); + }); + + test("Exclusive renders its children only when window.EXCLUSIVE_SECTIONS lists the name", () => { + const tree = () => mount(x); + expect(tree().find("span")).toHaveLength(0); + window.EXCLUSIVE_SECTIONS = ["sponsors"]; + expect(tree().find("span")).toHaveLength(1); + }); + + test("the getters stay exported from utils/methods and security/methods", () => { + expect(utilsMethods.buildAPIBaseUrl).toBe(buildAPIBaseUrl); + expect(utilsMethods.getTimeServiceUrl).toBe(getTimeServiceUrl); + expect(utilsMethods.getAllowedUserGroups).toBe(getAllowedUserGroups); + expect(securityMethods.getOAuth2ClientId).toBe(getOAuth2ClientId); + expect(securityMethods.getOAuth2Flow).toBe(getOAuth2Flow); + expect(securityMethods.useOAuth2RefreshToken).toBe(useOAuth2RefreshToken); + expect(securityMethods.getOAuth2IDPBaseUrl).toBe(getOAuth2IDPBaseUrl); + expect(securityMethods.getOAuth2Scopes).toBe(getOAuth2Scopes); + }); +}); diff --git a/src/utils/__tests__/config.test.js b/src/utils/__tests__/config.test.js new file mode 100644 index 00000000..742d1901 --- /dev/null +++ b/src/utils/__tests__/config.test.js @@ -0,0 +1,127 @@ +/** + * setConfig lets a consumer hand uicore its app settings directly. A value + * set this way wins over the matching window global; anything not set keeps + * reading the global, so consumers that only plant globals are unaffected. + */ +import React from "react"; +import Enzyme, { mount } from "enzyme"; +import Adapter from "enzyme-adapter-react-16"; +import { + setConfig, + getConfig, + buildAPIBaseUrl, + getTimeServiceUrl, + getAllowedUserGroups, + getOAuth2ClientId, + getOAuth2Flow, + useOAuth2RefreshToken, + getOAuth2IDPBaseUrl, + getOAuth2Scopes, + getExclusiveSections, +} from "../config"; +import Exclusive from "../../components/exclusive-wrapper"; + +Enzyme.configure({ adapter: new Adapter() }); + +const WINDOW_GLOBALS = { + API_BASE_URL: "https://window.test", + TIMEINTERVALSINCE1970_API_URL: "https://time.window", + ALLOWED_USER_GROUPS: "window-group", + OAUTH2_CLIENT_ID: "window-cid", + OAUTH2_FLOW: "window-flow", + OAUTH2_USE_REFRESH_TOKEN: true, + IDP_BASE_URL: "https://idp.window", + SCOPES: "window-scopes", + EXCLUSIVE_SECTIONS: ["window-section"], +}; + +describe("setConfig", () => { + beforeEach(() => Object.assign(window, WINDOW_GLOBALS)); + + afterEach(() => { + setConfig({}); + Object.keys(WINDOW_GLOBALS).forEach((k) => delete window[k]); + }); + + test("getConfig returns a copy of what was set; setConfig({}) clears it", () => { + setConfig({ apiBaseUrl: "https://cfg.test" }); + expect(getConfig()).toEqual({ apiBaseUrl: "https://cfg.test" }); + getConfig().apiBaseUrl = "mutated"; + expect(getConfig()).toEqual({ apiBaseUrl: "https://cfg.test" }); + setConfig({}); + expect(getConfig()).toEqual({}); + }); + + test("setConfig replaces the previous object instead of merging", () => { + setConfig({ apiBaseUrl: "https://a.test" }); + setConfig({ scopes: "b" }); + expect(getConfig()).toEqual({ scopes: "b" }); + expect(buildAPIBaseUrl("/x")).toBe("https://window.test/x"); + }); + + test("setConfig with null, undefined or an array clears the config", () => { + setConfig({ apiBaseUrl: "https://a.test" }); + setConfig(null); + expect(getConfig()).toEqual({}); + setConfig({ apiBaseUrl: "https://a.test" }); + setConfig(); + expect(getConfig()).toEqual({}); + setConfig(["x"]); + expect(getConfig()).toEqual({}); + }); + + test("a key that is not configured keeps reading the window global", () => { + setConfig({ scopes: "openid" }); + expect(buildAPIBaseUrl("/x")).toBe("https://window.test/x"); + expect(getOAuth2Flow()).toBe("window-flow"); + }); + + test("every configured value wins over its window global", () => { + setConfig({ + apiBaseUrl: "https://cfg.test", + timeApiUrl: "https://time.cfg", + allowedUserGroups: "cfg-group", + oauth2ClientId: "cfg-cid", + oauth2Flow: "cfg-flow", + oauth2UseRefreshToken: false, + idpBaseUrl: "https://idp.cfg", + scopes: "cfg-scopes", + exclusiveSections: ["sponsors"], + }); + expect(buildAPIBaseUrl("/x")).toBe("https://cfg.test/x"); + expect(getTimeServiceUrl()).toBe("https://time.cfg"); + expect(getAllowedUserGroups()).toBe("cfg-group"); + expect(getOAuth2ClientId()).toBe("cfg-cid"); + expect(getOAuth2Flow()).toBe("cfg-flow"); + expect(useOAuth2RefreshToken()).toBe(false); + expect(getOAuth2IDPBaseUrl()).toBe("https://idp.cfg"); + expect(getOAuth2Scopes()).toBe("cfg-scopes"); + expect(getExclusiveSections()).toEqual(["sponsors"]); + const tree = mount(x); + expect(tree.find("span")).toHaveLength(1); + }); + + test("falsy configured values (0, false, null) win over a truthy global", () => { + setConfig({ scopes: null, oauth2UseRefreshToken: false, oauth2ClientId: 0 }); + expect(getOAuth2Scopes()).toBe(null); + expect(useOAuth2RefreshToken()).toBe(false); + expect(getOAuth2ClientId()).toBe(0); + }); + + test("undefined and empty-string values are treated as not set", () => { + setConfig({ apiBaseUrl: undefined, allowedUserGroups: "" }); + expect(buildAPIBaseUrl("/x")).toBe("https://window.test/x"); + expect(getAllowedUserGroups()).toBe(window.ALLOWED_USER_GROUPS || ""); + }); + + test("a config set through one module copy is visible to a second copy", () => { + // The state rides globalThis under Symbol.for, so duplicate installs of + // the package share it. + setConfig({ oauth2ClientId: "shared-client" }); + let secondCopy; + jest.isolateModules(() => { + secondCopy = require("../config"); + }); + expect(secondCopy.getOAuth2ClientId()).toBe("shared-client"); + }); +}); diff --git a/src/utils/config.js b/src/utils/config.js new file mode 100644 index 00000000..b3e36122 --- /dev/null +++ b/src/utils/config.js @@ -0,0 +1,71 @@ +/** + * App settings handed to uicore by the consumer via setConfig. Each getter + * below returns the configured value when one is set and otherwise falls + * back to the window global uicore has always read. + * + * A key set to undefined or an empty string is UNSET: the fallback applies. + * null, false and 0 are values and win over the global (a configured + * `oauth2UseRefreshToken: false` is the way to turn the refresh flow off). + * setConfig replaces the whole object; setConfig({}) or setConfig() clears it. + * + * useOAuth2RefreshToken: the window fallback keeps its historical shape + * (always truthy), so a configured `oauth2UseRefreshToken: false` is the + * way to turn the refresh-token flow off. + * + * The state lives on globalThis under a Symbol.for key so every copy of + * this module shares it. + */ +const CONFIG_KEY = Symbol.for('openstack-uicore-foundation.config'); + +const readConfig = () => globalThis[CONFIG_KEY] || {}; + +export const setConfig = (next) => { + const source = next && typeof next === 'object' && !Array.isArray(next) ? next : {}; + globalThis[CONFIG_KEY] = Object.fromEntries( + Object.entries(source).filter(([, value]) => value !== undefined && value !== ''), + ); +}; + +export const getConfig = () => ({ ...readConfig() }); + +const hasWindow = () => typeof window !== 'undefined'; + +const configuredOr = (key, ambient) => { + const value = readConfig()[key]; + return value !== undefined ? value : ambient(); +}; + +const getApiBaseUrl = () => + configuredOr('apiBaseUrl', () => (hasWindow() ? window.API_BASE_URL : null)); + +export const buildAPIBaseUrl = (relativeUrl) => { + const base = getApiBaseUrl(); + if (base === null) return null; + return `${base}${relativeUrl}`; +}; + +export const getTimeServiceUrl = () => + configuredOr('timeApiUrl', () => + (hasWindow() ? window.TIMEINTERVALSINCE1970_API_URL || process.env.TIMEINTERVALSINCE1970_API_URL : null)); + +export const getAllowedUserGroups = () => + configuredOr('allowedUserGroups', () => (hasWindow() ? window.ALLOWED_USER_GROUPS || '' : null)); + +export const getOAuth2ClientId = () => + configuredOr('oauth2ClientId', () => (hasWindow() ? window.OAUTH2_CLIENT_ID : null)); + +export const getOAuth2Flow = () => + configuredOr('oauth2Flow', () => (hasWindow() ? window.OAUTH2_FLOW || 'token id_token' : 'token id_token')); + +export const useOAuth2RefreshToken = () => + configuredOr('oauth2UseRefreshToken', () => + (hasWindow() ? new Boolean(window.OAUTH2_USE_REFRESH_TOKEN || true) : true)); + +export const getOAuth2IDPBaseUrl = () => + configuredOr('idpBaseUrl', () => (hasWindow() ? window.IDP_BASE_URL : null)); + +export const getOAuth2Scopes = () => + configuredOr('scopes', () => (hasWindow() ? window.SCOPES : null)); + +export const getExclusiveSections = () => + configuredOr('exclusiveSections', () => (hasWindow() ? window.EXCLUSIVE_SECTIONS : undefined)); diff --git a/src/utils/methods.js b/src/utils/methods.js index fb3a51a4..f4de0eb0 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -116,19 +116,7 @@ export const getCurrentHref = () => { return null; }; -export const getAllowedUserGroups = () => { - if(typeof window !== 'undefined') { - return window.ALLOWED_USER_GROUPS || ''; - } - return null; -}; - -export const buildAPIBaseUrl = (relativeUrl) => { - if(typeof window !== 'undefined'){ - return `${window.API_BASE_URL}${relativeUrl}`; - } - return null``; -}; +export { buildAPIBaseUrl, getAllowedUserGroups, getTimeServiceUrl } from './config'; export const putOnLocalStorage = (key, value) => { if(typeof window !== 'undefined') { @@ -236,13 +224,6 @@ export const retryPromise = async ( return false; } -export const getTimeServiceUrl = () => { - if(typeof window !== 'undefined') { - return window.TIMEINTERVALSINCE1970_API_URL || process.env.TIMEINTERVALSINCE1970_API_URL; - } - return null; -}; - export const getEventLocation = (event, summitVenueCount, summitShowLocDate = null, nowUtc = null) => { const shouldShowVenues = (summitShowLocDate && nowUtc) ? summitShowLocDate * 1000 < nowUtc : true; const locationName = []; diff --git a/src/utils/query-actions.js b/src/utils/query-actions.js index f52012b7..f8769641 100644 --- a/src/utils/query-actions.js +++ b/src/utils/query-actions.js @@ -13,7 +13,7 @@ import { fetchErrorHandler, fetchResponseHandler, escapeFilterValue } from "./actions"; import { getAccessToken } from '../components/security/methods'; -import { buildAPIBaseUrl } from "./methods"; +import { buildAPIBaseUrl } from "./config"; import debounce from 'lodash/debounce'; export const RECEIVE_COUNTRIES = 'RECEIVE_COUNTRIES'; const callDelay = 500; // milliseconds diff --git a/webpack.common.js b/webpack.common.js index ed288c6c..43e3509d 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -86,6 +86,7 @@ module.exports = { 'utils/fragment-parser': './src/components/fragment-parser.js', 'utils/use-fit-text': './src/components/use-fit-text.js', 'utils/actions': './src/utils/actions.js', + 'utils/config': './src/utils/config.js', 'utils/methods': './src/utils/methods.js', 'utils/query-actions': './src/utils/query-actions.js', 'utils/reducers': './src/utils/reducers.js', From ebb51c094e95aaa28459694075b2a6bd7a81a768 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 3 Sep 2026 18:54:18 -0300 Subject: [PATCH 2/4] fix(clock): skip the server-time fetch when no URL is configured getServerTime fetched whatever getTimeServiceUrl returned, so an unconfigured host produced a garbage request ('' fetches the current page, undefined fetches the literal string) before landing on the same local-clock fallback a rejected fetch reaches directly. --- src/components/clock.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/clock.js b/src/components/clock.js index 2b6adb47..e88deb3a 100644 --- a/src/components/clock.js +++ b/src/components/clock.js @@ -113,6 +113,9 @@ class Clock extends React.Component { getServerTime = () => { const timeServiceUrl = getTimeServiceUrl(); + // No endpoint configured: go straight to the local-clock fallback + // instead of fetching '' or "undefined". + if (!timeServiceUrl) return Promise.reject(null); return fetch(`${timeServiceUrl}`).then(async (response) => { if (response.status === 200) { return response.json(); From 2ef8771b08faabbd8eb45d4ce1f9566ddf0fd2f2 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 7 Oct 2026 00:13:37 -0300 Subject: [PATCH 3/4] fix(config): read config through the guarded global accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readConfig and setConfig used bare globalThis. They now use the same _global fallback (globalThis → window → {}) as the security methods, so the window-globals fallback still works on a runtime without globalThis. --- src/utils/config.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/utils/config.js b/src/utils/config.js index b3e36122..f356e895 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -12,16 +12,23 @@ * (always truthy), so a configured `oauth2UseRefreshToken: false` is the * way to turn the refresh-token flow off. * - * The state lives on globalThis under a Symbol.for key so every copy of + * The state lives on a global under a Symbol.for key so every copy of * this module shares it. */ +const _global = + typeof globalThis !== 'undefined' + ? globalThis + : typeof window !== 'undefined' + ? window + : {}; + const CONFIG_KEY = Symbol.for('openstack-uicore-foundation.config'); -const readConfig = () => globalThis[CONFIG_KEY] || {}; +const readConfig = () => _global[CONFIG_KEY] || {}; export const setConfig = (next) => { const source = next && typeof next === 'object' && !Array.isArray(next) ? next : {}; - globalThis[CONFIG_KEY] = Object.fromEntries( + _global[CONFIG_KEY] = Object.fromEntries( Object.entries(source).filter(([, value]) => value !== undefined && value !== ''), ); }; From 0800356091d81bf20e0d7a6b261017bb13377293 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 7 Oct 2026 00:13:38 -0300 Subject: [PATCH 4/4] fix(security): parse allowed user groups through a falsy-safe helper getUserInfo split the allowed-user-groups string whenever it was not '', so the null value getAllowedUserGroups returns with no window threw. parseUserGroups splits a truthy string and returns an empty list for any falsy value; getUserInfo calls it. --- src/components/security/actions.js | 5 ++--- src/utils/__tests__/config.test.js | 17 +++++++++++++++++ src/utils/config.js | 6 ++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/security/actions.js b/src/components/security/actions.js index 64009564..b417699b 100644 --- a/src/components/security/actions.js +++ b/src/components/security/actions.js @@ -13,7 +13,7 @@ import T from "i18n-react/dist/i18n-react"; import {authErrorHandler, createAction, getRequest, showMessage, startLoading, stopLoading} from "../../utils/actions"; -import {buildAPIBaseUrl, getAllowedUserGroups} from '../../utils/config'; +import {buildAPIBaseUrl, getAllowedUserGroups, parseUserGroups} from '../../utils/config'; import { getAccessToken, storeAuthInfo, initLogOut} from './methods'; /** @@ -50,8 +50,7 @@ export const doLogout = (backUrl) => (dispatch, getState) => { export const getUserInfo = (expand = 'groups', fields='', backUrl = null, history = null, errorHandler = null ) => async (dispatch, getState) => { - let AllowedUserGroups = getAllowedUserGroups(); - AllowedUserGroups = AllowedUserGroups !== '' ? AllowedUserGroups.split(' ') : []; + let AllowedUserGroups = parseUserGroups(getAllowedUserGroups()); let {loggedUserState} = getState(); let {member} = loggedUserState; diff --git a/src/utils/__tests__/config.test.js b/src/utils/__tests__/config.test.js index 742d1901..0202b8f7 100644 --- a/src/utils/__tests__/config.test.js +++ b/src/utils/__tests__/config.test.js @@ -12,6 +12,7 @@ import { buildAPIBaseUrl, getTimeServiceUrl, getAllowedUserGroups, + parseUserGroups, getOAuth2ClientId, getOAuth2Flow, useOAuth2RefreshToken, @@ -125,3 +126,19 @@ describe("setConfig", () => { expect(secondCopy.getOAuth2ClientId()).toBe("shared-client"); }); }); + +describe("parseUserGroups", () => { + test("splits a space-separated string into a list", () => { + expect(parseUserGroups("a b c")).toEqual(["a", "b", "c"]); + }); + + test("a single value yields a one-item list", () => { + expect(parseUserGroups("staff")).toEqual(["staff"]); + }); + + test("a falsy value (empty string, null, undefined) yields an empty list", () => { + expect(parseUserGroups("")).toEqual([]); + expect(parseUserGroups(null)).toEqual([]); + expect(parseUserGroups(undefined)).toEqual([]); + }); +}); diff --git a/src/utils/config.js b/src/utils/config.js index f356e895..4dcedb27 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -58,6 +58,12 @@ export const getTimeServiceUrl = () => export const getAllowedUserGroups = () => configuredOr('allowedUserGroups', () => (hasWindow() ? window.ALLOWED_USER_GROUPS || '' : null)); +/** + * Split the space-separated allowed-user-groups string into a list. A falsy + * value yields an empty list. + */ +export const parseUserGroups = (value) => (value ? value.split(' ') : []); + export const getOAuth2ClientId = () => configuredOr('oauth2ClientId', () => (hasWindow() ? window.OAUTH2_CLIENT_ID : null));