1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: license checker for self-hosted (#5239)

Show banner for enterprise self-hosted if they violate their license.

---------

Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
Ivar Conradi Østhus 2024-01-02 21:06:35 +01:00 committed by GitHub
parent 2a1c0616e0
commit fef6935d3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 298 additions and 0 deletions

View File

@ -22,6 +22,7 @@ import { styled } from '@mui/material';
import { InitialRedirect } from './InitialRedirect'; import { InitialRedirect } from './InitialRedirect';
import { InternalBanners } from './banners/internalBanners/InternalBanners'; import { InternalBanners } from './banners/internalBanners/InternalBanners';
import { ExternalBanners } from './banners/externalBanners/ExternalBanners'; import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
const StyledContainer = styled('div')(() => ({ const StyledContainer = styled('div')(() => ({
'& ul': { '& ul': {
@ -65,6 +66,7 @@ export const App = () => {
)} )}
show={<MaintenanceBanner />} show={<MaintenanceBanner />}
/> />
<LicenseBanner />
<ExternalBanners /> <ExternalBanners />
<InternalBanners /> <InternalBanners />
<StyledContainer> <StyledContainer>

View File

@ -19,6 +19,7 @@ import NotFound from 'component/common/NotFound/NotFound';
import { AdminIndex } from './AdminIndex'; import { AdminIndex } from './AdminIndex';
import { AdminTabsMenu } from './menu/AdminTabsMenu'; import { AdminTabsMenu } from './menu/AdminTabsMenu';
import { Banners } from './banners/Banners'; import { Banners } from './banners/Banners';
import { License } from './license/License';
export const Admin = () => { export const Admin = () => {
return ( return (
@ -38,6 +39,7 @@ export const Admin = () => {
<Route path='network/*' element={<Network />} /> <Route path='network/*' element={<Network />} />
<Route path='maintenance' element={<MaintenanceAdmin />} /> <Route path='maintenance' element={<MaintenanceAdmin />} />
<Route path='banners' element={<Banners />} /> <Route path='banners' element={<Banners />} />
<Route path='license' element={<License />} />
<Route path='cors' element={<CorsAdmin />} /> <Route path='cors' element={<CorsAdmin />} />
<Route path='auth' element={<AuthSettings />} /> <Route path='auth' element={<AuthSettings />} />
<Route <Route

View File

@ -86,6 +86,13 @@ export const adminRoutes: INavigationMenuItem[] = [
menu: { adminSettings: true }, menu: { adminSettings: true },
group: 'instance', group: 'instance',
}, },
{
path: '/admin/license',
title: 'License',
menu: { adminSettings: true, mode: ['enterprise'] },
flag: 'enableLicense',
group: 'instance',
},
{ {
path: '/admin/instance-privacy', path: '/admin/instance-privacy',
title: 'Instance privacy', title: 'Instance privacy',

View File

@ -0,0 +1,153 @@
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Box, Button, Grid, TextField, styled } from '@mui/material';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import {
useLicense,
useLicenseCheck,
} from 'hooks/api/getters/useLicense/useLicense';
import { formatDateYMD } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { useState } from 'react';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import useLicenseKeyApi from 'hooks/api/actions/useLicenseAPI/useLicenseApi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledBox = styled(Box)(({ theme }) => ({
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<HTMLInputElement>) => {
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 (
<PageContent header={<PageHeader title='Unleash Enterprise License' />}>
<StyledBox>
<ConditionallyRender
condition={!!license.token}
show={
<div>
<StyledDataCollectionPropertyRow>
<StyledPropertyName>
Customer
</StyledPropertyName>
<StyledPropertyDetails>
{license?.customer}
</StyledPropertyDetails>
</StyledDataCollectionPropertyRow>
<StyledDataCollectionPropertyRow>
<StyledPropertyName>Plan</StyledPropertyName>
<StyledPropertyDetails>
{license?.plan}
</StyledPropertyDetails>
</StyledDataCollectionPropertyRow>
<StyledDataCollectionPropertyRow>
<StyledPropertyName>Seats</StyledPropertyName>
<StyledPropertyDetails>
{license?.seats}
</StyledPropertyDetails>
</StyledDataCollectionPropertyRow>
<StyledDataCollectionPropertyRow>
<StyledPropertyName>
Expire at
</StyledPropertyName>
<StyledPropertyDetails>
{formatDateYMD(
license.expireAt,
locationSettings.locale,
)}
</StyledPropertyDetails>
</StyledDataCollectionPropertyRow>
</div>
}
elseShow={
<p>
You do not have a registered Unleash Enterprise
License.
</p>
}
/>
<form onSubmit={onSubmit}>
<TextField
onChange={updateToken}
label='New license key'
name='licenseKey'
value={token}
style={{ width: '100%' }}
variant='outlined'
size='small'
multiline
rows={6}
required
/>
<Grid container spacing={3} marginTop={2}>
<Grid item md={5}>
<Button
variant='contained'
color='primary'
type='submit'
disabled={loading}
>
Update license key
</Button>{' '}
<p>
<small style={{ color: 'red' }}>
{error?.message}
</small>
</p>
</Grid>
</Grid>
</form>
</StyledBox>
</PageContent>
);
};

View File

@ -1,4 +1,5 @@
import { Banner } from 'component/banners/Banner/Banner'; import { Banner } from 'component/banners/Banner/Banner';
import { useLicenseCheck } from 'hooks/api/getters/useLicense/useLicense';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useVariant } from 'hooks/useVariant'; import { useVariant } from 'hooks/useVariant';
import { IBanner } from 'interfaces/banner'; import { IBanner } from 'interfaces/banner';

View File

@ -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 <Banner key={banner.message} banner={banner} />;
}
return null;
};

View File

@ -0,0 +1,37 @@
import { Dispatch, SetStateAction } from 'react';
import useAPI from '../useApi/useApi';
export const handleBadRequest = async (
setErrors?: Dispatch<SetStateAction<{}>>,
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<void> => {
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;

View File

@ -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());
};

View File

@ -71,6 +71,7 @@ export type UiFlags = {
celebrateUnleash?: boolean; celebrateUnleash?: boolean;
increaseUnleashWidth?: boolean; increaseUnleashWidth?: boolean;
featureSearchFeedback?: boolean; featureSearchFeedback?: boolean;
enableLicense?: boolean;
}; };
export interface IVersionInfo { export interface IVersionInfo {