From d3b7a9dd252d382b8f5838767f41544d4b86ee17 Mon Sep 17 00:00:00 2001 From: Marc Itzenthaler Date: Tue, 27 Feb 2024 23:24:33 +0100 Subject: [PATCH] feat: changed tenant provider --- src/components/app/NavigationBar.tsx | 6 +- src/components/app/TenantThemingLoader.tsx | 16 - src/components/app/app.tsx | 2 - src/components/askerInfo/AskerInfoContent.tsx | 6 +- src/components/error/Error.tsx | 16 +- src/components/header/Header.tsx | 5 +- src/components/login/Login.tsx | 3 +- .../registration/RegistrationForm.tsx | 6 +- src/globalState/provider/LocaleProvider.tsx | 5 +- src/globalState/provider/TenantProvider.tsx | 297 +++++++++++++++++- src/utils/useTenantTheming.ts | 276 ---------------- 11 files changed, 308 insertions(+), 330 deletions(-) delete mode 100644 src/components/app/TenantThemingLoader.tsx delete mode 100644 src/utils/useTenantTheming.ts diff --git a/src/components/app/NavigationBar.tsx b/src/components/app/NavigationBar.tsx index 0b16d2ed0..8350b131b 100644 --- a/src/components/app/NavigationBar.tsx +++ b/src/components/app/NavigationBar.tsx @@ -15,8 +15,8 @@ import { ConsultingTypesContext, SessionsDataContext, SET_SESSIONS, - TenantContext, - LocaleContext + LocaleContext, + useTenant } from '../../globalState'; import { initNavigationHandler } from './navigationHandler'; import { ReactComponent as LogoutIconOutline } from '../../resources/img/icons/logout_outline.svg'; @@ -62,7 +62,7 @@ export const NavigationBar = ({ group: unreadGroup, teamsessions: unreadTeamSessions } = useContext(RocketChatUnreadContext); - const { tenant } = useContext(TenantContext); + const tenant = useTenant(); const ref_menu = useRef([]); const ref_local = useRef(); diff --git a/src/components/app/TenantThemingLoader.tsx b/src/components/app/TenantThemingLoader.tsx deleted file mode 100644 index d79cca879..000000000 --- a/src/components/app/TenantThemingLoader.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import useTenantTheming from '../../utils/useTenantTheming'; -import { Modal } from '../modal/Modal'; -import { Spinner } from '../spinner/Spinner'; - -export const TenantThemingLoader = () => { - const isLoadingTheme = useTenantTheming(); - - return isLoadingTheme ? ( - -
- -
-
- ) : null; -}; diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index e3c9e58ab..c2ce7fc5e 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -14,7 +14,6 @@ import { ContextProvider } from '../../globalState/state'; import { WebsocketHandler } from './WebsocketHandler'; import ErrorBoundary from './ErrorBoundary'; import { LanguagesProvider } from '../../globalState/provider/LanguagesProvider'; -import { TenantThemingLoader } from './TenantThemingLoader'; import { AppConfigProvider, InformalProvider, @@ -137,7 +136,6 @@ const RouterWrapper = ({ extraRoutes }: RouterWrapperProps) => { )} - {startWebsocket && ( { - const { tenant } = useContext(TenantContext); + const tenant = useTenant(); const { activeSession } = useContext(ActiveSessionContext); const { userData } = useContext(UserDataContext); diff --git a/src/components/error/Error.tsx b/src/components/error/Error.tsx index 022963b2a..37bfb9015 100644 --- a/src/components/error/Error.tsx +++ b/src/components/error/Error.tsx @@ -6,13 +6,16 @@ import { ReactComponent as Icon401 } from '../../resources/img/illustrations/una import { ReactComponent as Icon404 } from '../../resources/img/illustrations/not-found.svg'; import { ReactComponent as Icon500 } from '../../resources/img/illustrations/internal-server-error.svg'; import { Button, BUTTON_TYPES } from '../button/Button'; -import useTenantTheming from '../../utils/useTenantTheming'; import '../../resources/styles/styles'; import './error.styles'; import { useTranslation } from 'react-i18next'; import { LocaleSwitch } from '../localeSwitch/LocaleSwitch'; import { useAppConfig } from '../../hooks/useAppConfig'; -import { LocaleProvider, AppConfigProvider } from '../../globalState'; +import { + LocaleProvider, + AppConfigProvider, + TenantProvider +} from '../../globalState'; import { AppConfigInterface } from '../../globalState/interfaces'; import { useResponsive } from '../../hooks/useResponsive'; @@ -27,15 +30,16 @@ type ErrorProps = { export const Error = ({ config }: ErrorProps) => ( - - - + + + + + ); export const ErrorContent = () => { const { t: translate } = useTranslation(); - useTenantTheming(); const settings = useAppConfig(); const statusCode = getStatusCode(); const { fromL } = useResponsive(); diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index 803269a5a..b9fdcf6be 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -1,15 +1,14 @@ import * as React from 'react'; import { Headline } from '../headline/Headline'; import { Text } from '../text/Text'; -import { useContext } from 'react'; -import { TenantContext } from '../../globalState'; +import { useTenant } from '../../globalState'; import './header.styles'; import { useTranslation } from 'react-i18next'; import { LocaleSwitch } from '../localeSwitch/LocaleSwitch'; export const Header = ({ showLocaleSwitch = false }) => { const { t: translate } = useTranslation(); - const { tenant } = useContext(TenantContext); + const tenant = useTenant(); return (
diff --git a/src/components/login/Login.tsx b/src/components/login/Login.tsx index 3d2d1ca88..271daeae9 100644 --- a/src/components/login/Login.tsx +++ b/src/components/login/Login.tsx @@ -26,7 +26,6 @@ import { AUTHORITIES, hasUserAuthority, RocketChatGlobalSettingsContext, - TenantContext, UserDataContext, LocaleContext, useTenant @@ -76,7 +75,7 @@ export const Login = () => { const tenantData = useTenant(); const { locale, initLocale } = useContext(LocaleContext); - const { tenant } = useContext(TenantContext); + const tenant = useTenant(); const { getSetting } = useContext(RocketChatGlobalSettingsContext); const { userData, reloadUserData } = useContext(UserDataContext); const { Stage } = useContext(GlobalComponentContext); diff --git a/src/components/registration/RegistrationForm.tsx b/src/components/registration/RegistrationForm.tsx index 8ee14afd8..0b7338211 100644 --- a/src/components/registration/RegistrationForm.tsx +++ b/src/components/registration/RegistrationForm.tsx @@ -8,8 +8,8 @@ import { redirectToApp } from './autoLogin'; import { NOTIFICATION_TYPE_ERROR, NotificationsContext, - TenantContext, - useLocaleData + useLocaleData, + useTenant } from '../../globalState'; import { AgencyDataInterface, @@ -88,7 +88,7 @@ export const RegistrationForm = () => { string[] >([]); - const { tenant } = useContext(TenantContext); + const tenant = useTenant(); const { featureToolsEnabled } = getTenantSettings(); // Logout from budibase diff --git a/src/globalState/provider/LocaleProvider.tsx b/src/globalState/provider/LocaleProvider.tsx index 26d5a5eba..fdd4c8690 100644 --- a/src/globalState/provider/LocaleProvider.tsx +++ b/src/globalState/provider/LocaleProvider.tsx @@ -5,14 +5,12 @@ import { InformalContext } from './InformalProvider'; import { useAppConfig } from '../../hooks/useAppConfig'; import { setValueInCookie } from '../../components/sessionCookie/accessSessionCookie'; import { useTenant } from './TenantProvider'; -import useTenantTheming from '../../utils/useTenantTheming'; import { LocaleContext, TLocaleContext } from '../context/LocaleContext'; export const STORAGE_KEY_LOCALE = 'locale'; export function LocaleProvider(props) { const settings = useAppConfig(); - const isLoading = useTenantTheming(); const tenant = useTenant(); const [initialized, setInitialized] = useState(false); const [initLocale, setInitLocale] = useState(null); @@ -23,7 +21,7 @@ export function LocaleProvider(props) { useEffect(() => { // If using the tenant service we should load first the tenant because we need the // active languages from the server to apply it on loading - if ((settings.useTenantService && isLoading) || initialized) { + if (initialized) { return; } @@ -58,7 +56,6 @@ export function LocaleProvider(props) { }); }, [ initialized, - isLoading, settings.i18n, settings.translation, settings.useTenantService, diff --git a/src/globalState/provider/TenantProvider.tsx b/src/globalState/provider/TenantProvider.tsx index 8dc8e76d0..d97574ab2 100644 --- a/src/globalState/provider/TenantProvider.tsx +++ b/src/globalState/provider/TenantProvider.tsx @@ -1,28 +1,301 @@ import * as React from 'react'; -import { createContext, useState, useContext, useCallback } from 'react'; +import { + createContext, + useContext, + useEffect, + useCallback, + useMemo, + useState +} from 'react'; +import contrast from 'get-contrast'; import { setTenantSettings } from '../../utils/tenantSettingsHelper'; import { TenantDataInterface } from '../interfaces'; +import { Modal } from '../../components/modal/Modal'; +import { Spinner } from '../../components/spinner/Spinner'; +import { apiGetTenantTheming } from '../../api/apiGetTenantTheming'; +import { useLocaleData } from './LocaleProvider'; +import getLocationVariables from '../../utils/getLocationVariables'; +import decodeHTML from '../../utils/decodeHTML'; +import { useAppConfig } from '../../hooks/useAppConfig'; -export const TenantContext = createContext<{ - tenant: TenantDataInterface; - setTenant(tenant: TenantDataInterface): void; -}>(null); +export const TenantContext = createContext(null); + +const RGBToHSL = (r, g, b) => { + // Make r, g, and b fractions of 1 + r /= 255; + g /= 255; + b /= 255; + + // Find greatest and smallest channel values + const cmin = Math.min(r, g, b); + const cmax = Math.max(r, g, b); + const delta = cmax - cmin; + let h; + let s; + let l; + + // Calculate hue + // No difference + if (delta === 0) h = 0; + // Red is max + else if (cmax === r) h = ((g - b) / delta) % 6; + // Green is max + else if (cmax === g) h = (b - r) / delta + 2; + // Blue is max + else h = (r - g) / delta + 4; + + h = Math.round(h * 60); + + // Make negative hues positive behind 360° + if (h < 0) h += 360; + + // Calculate lightness + l = (cmax + cmin) / 2; + + // Calculate saturation + s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + // Multiply l and s by 100 + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + + return { h, s, l }; +}; + +const hexToRGB = (hex) => { + let r = '0'; + let g = '0'; + let b = '0'; + + // 3 digits + if (hex.length === 4) { + r = '0x' + hex[1] + hex[1]; + g = '0x' + hex[2] + hex[2]; + b = '0x' + hex[3] + hex[3]; + + // 6 digits + } else if (hex.length === 7) { + r = '0x' + hex[1] + hex[2]; + g = '0x' + hex[3] + hex[4]; + b = '0x' + hex[5] + hex[6]; + } + + return RGBToHSL(r, g, b); +}; + +/** + * adjusting colors via lightness, for hover effects, etc. + * @param color {object} + * @param adjust {number} + * @return {string} + */ +const adjustHSLColor = ({ + color, + adjust +}: { + color: Record; + adjust: number; +}): string => `hsl(${color.h}, ${color.s}%, ${adjust}%)`; + +const injectCss = ({ primaryColor, secondaryColor }) => { + // make HSL colors over RGB from hex + const primaryHSL = hexToRGB(primaryColor); + const secondaryHSL = secondaryColor && hexToRGB(secondaryColor); + // The level AA WCAG scrore requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (at least 18pt) or bold text. + const contrastThreshold = 4.5; + + // Intended to be used as the foreground color when text + // or icons are used on top of the primary color. + const textColorContrastSwitch = + primaryColor && contrast.ratio('#fff', primaryColor) > contrastThreshold + ? 'var(--skin-color-primary-foreground-light)' + : 'var(--skin-color-primary-foreground-dark)'; + + // Intended to be used as the foreground color when text + // or icons are used on top of the secondary color. + const textColorSecondaryContrastSwitch = + secondaryColor && + contrast.ratio('#fff', secondaryColor) > contrastThreshold + ? 'var(--skin-color-primary-foreground-light)' + : 'var(--skin-color-primary-foreground-dark)'; + + const secondaryColorContrastSafe = + secondaryColor && + contrast.ratio('#fff', secondaryColor) > contrastThreshold + ? secondaryColor + : 'var(--skin-color-default)'; + + const primaryColorContrastSafe = + primaryColor && contrast.ratio('#fff', primaryColor) < contrastThreshold + ? 'var(--skin-color-primary-foreground-dark)' + : primaryColor; + + document.head.insertAdjacentHTML( + 'beforeend', + `` + ); +}; + +const getOrCreateHeadNode = ( + tagName: string, + attributes?: Record +) => { + let selector = tagName; + if (attributes) { + selector += '['; + selector += Object.entries(attributes) + .map(([key, value]) => `${key}="${value}"`) + .join(' '); + selector += ']'; + } + + let node = document.querySelector(selector); + if (!node) { + node = document.createElement(tagName); + if (attributes) { + Object.entries(attributes).forEach(([key, value]) => { + node.setAttribute(key, value); + }); + } + document.head.appendChild(node); + } + + return node; +}; + +const applyTheming = (tenant: TenantDataInterface) => { + if (tenant.theming) { + injectCss(tenant.theming); + + getOrCreateHeadNode('meta', { name: 'theme-color' }).setAttribute( + 'content', + tenant.theming.primaryColor + ); + + if (tenant.theming.favicon) { + getOrCreateHeadNode('link', { rel: 'icon' }).setAttribute( + 'href', + tenant.theming.favicon + ); + } + } + + if (tenant.name) { + getOrCreateHeadNode('title').textContent = tenant.name; + getOrCreateHeadNode('meta', { property: 'og:title' }).setAttribute( + 'content', + tenant.name + ); + } + if (tenant.content?.claim) { + getOrCreateHeadNode('meta', { name: 'description' }).setAttribute( + 'content', + tenant.content.claim + ); + getOrCreateHeadNode('meta', { + property: 'og:description' + }).setAttribute('content', tenant.content.claim); + } +}; export function TenantProvider(props) { + const settings = useAppConfig(); const [tenant, setTenant] = useState(); + const { locale } = useLocaleData(); + const { subdomain } = getLocationVariables(); + const [loading, setLoading] = useState(settings.useTenantService); + + const cypressTenantEnabled = useMemo( + () => (window as any).Cypress?.env('TENANT_ENABLED'), + [] + ); + + const onTenantServiceResponse = useCallback( + (tenant: TenantDataInterface) => { + if (!subdomain && cypressTenantEnabled !== '1') { + setTenant({ settings } as any); + setTenantSettings(settings as any); + } else { + // ToDo: See VIC-428 + VIC-427 + const decodedTenant = JSON.parse(JSON.stringify(tenant)); + + decodedTenant.theming.logo = decodeHTML(tenant.theming.logo); + decodedTenant.theming.favicon = decodeHTML( + tenant.theming.favicon + ); + decodedTenant.content.claim = decodeHTML(tenant.content.claim); + decodedTenant.name = decodeHTML(tenant.name); + + applyTheming(decodedTenant); + setTenant(decodedTenant); + setTenantSettings(decodedTenant.settings); + } + }, + [settings, subdomain, cypressTenantEnabled] + ); + + useEffect(() => { + if (!settings.useTenantService) { + return; + } - const setSettings = useCallback((tenant) => { - setTenantSettings(tenant.settings); - setTenant(tenant); - }, []); + apiGetTenantTheming() + .then(onTenantServiceResponse) + .catch((error) => { + console.log('Theme could not be loaded', error); + }) + .finally(() => { + setLoading(false); + }); + // False positive + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subdomain, locale, settings]); return ( - - {props.children} + + {loading ? ( + +
+ +
+
+ ) : ( + props.children + )} + ;
); } export function useTenant() { - return useContext(TenantContext)?.tenant || null; + return useContext(TenantContext) || null; } diff --git a/src/utils/useTenantTheming.ts b/src/utils/useTenantTheming.ts deleted file mode 100644 index 131355801..000000000 --- a/src/utils/useTenantTheming.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { apiGetTenantTheming } from '../api/apiGetTenantTheming'; -import { TenantContext, useLocaleData } from '../globalState'; -import { TenantDataInterface } from '../globalState/interfaces'; -import getLocationVariables from './getLocationVariables'; -import decodeHTML from './decodeHTML'; -import contrast from 'get-contrast'; -import { useAppConfig } from '../hooks/useAppConfig'; - -const RGBToHSL = (r, g, b) => { - // Make r, g, and b fractions of 1 - r /= 255; - g /= 255; - b /= 255; - - // Find greatest and smallest channel values - const cmin = Math.min(r, g, b); - const cmax = Math.max(r, g, b); - const delta = cmax - cmin; - let h; - let s; - let l; - - // Calculate hue - // No difference - if (delta === 0) h = 0; - // Red is max - else if (cmax === r) h = ((g - b) / delta) % 6; - // Green is max - else if (cmax === g) h = (b - r) / delta + 2; - // Blue is max - else h = (r - g) / delta + 4; - - h = Math.round(h * 60); - - // Make negative hues positive behind 360° - if (h < 0) h += 360; - - // Calculate lightness - l = (cmax + cmin) / 2; - - // Calculate saturation - s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); - - // Multiply l and s by 100 - s = +(s * 100).toFixed(1); - l = +(l * 100).toFixed(1); - - return { h, s, l }; -}; - -const hexToRGB = (hex) => { - let r = '0'; - let g = '0'; - let b = '0'; - - // 3 digits - if (hex.length === 4) { - r = '0x' + hex[1] + hex[1]; - g = '0x' + hex[2] + hex[2]; - b = '0x' + hex[3] + hex[3]; - - // 6 digits - } else if (hex.length === 7) { - r = '0x' + hex[1] + hex[2]; - g = '0x' + hex[3] + hex[4]; - b = '0x' + hex[5] + hex[6]; - } - - return RGBToHSL(r, g, b); -}; - -/** - * adjusting colors via lightness, for hover effects, etc. - * @param color {object} - * @param adjust {number} - * @return {string} - */ -const adjustHSLColor = ({ - color, - adjust -}: { - color: Record; - adjust: number; -}): string => { - return `hsl(${color.h}, ${color.s}%, ${adjust}%)`; -}; - -const injectCss = ({ primaryColor, secondaryColor }) => { - // make HSL colors over RGB from hex - const primaryHSL = hexToRGB(primaryColor); - const secondaryHSL = secondaryColor && hexToRGB(secondaryColor); - // The level AA WCAG scrore requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (at least 18pt) or bold text. - const contrastThreshold = 4.5; - - // Intended to be used as the foreground color when text - // or icons are used on top of the primary color. - const textColorContrastSwitch = - primaryColor && contrast.ratio('#fff', primaryColor) > contrastThreshold - ? 'var(--skin-color-primary-foreground-light)' - : 'var(--skin-color-primary-foreground-dark)'; - - // Intended to be used as the foreground color when text - // or icons are used on top of the secondary color. - const textColorSecondaryContrastSwitch = - secondaryColor && - contrast.ratio('#fff', secondaryColor) > contrastThreshold - ? 'var(--skin-color-primary-foreground-light)' - : 'var(--skin-color-primary-foreground-dark)'; - - const secondaryColorContrastSafe = - secondaryColor && - contrast.ratio('#fff', secondaryColor) > contrastThreshold - ? secondaryColor - : 'var(--skin-color-default)'; - - const primaryColorContrastSafe = - primaryColor && contrast.ratio('#fff', primaryColor) < contrastThreshold - ? 'var(--skin-color-primary-foreground-dark)' - : primaryColor; - - document.head.insertAdjacentHTML( - 'beforeend', - `` - ); -}; - -const getOrCreateHeadNode = ( - tagName: string, - attributes?: Record -) => { - let selector = tagName; - if (attributes) { - selector += '['; - selector += Object.entries(attributes) - .map(([key, value]) => `${key}="${value}"`) - .join(' '); - selector += ']'; - } - - let node = document.querySelector(selector); - if (!node) { - node = document.createElement(tagName); - if (attributes) { - Object.entries(attributes).forEach(([key, value]) => { - node.setAttribute(key, value); - }); - } - document.head.appendChild(node); - } - - return node; -}; - -const applyTheming = (tenant: TenantDataInterface) => { - if (tenant.theming) { - injectCss(tenant.theming); - - getOrCreateHeadNode('meta', { name: 'theme-color' }).setAttribute( - 'content', - tenant.theming.primaryColor - ); - - if (tenant.theming.favicon) { - getOrCreateHeadNode('link', { rel: 'icon' }).setAttribute( - 'href', - tenant.theming.favicon - ); - } - } - - if (tenant.name) { - getOrCreateHeadNode('title').textContent = tenant.name; - getOrCreateHeadNode('meta', { property: 'og:title' }).setAttribute( - 'content', - tenant.name - ); - } - if (tenant.content?.claim) { - getOrCreateHeadNode('meta', { name: 'description' }).setAttribute( - 'content', - tenant.content.claim - ); - getOrCreateHeadNode('meta', { - property: 'og:description' - }).setAttribute('content', tenant.content.claim); - } -}; - -const useTenantTheming = () => { - const settings = useAppConfig(); - const tenantContext = useContext(TenantContext); - const { locale } = useLocaleData(); - const { subdomain } = getLocationVariables(); - const [isLoadingTenant, setIsLoadingTenant] = useState( - settings.useTenantService - ); - - const cypressTenantEnabled = useMemo( - () => (window as any).Cypress?.env('TENANT_ENABLED'), - [] - ); - - const onTenantServiceResponse = useCallback( - (tenant: TenantDataInterface) => { - if (!subdomain && cypressTenantEnabled !== '1') { - tenantContext?.setTenant({ settings } as any); - } else { - // ToDo: See VIC-428 + VIC-427 - const decodedTenant = JSON.parse(JSON.stringify(tenant)); - - decodedTenant.theming.logo = decodeHTML(tenant.theming.logo); - decodedTenant.theming.favicon = decodeHTML( - tenant.theming.favicon - ); - decodedTenant.content.claim = decodeHTML(tenant.content.claim); - decodedTenant.name = decodeHTML(tenant.name); - - applyTheming(decodedTenant); - tenantContext?.setTenant(decodedTenant); - } - return; - }, - [settings, subdomain, tenantContext, cypressTenantEnabled] - ); - - useEffect(() => { - if (!settings.useTenantService) { - return; - } - - apiGetTenantTheming() - .then(onTenantServiceResponse) - .catch((error) => { - console.log('Theme could not be loaded', error); - }) - .finally(() => { - setIsLoadingTenant(false); - }); - // False positive - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tenantContext?.setTenant, subdomain, locale]); - - return isLoadingTenant; -}; - -export default useTenantTheming;