Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce Pricing for Task/Project Management Features #6767

Merged
merged 16 commits into from
Jan 23, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
- Changed branding of WEBKNOSSOS including a new logo, new primary colors, and UPPERCASE name. [#6739](https://github.com/scalableminds/webknossos/pull/6739)
- Improves performance for ingesting Voxelytics reporting data. [#6732](https://github.com/scalableminds/webknossos/pull/6732)
- Implements statistics from combined worflow runs in the Voxelytics reporting. [#6732](https://github.com/scalableminds/webknossos/pull/6732)
- Limit paid task/project management features to respective organization plans. [6767](https://github.com/scalableminds/webknossos/pull/6767)

### Fixed
- Fixed node selection and context menu for node ids greater than 130813. [#6724](https://github.com/scalableminds/webknossos/pull/6724) and [#6731](https://github.com/scalableminds/webknossos/pull/6731)
Expand Down
1 change: 1 addition & 0 deletions frontend/javascripts/admin/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ export class InviteUsersModal extends React.Component<
if (this.props.handleVisibleChange != null) this.props.handleVisibleChange(false);
if (this.props.destroy != null) this.props.destroy();
}}
closable
>
{this.getContent(isInvitesDisabled)}
</Modal>
Expand Down
59 changes: 31 additions & 28 deletions frontend/javascripts/admin/organization/organization_edit_view.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RouteComponentProps, withRouter } from "react-router-dom";
import React from "react";
import { connect } from "react-redux";
import { Form, Button, Card, Input, Row, FormInstance, Col, Skeleton } from "antd";
import {
MailOutlined,
Expand All @@ -7,10 +8,8 @@ import {
SaveOutlined,
IdcardOutlined,
} from "@ant-design/icons";
import React from "react";
import { confirmAsync } from "dashboard/dataset/helper_components";
import {
getOrganization,
deleteOrganization,
updateOrganization,
getUsers,
Expand All @@ -26,7 +25,9 @@ import {
PlanExpirationCard,
PlanUpgradeCard,
} from "./organization_cards";
import { enforceActiveOrganization } from "oxalis/model/accessors/organization_accessors";
import { getActiveUserCount } from "./pricing_plan_utils";
import type { OxalisState } from "oxalis/store";

const FormItem = Form.Item;
export enum PricingPlanEnum {
Expand All @@ -37,31 +38,33 @@ export enum PricingPlanEnum {
PowerTrial = "Power_Trial",
Custom = "Custom",
}
type Props = {
organizationName: string;

type StateProps = {
organization: APIOrganization;
};
type Props = StateProps;
hotzenklotz marked this conversation as resolved.
Show resolved Hide resolved

type State = {
displayName: string;
newUserMailingList: string;
pricingPlan: PricingPlanEnum | null | undefined;
isFetchingData: boolean;
isDeleting: boolean;
organization: APIOrganization | null;
activeUsersCount: number;
pricingPlanStatus: APIPricingPlanStatus | null;
};

class OrganizationEditView extends React.PureComponent<Props, State> {
state: State = {
displayName: "",
newUserMailingList: "",
pricingPlan: null,
displayName: this.props.organization.displayName,
newUserMailingList: this.props.organization.newUserMailingList,
pricingPlan: this.props.organization.pricingPlan,
philippotto marked this conversation as resolved.
Show resolved Hide resolved
isFetchingData: false,
isDeleting: false,
organization: null,
activeUsersCount: 1,
pricingPlanStatus: null,
};

formRef = React.createRef<FormInstance>();

componentDidMount() {
Expand Down Expand Up @@ -98,19 +101,14 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
this.setState({
isFetchingData: true,
});
const [organization, users, pricingPlanStatus] = await Promise.all([
getOrganization(this.props.organizationName),
getUsers(),
getPricingPlanStatus(),
]);
const [users, pricingPlanStatus] = await Promise.all([getUsers(), getPricingPlanStatus()]);

const { displayName, newUserMailingList, pricingPlan } = organization;
const { displayName, newUserMailingList, pricingPlan } = this.props.organization;
this.setState({
displayName,
pricingPlan: coalesce(PricingPlanEnum, pricingPlan),
newUserMailingList,
isFetchingData: false,
organization,
pricingPlanStatus,
activeUsersCount: getActiveUserCount(users),
});
Expand All @@ -119,7 +117,7 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'formValues' implicitly has an 'any' typ... Remove this comment to see the full error message
onFinish = async (formValues) => {
await updateOrganization(
this.props.organizationName,
this.props.organization.name,
formValues.displayName,
formValues.newUserMailingList,
);
Expand All @@ -142,7 +140,7 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
this.setState({
isDeleting: true,
});
await deleteOrganization(this.props.organizationName);
await deleteOrganization(this.props.organization.name);
this.setState({
isDeleting: false,
});
Expand All @@ -151,14 +149,14 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
};

handleCopyNameButtonClicked = async (): Promise<void> => {
await navigator.clipboard.writeText(this.props.organizationName);
await navigator.clipboard.writeText(this.props.organization.name);
Toast.success("Copied organization name to the clipboard.");
};

render() {
if (
this.state.isFetchingData ||
!this.state.organization ||
!this.props.organization ||
!this.state.pricingPlan ||
!this.state.pricingPlanStatus
)
Expand Down Expand Up @@ -191,18 +189,18 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
<h2>{this.state.displayName}</h2>
</Row>
{this.state.pricingPlanStatus.isExceeded ? (
<PlanExceededAlert organization={this.state.organization} />
<PlanExceededAlert organization={this.props.organization} />
) : null}
{this.state.pricingPlanStatus.isAlmostExceeded &&
!this.state.pricingPlanStatus.isExceeded ? (
<PlanAboutToExceedAlert organization={this.state.organization} />
<PlanAboutToExceedAlert organization={this.props.organization} />
) : null}
<PlanDashboardCard
organization={this.state.organization}
organization={this.props.organization}
activeUsersCount={this.state.activeUsersCount}
/>
<PlanExpirationCard organization={this.state.organization} />
<PlanUpgradeCard organization={this.state.organization} />
<PlanExpirationCard organization={this.props.organization} />
<PlanUpgradeCard organization={this.props.organization} />
<Card title="Settings" style={{ marginBottom: 20 }}>
<Form
onFinish={this.onFinish}
Expand All @@ -217,7 +215,7 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
<Input.Group compact>
<Input
prefix={<IdcardOutlined />}
value={this.props.organizationName}
value={this.props.organization.name}
style={{
width: "calc(100% - 31px)",
}}
Expand Down Expand Up @@ -303,4 +301,9 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
}
}

export default withRouter<RouteComponentProps & Props, any>(OrganizationEditView);
const mapStateToProps = (state: OxalisState): StateProps => ({
organization: enforceActiveOrganization(state.activeOrganization),
});

const connector = connect(mapStateToProps);
export default connector(OrganizationEditView);
24 changes: 24 additions & 0 deletions frontend/javascripts/admin/organization/pricing_plan_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,27 @@ export function hasPricingPlanExceededStorage(organization: APIOrganization): bo
export function isUserAllowedToRequestUpgrades(user: APIUser): boolean {
return user.isAdmin || user.isOrganizationOwner;
}

export function isPricingPlanGreaterEqualThan(planA: PricingPlanEnum, planB: PricingPlanEnum) {
switch (planA) {
case PricingPlanEnum.Power:
case PricingPlanEnum.PowerTrial:
case PricingPlanEnum.Custom:
return true;

case PricingPlanEnum.Team:
case PricingPlanEnum.TeamTrial:
switch (planB) {
case PricingPlanEnum.Power:
case PricingPlanEnum.PowerTrial:
case PricingPlanEnum.Custom:
return false;
default:
return true;
}

case PricingPlanEnum.Basic:
default:
return false;
philippotto marked this conversation as resolved.
Show resolved Hide resolved
}
}
20 changes: 12 additions & 8 deletions frontend/javascripts/admin/user/user_list_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { connect } from "react-redux";
import * as React from "react";
import React from "react";
import _ from "lodash";
import moment from "moment";
import { location } from "libs/window";
import type { APIUser, APITeamMembership, ExperienceMap } from "types/api_flow_types";
import type {
APIUser,
APITeamMembership,
ExperienceMap,
APIOrganization,
} from "types/api_flow_types";
import { InviteUsersModal } from "admin/onboarding";
import type { OxalisState } from "oxalis/store";
import { enforceActiveUser } from "oxalis/model/accessors/user_accessor";
Expand All @@ -37,13 +42,15 @@ import * as Utils from "libs/utils";
import messages from "messages";
import { logoutUserAction } from "../../oxalis/model/actions/user_actions";
import Store from "../../oxalis/store";
import { enforceActiveOrganization } from "oxalis/model/accessors/organization_accessors";

const { Column } = Table;
const { Search } = Input;
const typeHint: APIUser[] = [];

type StateProps = {
activeUser: APIUser;
activeOrganization: APIOrganization;
};
type Props = RouteComponentProps & StateProps;

Expand Down Expand Up @@ -80,7 +87,7 @@ class UserListView extends React.PureComponent<Props, State> {
searchQuery: "",
singleSelectedUser: null,
domainToEdit: null,
maxUserCountPerOrganization: Number.POSITIVE_INFINITY,
maxUserCountPerOrganization: this.props.activeOrganization.includedUsers,
hotzenklotz marked this conversation as resolved.
Show resolved Hide resolved
};

componentDidMount() {
Expand All @@ -103,15 +110,11 @@ class UserListView extends React.PureComponent<Props, State> {
this.setState({
isLoading: true,
});
const [users, organization] = await Promise.all([
getEditableUsers(),
getOrganization(this.props.activeUser.organization),
]);
const users = await getEditableUsers();

this.setState({
isLoading: false,
users,
maxUserCountPerOrganization: organization.includedUsers,
});
}

Expand Down Expand Up @@ -681,6 +684,7 @@ class UserListView extends React.PureComponent<Props, State> {

const mapStateToProps = (state: OxalisState): StateProps => ({
activeUser: enforceActiveUser(state.activeUser),
activeOrganization: enforceActiveOrganization(state.activeOrganization),
});

const connector = connect(mapStateToProps);
Expand Down
110 changes: 110 additions & 0 deletions frontend/javascripts/components/pricing_enforcers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from "react";
import { useSelector } from "react-redux";
import { Tooltip, Menu, MenuItemProps, Alert, ButtonProps, Button } from "antd";
import { LockOutlined } from "@ant-design/icons";
import { PricingPlanEnum } from "admin/organization/organization_edit_view";
import { isPricingPlanGreaterEqualThan } from "admin/organization/pricing_plan_utils";
import { Link } from "react-router-dom";
import type { MenuClickEventHandler } from "rc-menu/lib/interface";
import type { OxalisState } from "oxalis/store";

const toolTipMessage = `This feature is not available in your organisation's plan. Ask your organisation owner to upgrade.`;
hotzenklotz marked this conversation as resolved.
Show resolved Hide resolved

const handleMouseClick = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};

export function PricingEnforcedMenuItem({
children,
requiredPricingPlan,
showLockIcon = true,
...menuItemProps
}: {
children: React.ReactNode;
requiredPricingPlan: PricingPlanEnum;
showLockIcon?: boolean;
} & MenuItemProps): JSX.Element {
const currentPricingPlan = useSelector((state: OxalisState) =>
state.activeOrganization ? state.activeOrganization.pricingPlan : PricingPlanEnum.Basic,
);
const isFeatureAllowed = isPricingPlanGreaterEqualThan(currentPricingPlan, requiredPricingPlan);

if (isFeatureAllowed) return <Menu.Item {...menuItemProps}>{children}</Menu.Item>;

const handleMenuClick: MenuClickEventHandler = (info) => {
info.domEvent.preventDefault();
info.domEvent.stopPropagation();
};
hotzenklotz marked this conversation as resolved.
Show resolved Hide resolved

return (
<Tooltip title={toolTipMessage} placement="right">
<Menu.Item
onClick={handleMenuClick}
onAuxClick={handleMouseClick}
onDoubleClick={handleMouseClick}
onClickCapture={handleMouseClick}
className="ant-dropdown-menu-item-disabled"
{...menuItemProps}
>
{children}
{showLockIcon ? <LockOutlined style={{ marginLeft: 5 }} /> : null}
</Menu.Item>
</Tooltip>
);
}

export function PricingEnforcedButton({
children,
requiredPricingPlan,
showLockIcon = true,
...buttonProps
}: {
children: React.ReactNode;
requiredPricingPlan: PricingPlanEnum;
showLockIcon?: boolean;
} & ButtonProps): JSX.Element {
const currentPricingPlan = useSelector((state: OxalisState) =>
state.activeOrganization ? state.activeOrganization.pricingPlan : PricingPlanEnum.Basic,
);
const isFeatureAllowed = isPricingPlanGreaterEqualThan(currentPricingPlan, requiredPricingPlan);

if (isFeatureAllowed) return <Button {...buttonProps}>{children}</Button>;

return (
<Tooltip title={toolTipMessage} placement="right">
<Button {...buttonProps} disabled>
{children}
{showLockIcon ? <LockOutlined style={{ marginLeft: 5 }} /> : null}
</Button>
</Tooltip>
);
}

export function PageUnavailableForYourPlanView() {
const organizationName = useSelector((state: OxalisState) => state.activeOrganization?.name);

return (
<div className="container">
<Alert
style={{
maxWidth: "500px",
margin: "0 auto",
}}
message="Feature not available"
description={
<>
<p>
The requested feature is not available in your WEBKNOSSOS organization. Consider
upgrading to a higher WEBKNOSSOS plan to unlock it or ask your organization's owner to
upgrade.
</p>
<Link to={`/organizations/${organizationName}`}>Go to Organization Settings</Link>
philippotto marked this conversation as resolved.
Show resolved Hide resolved
</>
}
type="error"
showIcon
/>
</div>
);
}
Loading