From fef6935d3a3d523a1d90c7a4d4c4a29bdd453bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Tue, 2 Jan 2024 21:06:35 +0100 Subject: [PATCH] feat: license checker for self-hosted (#5239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show banner for enterprise self-hosted if they violate their license. --------- Co-authored-by: Nuno Góis --- frontend/src/component/App.tsx | 2 + frontend/src/component/admin/Admin.tsx | 2 + frontend/src/component/admin/adminRoutes.ts | 7 + .../src/component/admin/license/License.tsx | 153 ++++++++++++++++++ .../externalBanners/ExternalBanners.tsx | 1 + .../banners/internalBanners/LicenseBanner.tsx | 28 ++++ .../actions/useLicenseAPI/useLicenseApi.ts | 37 +++++ .../api/getters/useLicense/useLicense.ts | 67 ++++++++ frontend/src/interfaces/uiConfig.ts | 1 + 9 files changed, 298 insertions(+) create mode 100644 frontend/src/component/admin/license/License.tsx create mode 100644 frontend/src/component/banners/internalBanners/LicenseBanner.tsx create mode 100644 frontend/src/hooks/api/actions/useLicenseAPI/useLicenseApi.ts create mode 100644 frontend/src/hooks/api/getters/useLicense/useLicense.ts diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx index 778ca85f06..f1ff13add3 100644 --- a/frontend/src/component/App.tsx +++ b/frontend/src/component/App.tsx @@ -22,6 +22,7 @@ import { styled } from '@mui/material'; import { InitialRedirect } from './InitialRedirect'; import { InternalBanners } from './banners/internalBanners/InternalBanners'; import { ExternalBanners } from './banners/externalBanners/ExternalBanners'; +import { LicenseBanner } from './banners/internalBanners/LicenseBanner'; const StyledContainer = styled('div')(() => ({ '& ul': { @@ -65,6 +66,7 @@ export const App = () => { )} show={} /> + diff --git a/frontend/src/component/admin/Admin.tsx b/frontend/src/component/admin/Admin.tsx index be50b4963f..cfea959366 100644 --- a/frontend/src/component/admin/Admin.tsx +++ b/frontend/src/component/admin/Admin.tsx @@ -19,6 +19,7 @@ import NotFound from 'component/common/NotFound/NotFound'; import { AdminIndex } from './AdminIndex'; import { AdminTabsMenu } from './menu/AdminTabsMenu'; import { Banners } from './banners/Banners'; +import { License } from './license/License'; export const Admin = () => { return ( @@ -38,6 +39,7 @@ export const Admin = () => { } /> } /> } /> + } /> } /> } /> ({ + display: 'grid', + gap: theme.spacing(4), +})); + +const StyledDataCollectionPropertyRow = styled('div')(() => ({ + display: 'table-row', +})); + +const StyledPropertyName = styled('p')(({ theme }) => ({ + display: 'table-cell', + fontWeight: theme.fontWeight.bold, + paddingTop: theme.spacing(2), +})); + +const StyledPropertyDetails = styled('p')(({ theme }) => ({ + display: 'table-cell', + paddingTop: theme.spacing(2), + paddingLeft: theme.spacing(4), +})); + +export const License = () => { + const { setToastData, setToastApiError } = useToast(); + const { license, error, refetchLicense } = useLicense(); + const { reCheckLicense } = useLicenseCheck(); + const { loading } = useUiConfig(); + const { locationSettings } = useLocationSettings(); + const [token, setToken] = useState(''); + const { updateLicenseKey } = useLicenseKeyApi(); + + const updateToken = (event: React.ChangeEvent) => { + setToken(event.target.value.trim()); + }; + + if (loading || !license) { + return null; + } + + const onSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + try { + await updateLicenseKey(token); + setToastData({ + title: 'License key updated', + type: 'success', + }); + refetchLicense(); + reCheckLicense(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + }> + + + + + Customer + + + {license?.customer} + + + + Plan + + {license?.plan} + + + + Seats + + {license?.seats} + + + + + Expire at + + + {formatDateYMD( + license.expireAt, + locationSettings.locale, + )} + + + + } + elseShow={ +

+ You do not have a registered Unleash Enterprise + License. +

+ } + /> + +
+ + + + {' '} +

+ + {error?.message} + +

+
+
+ +
+
+ ); +}; diff --git a/frontend/src/component/banners/externalBanners/ExternalBanners.tsx b/frontend/src/component/banners/externalBanners/ExternalBanners.tsx index a8c768c2e1..cb90011fbc 100644 --- a/frontend/src/component/banners/externalBanners/ExternalBanners.tsx +++ b/frontend/src/component/banners/externalBanners/ExternalBanners.tsx @@ -1,4 +1,5 @@ import { Banner } from 'component/banners/Banner/Banner'; +import { useLicenseCheck } from 'hooks/api/getters/useLicense/useLicense'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useVariant } from 'hooks/useVariant'; import { IBanner } from 'interfaces/banner'; diff --git a/frontend/src/component/banners/internalBanners/LicenseBanner.tsx b/frontend/src/component/banners/internalBanners/LicenseBanner.tsx new file mode 100644 index 0000000000..0cf1bd1b2b --- /dev/null +++ b/frontend/src/component/banners/internalBanners/LicenseBanner.tsx @@ -0,0 +1,28 @@ +import { Banner } from 'component/banners/Banner/Banner'; +import { useLicenseCheck } from 'hooks/api/getters/useLicense/useLicense'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { BannerVariant } from 'interfaces/banner'; + +export const LicenseBanner = () => { + const { isEnterprise } = useUiConfig(); + const licenseInfo = useLicenseCheck(); + + // Only for enterprise + if ( + isEnterprise() && + licenseInfo && + !licenseInfo.isValid && + !licenseInfo.loading && + !licenseInfo.error + ) { + const banner = { + message: + licenseInfo.message || 'You have an invalid Unleash license.', + variant: 'error' as BannerVariant, + sticky: true, + }; + + return ; + } + return null; +}; diff --git a/frontend/src/hooks/api/actions/useLicenseAPI/useLicenseApi.ts b/frontend/src/hooks/api/actions/useLicenseAPI/useLicenseApi.ts new file mode 100644 index 0000000000..95d132c6bc --- /dev/null +++ b/frontend/src/hooks/api/actions/useLicenseAPI/useLicenseApi.ts @@ -0,0 +1,37 @@ +import { Dispatch, SetStateAction } from 'react'; +import useAPI from '../useApi/useApi'; + +export const handleBadRequest = async ( + setErrors?: Dispatch>, + res?: Response, +) => { + if (!setErrors) return; + if (res) { + const data = await res.json(); + setErrors({ message: data.message }); + throw new Error(data.message); + } + + throw new Error('Did not receive a response from the server.'); +}; + +const useLicenseKeyApi = () => { + const { makeRequest, createRequest, errors, loading } = useAPI({ + propagateErrors: true, + handleBadRequest, + }); + + const updateLicenseKey = async (token: string): Promise => { + const path = `api/admin/license`; + const req = createRequest(path, { + method: 'POST', + body: JSON.stringify({ token }), + }); + + await makeRequest(req.caller, req.id); + }; + + return { updateLicenseKey, errors, loading }; +}; + +export default useLicenseKeyApi; diff --git a/frontend/src/hooks/api/getters/useLicense/useLicense.ts b/frontend/src/hooks/api/getters/useLicense/useLicense.ts new file mode 100644 index 0000000000..9660aa3ccd --- /dev/null +++ b/frontend/src/hooks/api/getters/useLicense/useLicense.ts @@ -0,0 +1,67 @@ +import useSWR from 'swr'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useEnterpriseSWR } from '../useEnterpriseSWR/useEnterpriseSWR'; + +export interface LicenseInfo { + isValid: boolean; + message?: string; + loading: boolean; + reCheckLicense: () => void; + error?: Error; +} + +const fallback = { + isValid: true, + message: '', + loading: false, +}; + +export interface License { + license?: { + token: string; + customer: string; + plan: string; + seats: number; + expireAt: Date; + }; + loading: boolean; + refetchLicense: () => void; + error?: Error; +} + +export const useLicenseCheck = (): LicenseInfo => { + const { data, error, mutate } = useEnterpriseSWR( + fallback, + formatApiPath(`api/admin/license/check`), + fetcher, + ); + + return { + isValid: data?.isValid, + message: data?.message, + loading: !error && !data, + reCheckLicense: () => mutate(), + error, + }; +}; + +export const useLicense = (): License => { + const { data, error, mutate } = useSWR( + formatApiPath(`api/admin/license`), + fetcher, + ); + + return { + license: { ...data }, + loading: !error && !data, + refetchLicense: () => mutate(), + error, + }; +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('License')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 615d5d5ce2..fe42703c5c 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -71,6 +71,7 @@ export type UiFlags = { celebrateUnleash?: boolean; increaseUnleashWidth?: boolean; featureSearchFeedback?: boolean; + enableLicense?: boolean; }; export interface IVersionInfo {