Skip to content

Commit

Permalink
[core] [docs] Add warnings, docs and gating for paid features (#3156)
Browse files Browse the repository at this point in the history
  • Loading branch information
bharatkashyap authored Feb 6, 2024
1 parent 2e701f6 commit 6e07cac
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 37 deletions.
1 change: 1 addition & 0 deletions docs/data/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import componentsManifest from './toolpad/reference/components/manifest.json';
const pages: MuiPage[] = [
{
pathname: '/toolpad/getting-started-group',
title: 'Getting Started',
children: [
{ pathname: '/toolpad/getting-started', title: 'Overview' },
{ pathname: '/toolpad/getting-started/installation' },
Expand Down
27 changes: 27 additions & 0 deletions docs/data/toolpad/getting-started/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,30 @@ The roadmap does not represent a commitment, obligation, or promise to deliver a
## Global roadmap

To learn more about our plans for Material UI in general, visit the [global roadmap](/material-ui/discover-more/roadmap/).

## Paid Plan

A few features in Toolpad are proposed to be placed under a paid plan. These:

- will not be covered under the free-forever MIT license
- will require the purchase of a paid license to use in production

The following features are currently planned to be included within this scope:

- ### Authorization

Features allowing you to grant conditional access to pages based on user roles are part of this proposed paid plan. Read more about this feature on [authorization](/toolpad/concepts/rbac/).

## How to upgrade

Currently, accessing these features requires you to add the following to your `application.yml`:

```yml
spec: { plan: pro }
```
:::info
Add the above alongside any existing content of the `spec` attribute
:::

Using these features in production will require the purchase of a paid license. Please get in touch with us at [[email protected]](mailto:[email protected]) if you require to do so.
5 changes: 5 additions & 0 deletions docs/schemas/v1/definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
"spec": {
"type": "object",
"properties": {
"plan": {
"type": "string",
"enum": ["free", "pro"],
"description": "The plan for this application."
},
"authentication": {
"type": "object",
"properties": {
Expand Down
1 change: 1 addition & 0 deletions packages/toolpad-app/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const DOCUMENTATION_INSTALLATION_URL =
'https://mui.com/toolpad/getting-started/installation/';
export const ROADMAP_URL = 'https://github.com/orgs/mui/projects/9';
export const SCHEDULE_DEMO_URL = 'https://calendly.com/prakhar-mui/toolpad';
export const UPGRADE_URL = 'https://mui.com/toolpad/getting-started/roadmap/#paid-plan';

export const TOOLPAD_BRIDGE_GLOBAL = '__TOOLPAD_BRIDGE__';

Expand Down
1 change: 1 addition & 0 deletions packages/toolpad-app/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ async function createToolpadHandler({
const editorBasename = '/_toolpad';

const project = await initProject({ toolpadDevMode, dev, dir, externalUrl, base });
await project.checkPlan();
await project.start();

const router = express.Router();
Expand Down
54 changes: 52 additions & 2 deletions packages/toolpad-app/src/server/localMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import type {
import EnvManager from './EnvManager';
import FunctionsManager, { CreateDataProviderOptions } from './FunctionsManager';
import { VersionInfo, checkVersion } from './versionInfo';
import { VERSION_CHECK_INTERVAL } from '../constants';
import { UPGRADE_URL, VERSION_CHECK_INTERVAL } from '../constants';
import DataManager from './DataManager';
import { PAGE_COLUMN_COMPONENT_ID, PAGE_ROW_COMPONENT_ID } from '../runtime/toolpadComponents';
import packageInfo from '../packageInfo';
Expand Down Expand Up @@ -312,6 +312,8 @@ function mergeApplicationIntoDom(dom: appDom.AppDom, applicationFile: Applicatio
const applicationFileSpec = applicationFile.spec;
const app = appDom.getApp(dom);

dom = appDom.setNodeNamespacedProp(dom, app, 'attributes', 'plan', applicationFileSpec?.plan);

dom = appDom.setNodeNamespacedProp(dom, app, 'attributes', 'authentication', {
...applicationFileSpec?.authentication,
});
Expand Down Expand Up @@ -794,11 +796,11 @@ function extractThemeFromDom(dom: appDom.AppDom): Theme | null {

function extractApplicationFromDom(dom: appDom.AppDom): Application | null {
const rootNode = appDom.getApp(dom);

return {
apiVersion: API_VERSION,
kind: 'application',
spec: {
plan: rootNode.attributes.plan,
authentication: rootNode.attributes.authentication,
authorization: rootNode.attributes.authorization,
},
Expand Down Expand Up @@ -964,6 +966,29 @@ export function getRequiredEnvVars(dom: appDom.AppDom): Set<string> {
return new Set(allVars);
}

interface PaidFeature {
id: string;
label: string;
}

function detectPaidFeatures(application: Application): PaidFeature[] | null {
if (!application.spec || !application.spec.authorization) {
return null;
}

const hasRoles = Boolean(application?.spec?.authorization?.roles);
const hasAzureActiveDirectory = application?.spec?.authentication?.providers?.some(
(elems) => elems.provider === 'azure-ad',
);
const paidFeatures = [
hasRoles ? { id: 'roles', label: 'Role based access control' } : undefined,
hasAzureActiveDirectory
? { id: 'azure-ad', label: 'Azure AD authentication provider' }
: undefined,
].filter(Boolean) as PaidFeature[];
return paidFeatures.length > 0 ? paidFeatures : null;
}

class ToolpadProject {
private root: string;

Expand Down Expand Up @@ -1144,6 +1169,31 @@ class ToolpadProject {
this.alertedMissingVars = new Set(missingVars);
}

async checkPlan() {
const [dom] = await this.loadDomAndFingerprint();

const application = extractApplicationFromDom(dom);
if (!application || !application.spec) {
return;
}

if (!application.spec.plan || application.spec.plan === 'free') {
const paidFeatures = detectPaidFeatures(application);
if (paidFeatures) {
throw new Error(
`You are using ${chalk.bgBlue(paidFeatures.map((feature) => feature.label))} which ${paidFeatures.length > 1 ? 'are paid features' : 'is a paid feature'}. To continue using Toolpad, upgrade your plan or remove this feature. Learn more at ${chalk.cyan(UPGRADE_URL)}.`,
);
}
} else {
// eslint-disable-next-line no-console
console.log(
`${chalk.yellow(
'warn',
)} - You are using features that ${chalk.bold('are not covered under our MIT License')}. You will have to buy a license to use them in production.`,
);
}
}

async start() {
if (this.options.dev) {
await this.resetBuildInfo();
Expand Down
1 change: 1 addition & 0 deletions packages/toolpad-app/src/server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ elementSchema = baseElementSchema
export const applicationSchema = toolpadObjectSchema(
'application',
z.object({
plan: z.enum(['free', 'pro']).optional().describe('The plan for this application.'),
authentication: z
.object({
providers: z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import * as appDom from '@mui/toolpad-core/appDom';
import { useAppState, useAppStateApi } from '../AppState';
import TabPanel from '../../components/TabPanel';
import AzureIcon from '../../components/icons/AzureIcon';
import { UpgradeAlert } from './UpgradeAlert';

interface AuthProviderOption {
name: string;
Expand All @@ -64,6 +65,8 @@ const AUTH_PROVIDER_OPTIONS = new Map<string, AuthProviderOption>([
export function AppAuthenticationEditor() {
const { dom } = useAppState();
const appState = useAppStateApi();
const plan = appDom.getPlan(dom);
const isPaidPlan = plan !== undefined && plan !== 'free';

const handleAuthProvidersChange = React.useCallback(
(event: SelectChangeEvent<appDom.AuthProvider[]>) => {
Expand Down Expand Up @@ -143,12 +146,13 @@ export function AppAuthenticationEditor() {
.join(', ')
}
>
{[...AUTH_PROVIDER_OPTIONS].map(([value, { name, icon }]) => (
<MenuItem key={value} value={value}>
{[...AUTH_PROVIDER_OPTIONS].map(([value, { name, icon, hasRoles }]) => (
<MenuItem key={value} value={value} disabled={hasRoles && !isPaidPlan}>
<Stack direction="row" alignItems="center">
<Checkbox checked={authProviders.indexOf(value as appDom.AuthProvider) > -1} />
{icon}
<Typography ml={1}>{name}</Typography>
<Typography mx={1}>{name}</Typography>
{hasRoles && !isPaidPlan ? <UpgradeAlert feature={name} hideAction /> : null}
</Stack>
</MenuItem>
))}
Expand All @@ -164,6 +168,7 @@ export function AppAuthenticationEditor() {
</Link>
.
</Alert>

<Typography variant="subtitle1" mt={2}>
Required email domains
</Typography>
Expand All @@ -178,6 +183,20 @@ export function AppAuthenticationEditor() {
placeholder="example.com"
/>
))}
{!isPaidPlan ? (
<UpgradeAlert
type="error"
feature="Using authentication with a few specific providers (like Azure Active Directory)"
sx={{ position: 'absolute', bottom: (theme) => theme.spacing(4) }}
/>
) : (
<UpgradeAlert
type="warning"
warning="You are using features that are not covered by our MIT License. You will have to buy a license to use them in production."
hideAction
sx={{ position: 'absolute', bottom: (theme) => theme.spacing(4) }}
/>
)}
</Stack>
);
}
Expand Down Expand Up @@ -641,6 +660,8 @@ export interface AppAuthorizationDialogProps {

export default function AppAuthorizationDialog({ open, onClose }: AppAuthorizationDialogProps) {
const { dom } = useAppState();
const plan = appDom.getPlan(dom);
const isPaidPlan = plan !== undefined && plan !== 'free';

const [activeTab, setActiveTab] = React.useState<'authentication' | 'roles' | 'users'>(
'authentication',
Expand Down Expand Up @@ -694,22 +715,37 @@ export default function AppAuthorizationDialog({ open, onClose }: AppAuthorizati
<TabPanel disableGutters value="authentication">
<AppAuthenticationEditor />
</TabPanel>
<TabPanel disableGutters value="roles">
<Typography variant="body2">
Define the roles for your application. You can configure your pages to be accessible
to specific roles only.
</Typography>
<AppRolesEditor onRowUpdateError={handleRowUpdateError} />
</TabPanel>
<TabPanel disableGutters value="roleMappings">
<Typography variant="body2">
Define mappings from authentication provider roles to Toolpad roles.
</Typography>
<AppRoleMappingsEditor
onRowUpdateError={handleRowUpdateError}
roleEnabledActiveAuthProviderOptions={roleEnabledActiveAuthProviderOptions}
/>
</TabPanel>

<React.Fragment>
<TabPanel disableGutters value="roles">
{isPaidPlan ? (
<React.Fragment>
<Typography variant="body2">
Define the roles for your application. You can configure your pages to be
accessible to specific roles only.
</Typography>
<AppRolesEditor onRowUpdateError={handleRowUpdateError} />
</React.Fragment>
) : (
<UpgradeAlert type="error" feature="Role based access control" />
)}
</TabPanel>
<TabPanel disableGutters value="roleMappings">
{isPaidPlan ? (
<React.Fragment>
<Typography variant="body2">
Define mappings from authentication provider roles to Toolpad roles.
</Typography>
<AppRoleMappingsEditor
onRowUpdateError={handleRowUpdateError}
roleEnabledActiveAuthProviderOptions={roleEnabledActiveAuthProviderOptions}
/>
</React.Fragment>
) : (
<UpgradeAlert feature="Role mapping" />
)}
</TabPanel>
</React.Fragment>
</DialogContent>
</TabContext>
<DialogActions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { usePageEditorState } from './PageEditorProvider';
import UrlQueryEditor from './UrlQueryEditor';
import NodeNameEditor from '../NodeNameEditor';
import PageTitleEditor from '../PageTitleEditor';
import { UpgradeAlert } from '../UpgradeAlert';
import PageDisplayNameEditor from '../PageDisplayNameEditor';

const PAGE_DISPLAY_OPTIONS: { value: appDom.PageDisplayMode; label: string }[] = [
Expand All @@ -29,6 +30,8 @@ const PAGE_DISPLAY_OPTIONS: { value: appDom.PageDisplayMode; label: string }[] =
export default function PageOptionsPanel() {
const { nodeId: pageNodeId } = usePageEditorState();
const { dom } = useAppState();
const plan = appDom.getPlan(dom);
const isPaidPlan = plan !== undefined && plan !== 'free';
const domApi = useDomApi();

const appNode = appDom.getApp(dom);
Expand Down Expand Up @@ -120,22 +123,28 @@ export default function PageOptionsPanel() {
</div>
<div>
<Typography variant="body2">Authorization:</Typography>
<FormControlLabel
control={<Checkbox checked={allowAll} onChange={handleAllowAllChange} />}
label="Allow access to all roles"
/>
<Autocomplete
multiple
options={Array.from(availableRoles.keys())}
value={allowAll ? [] : allowedRoles}
onChange={handleAllowedRolesChange}
disabled={allowAll}
fullWidth
noOptionsText="No roles defined"
renderInput={(params) => (
<TextField {...params} label="Allowed roles" placeholder="Roles" />
)}
/>
{isPaidPlan ? (
<React.Fragment>
<FormControlLabel
control={<Checkbox checked={allowAll} onChange={handleAllowAllChange} />}
label="Allow access to all roles"
/>
<Autocomplete
multiple
options={Array.from(availableRoles.keys())}
value={allowAll ? [] : allowedRoles}
onChange={handleAllowedRolesChange}
disabled={allowAll}
fullWidth
noOptionsText="No roles defined"
renderInput={(params) => (
<TextField {...params} label="Allowed roles" placeholder="Roles" />
)}
/>
</React.Fragment>
) : (
<UpgradeAlert feature="Role based access control" hideAction />
)}
</div>
{appDom.isCodePage(page) ? null : (
<div>
Expand Down
Loading

0 comments on commit 6e07cac

Please sign in to comment.