Skip to content

Commit

Permalink
[x-license] license update proposal (#13459)
Browse files Browse the repository at this point in the history
Signed-off-by: Michel Engelen <32863416+michelengelen@users.noreply.github.com>
Co-authored-by: Andrew Cherniavskii <andrew.cherniavskii@gmail.com>
Co-authored-by: Flavien DELANGLE <flaviendelangle@gmail.com>
  • Loading branch information
3 people authored Jun 20, 2024
1 parent 5f49acf commit c84a2ee
Show file tree
Hide file tree
Showing 13 changed files with 279 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('<DataGridPremium /> - License', () => {
orderNumber: 'Test',
licensingModel: 'subscription',
scope: 'pro',
planVersion: 'initial',
}),
);
expect(() => render(<DataGridPremium columns={[]} rows={[]} autoHeight />)).toErrorDev([
Expand Down
2 changes: 2 additions & 0 deletions packages/x-license/src/Watermark/Watermark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ function getLicenseErrorMessage(licenseStatus: LicenseStatus) {
return 'MUI X Invalid license key';
case LICENSE_STATUS.OutOfScope:
return 'MUI X License key plan mismatch';
case LICENSE_STATUS.ProductNotCovered:
return 'MUI X Product not covered by plan';
case LICENSE_STATUS.NotFound:
return 'MUI X Missing license key';
default:
Expand Down
40 changes: 36 additions & 4 deletions packages/x-license/src/generateLicense/generateLicense.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ describe('License: generateLicense', () => {
orderNumber: 'MUI-123',
scope: 'pro',
licensingModel: 'subscription',
planVersion: 'initial',
}),
).to.equal(
'b2b2ea9c6fd846e11770da3c795d6f63Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXBybyxMTT1zdWJzY3JpcHRpb24sS1Y9Mg==',
'e8fad422a82720084ec67dd693f08056Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXBybyxMTT1zdWJzY3JpcHRpb24sUFY9aW5pdGlhbCxLVj0y',
);
});

Expand All @@ -22,9 +23,10 @@ describe('License: generateLicense', () => {
orderNumber: 'MUI-123',
scope: 'premium',
licensingModel: 'subscription',
planVersion: 'initial',
}),
).to.equal(
'ac8d20b4ecd1f919157f3713f8ba1651Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI=',
'8ca0384bfb92ec214d4cd72483f5110bTz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLFBWPWluaXRpYWwsS1Y9Mg==',
);
});

Expand All @@ -35,9 +37,10 @@ describe('License: generateLicense', () => {
orderNumber: 'MUI-123',
scope: 'pro',
licensingModel: 'subscription',
planVersion: 'initial',
}),
).to.equal(
'b2b2ea9c6fd846e11770da3c795d6f63Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXBybyxMTT1zdWJzY3JpcHRpb24sS1Y9Mg==',
'e8fad422a82720084ec67dd693f08056Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXBybyxMTT1zdWJzY3JpcHRpb24sUFY9aW5pdGlhbCxLVj0y',
);
});

Expand All @@ -48,9 +51,38 @@ describe('License: generateLicense', () => {
orderNumber: 'MUI-123',
scope: 'pro',
licensingModel: 'perpetual',
planVersion: 'initial',
}),
).to.equal(
'b16edd8e6bc83293a723779a259f520cTz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXBybyxMTT1wZXJwZXR1YWwsS1Y9Mg==',
'aaf2e3c60b06199962fbbab985843d97Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXBybyxMTT1wZXJwZXR1YWwsUFY9aW5pdGlhbCxLVj0y',
);
});

it('should generate subscription Pro license when `planVersion: "Q3-2024"`', () => {
expect(
generateLicense({
expiryDate: new Date(1591723879062),
orderNumber: 'MUI-123',
scope: 'pro',
licensingModel: 'subscription',
planVersion: 'Q3-2024',
}),
).to.equal(
'4adf08e54d606215809064d1d31b6b39Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXBybyxMTT1zdWJzY3JpcHRpb24sUFY9UTMtMjAyNCxLVj0y',
);
});

it('should generate subscription Premium license when `planVersion: "Q3-2024"`', () => {
expect(
generateLicense({
expiryDate: new Date(1591723879062),
orderNumber: 'MUI-123',
scope: 'premium',
licensingModel: 'subscription',
planVersion: 'Q3-2024',
}),
).to.equal(
'b76c2067275b3b566fcae1d28ad23c91Tz1NVUktMTIzLEU9MTU5MTcyMzg3OTA2MixTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLFBWPVEzLTIwMjQsS1Y9Mg==',
);
});
});
16 changes: 12 additions & 4 deletions packages/x-license/src/generateLicense/generateLicense.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { md5 } from '../encoding/md5';
import { base64Encode } from '../encoding/base64';
import { LICENSE_SCOPES, LicenseScope } from '../utils/licenseScope';
import { LICENSE_SCOPES, LicenseScope, PlanVersion } from '../utils/licenseScope';
import { LICENSING_MODELS, LicensingModel } from '../utils/licensingModel';

const licenseVersion = '2';
Expand All @@ -10,6 +10,7 @@ export interface LicenseDetails {
expiryDate: Date;
scope: LicenseScope;
licensingModel: LicensingModel;
planVersion: PlanVersion;
}

function getClearLicenseString(details: LicenseDetails) {
Expand All @@ -21,9 +22,16 @@ function getClearLicenseString(details: LicenseDetails) {
throw new Error('MUI X: Invalid licensing model');
}

return `O=${details.orderNumber},E=${details.expiryDate.getTime()},S=${details.scope},LM=${
details.licensingModel
},KV=${licenseVersion}`;
const keyParts = [
`O=${details.orderNumber}`,
`E=${details.expiryDate.getTime()}`,
`S=${details.scope}`,
`LM=${details.licensingModel}`,
`PV=${details.planVersion}`,
`KV=${licenseVersion}`,
];

return keyParts.join(',');
}

export function generateLicense(details: LicenseDetails) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
LicenseInfo,
generateLicense,
Unstable_LicenseInfoProvider as LicenseInfoProvider,
MuiCommercialPackageName,
} from '@mui/x-license';
import { sharedLicenseStatuses } from './useLicenseVerifier';
import { generateReleaseInfo } from '../verifyLicense';
Expand All @@ -14,9 +15,9 @@ const oneDayInMS = 1000 * 60 * 60 * 24;
const releaseDate = new Date(3000, 0, 0, 0, 0, 0, 0);
const RELEASE_INFO = generateReleaseInfo(releaseDate);

function TestComponent() {
const licesenStatus = useLicenseVerifier('x-date-pickers-pro', RELEASE_INFO);
return <div data-testid="status">Status: {licesenStatus.status}</div>;
function TestComponent(props: { packageName?: MuiCommercialPackageName }) {
const licenseStatus = useLicenseVerifier(props.packageName || 'x-date-pickers-pro', RELEASE_INFO);
return <div data-testid="status">Status: {licenseStatus.status}</div>;
}

describe('useLicenseVerifier', function test() {
Expand Down Expand Up @@ -63,6 +64,7 @@ describe('useLicenseVerifier', function test() {
licensingModel: 'perpetual',
orderNumber: '12345',
scope: 'pro',
planVersion: 'initial',
});

LicenseInfo.setLicenseKey('');
Expand All @@ -88,6 +90,7 @@ describe('useLicenseVerifier', function test() {
orderNumber: 'MUI-123',
scope: 'pro',
licensingModel: 'subscription',
planVersion: 'initial',
});
LicenseInfo.setLicenseKey(expiredLicenseKey);

Expand All @@ -105,5 +108,81 @@ describe('useLicenseVerifier', function test() {
]);
expect(actualErrorMsg).to.match(/MUI X: Expired license key/);
});

it('should throw if the license is not covering charts and tree-view', () => {
// Avoid Karma "Invalid left-hand side in assignment" SyntaxError
// eslint-disable-next-line no-useless-concat
process.env['NODE_' + 'ENV'] = 'development';

const licenseKey = generateLicense({
expiryDate: new Date(3001, 0, 0, 0, 0, 0, 0),
orderNumber: 'MUI-123',
scope: 'pro',
licensingModel: 'subscription',
planVersion: 'initial',
});

LicenseInfo.setLicenseKey(licenseKey);

expect(() => {
render(<TestComponent packageName={'x-charts-pro'} />);
}).to.toErrorDev(['MUI X: Product not covered by plan.']);

expect(() => {
render(<TestComponent packageName={'x-tree-view-pro'} />);
}).to.toErrorDev(['MUI X: Product not covered by plan.']);
});

it('should not throw if the license is covering charts and tree-view', () => {
// Avoid Karma "Invalid left-hand side in assignment" SyntaxError
// eslint-disable-next-line no-useless-concat
process.env['NODE_' + 'ENV'] = 'development';

const licenseKey = generateLicense({
expiryDate: new Date(3001, 0, 0, 0, 0, 0, 0),
orderNumber: 'MUI-123',
scope: 'pro',
licensingModel: 'subscription',
planVersion: 'Q3-2024',
});

LicenseInfo.setLicenseKey(licenseKey);

expect(() => {
render(<TestComponent packageName={'x-charts-pro'} />);
}).not.toErrorDev();

expect(() => {
render(<TestComponent packageName={'x-tree-view-pro'} />);
}).not.toErrorDev();
});

it('should not throw for existing pro and premium packages', () => {
// Avoid Karma "Invalid left-hand side in assignment" SyntaxError
// eslint-disable-next-line no-useless-concat
process.env['NODE_' + 'ENV'] = 'development';

const licenseKey = generateLicense({
expiryDate: new Date(3001, 0, 0, 0, 0, 0, 0),
orderNumber: 'MUI-123',
scope: 'premium',
licensingModel: 'subscription',
planVersion: 'Q3-2024',
});

LicenseInfo.setLicenseKey(licenseKey);

expect(() => {
render(<TestComponent packageName={'x-data-grid-pro'} />);
}).not.toErrorDev();

expect(() => {
render(<TestComponent packageName={'x-data-grid-premium'} />);
}).not.toErrorDev();

expect(() => {
render(<TestComponent packageName={'x-date-pickers-pro'} />);
}).not.toErrorDev();
});
});
});
11 changes: 7 additions & 4 deletions packages/x-license/src/useLicenseVerifier/useLicenseVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
showMissingLicenseKeyError,
showLicenseKeyPlanMismatchError,
showExpiredPackageVersionError,
showProductNotCoveredError,
} from '../utils/licenseErrorMessageUtils';
import { LICENSE_STATUS, LicenseStatus } from '../utils/licenseStatus';
import { LicenseScope } from '../utils/licenseScope';
import { extractAcceptedScopes, extractProductScope } from '../utils/licenseScope';
import MuiLicenseInfoContext from '../Unstable_LicenseInfoProvider/MuiLicenseInfoContext';

export type MuiCommercialPackageName =
Expand Down Expand Up @@ -47,15 +48,15 @@ export function useLicenseVerifier(
return sharedLicenseStatuses[packageName]!.licenseVerifier;
}

const acceptedScopes: LicenseScope[] = packageName.includes('premium')
? ['premium']
: ['pro', 'premium'];
const acceptedScopes = extractAcceptedScopes(packageName);
const productScope = extractProductScope(packageName);

const plan = packageName.includes('premium') ? 'Premium' : 'Pro';
const licenseStatus = verifyLicense({
releaseInfo,
licenseKey,
acceptedScopes,
productScope,
});

const fullPackageName = `@mui/${packageName}`;
Expand All @@ -64,6 +65,8 @@ export function useLicenseVerifier(
// Skip
} else if (licenseStatus.status === LICENSE_STATUS.Invalid) {
showInvalidLicenseKeyError();
} else if (licenseStatus.status === LICENSE_STATUS.ProductNotCovered) {
showProductNotCoveredError();
} else if (licenseStatus.status === LICENSE_STATUS.OutOfScope) {
showLicenseKeyPlanMismatchError();
} else if (licenseStatus.status === LICENSE_STATUS.NotFound) {
Expand Down
10 changes: 10 additions & 0 deletions packages/x-license/src/utils/licenseErrorMessageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ export function showLicenseKeyPlanMismatchError() {
]);
}

export function showProductNotCoveredError() {
showError([
'MUI X: Product not covered by plan.',
'',
'The component you are trying to use is not included in the Pro Plan your purchased. You are using a license that is only compatible with the `@mui/x-data-grid-pro` and `@mui/x-date-pickers-pro` commercial packages.',
'',
'To start using another Pro package, please consider reaching to our sales team to upgrade your license or visit https://mui.com/r/x-get-license to get a new license key.',
]);
}

export function showMissingLicenseKeyError({
plan,
packageName,
Expand Down
17 changes: 17 additions & 0 deletions packages/x-license/src/utils/licenseScope.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
export const LICENSE_SCOPES = ['pro', 'premium'] as const;
export const PRODUCT_SCOPES = ['data-grid', 'date-pickers', 'charts', 'tree-view'] as const;
export const PLAN_VERSIONS = ['initial', 'Q3-2024'] as const;

export type LicenseScope = (typeof LICENSE_SCOPES)[number];
export type ProductScope = (typeof PRODUCT_SCOPES)[number];
export type PlanVersion = (typeof PLAN_VERSIONS)[number];

export const extractProductScope = (packageName: string): ProductScope => {
// extract the part between "x-" and "-pro"/"-premium"
const regex = /x-(.*?)(-pro|-premium)?$/;
const match = packageName.match(regex);
return match![1] as ProductScope;
};

export const extractAcceptedScopes = (packageName: string): readonly LicenseScope[] => {
return packageName.includes('premium')
? LICENSE_SCOPES.filter((scope) => scope.includes('premium'))
: LICENSE_SCOPES;
};
1 change: 1 addition & 0 deletions packages/x-license/src/utils/licenseStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export enum LICENSE_STATUS {
ExpiredVersion = 'ExpiredVersion',
Valid = 'Valid',
OutOfScope = 'OutOfScope',
ProductNotCovered = 'ProductNotCovered',
}

export type LicenseStatus = keyof typeof LICENSE_STATUS;
Loading

0 comments on commit c84a2ee

Please sign in to comment.