Skip to content

Commit

Permalink
Enforce Pricing for Task/Project Management Features (#6767)
Browse files Browse the repository at this point in the history
* enforce pricing plan for features (WIP)
* enforce pricing for navbar links
* Merge branch 'master' of github.com:scalableminds/webknossos into enforce_plan

* 'master' of github.com:scalableminds/webknossos:
  Update VoxelyticsDAO.scala
  Show voxel size in details sidebar in new datasets tab (#6755)
  Fix font import (#6754)
  Fix breadcrumbs (II) (#6753)
* add organization to store
* fix type errors for activeOrganization
* Merge branch 'master' of github.com:scalableminds/webknossos into enforce_plan

* 'master' of github.com:scalableminds/webknossos:
  Make voxelytics sql queries compatible with postgres 10 (#6763)
  Adapt viewport and crosshair colors to new color scheme (#6760)
  minor logo fix (#6762)
  Fix superuser organization switching (#6756)
* naming stuff is hard
* Merge branch 'master' of github.com:scalableminds/webknossos into enforce_plan
* 'master' of github.com:scalableminds/webknossos:
  Fix antd deprecation warning for <Modal open/visible> (#6765)
  Fix exploring remote datasets with no credentials (#6764)
* added new page for when features are not available due to pricing limits
* prevent folder creation on dashboard
* Merge branch 'master' into enforce_plan
* applied PR feedback
* applied even more PR feedback
* Update frontend/javascripts/components/pricing_enforcers.tsx

Co-authored-by: Philipp Otto <[email protected]>
* Update frontend/javascripts/components/pricing_enforcers.tsx

Co-authored-by: Philipp Otto <[email protected]>
* Merge branch 'master' into enforce_plan
  • Loading branch information
hotzenklotz authored Jan 23, 2023
1 parent 84fbe36 commit d195800
Show file tree
Hide file tree
Showing 22 changed files with 374 additions and 146 deletions.
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
8 changes: 4 additions & 4 deletions frontend/javascripts/admin/dataset/dataset_upload_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -690,20 +690,20 @@ class DatasetUploadView extends React.Component<PropsWithFormAndRouter, State> {
return;
}

const teamOfOrganisation = fetchedTeams.find(
const teamOfOrganization = fetchedTeams.find(
(team) => team.name === team.organization,
);

if (teamOfOrganisation == null) {
if (teamOfOrganization == null) {
return;
}

if (this.formRef.current == null) return;
this.formRef.current.setFieldsValue({
initialTeams: [teamOfOrganisation],
initialTeams: [teamOfOrganization],
});
this.setState({
selectedTeams: [teamOfOrganisation],
selectedTeams: [teamOfOrganization],
});
}}
/>
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
98 changes: 29 additions & 69 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,17 +8,14 @@ import {
SaveOutlined,
IdcardOutlined,
} from "@ant-design/icons";
import React from "react";
import { confirmAsync } from "dashboard/dataset/helper_components";
import {
getOrganization,
deleteOrganization,
updateOrganization,
getUsers,
getPricingPlanStatus,
} from "admin/admin_rest_api";
import Toast from "libs/toast";
import { coalesce } from "libs/utils";
import { APIOrganization, APIPricingPlanStatus } from "types/api_flow_types";
import {
PlanAboutToExceedAlert,
Expand All @@ -26,7 +24,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,80 +37,40 @@ export enum PricingPlanEnum {
PowerTrial = "Power_Trial",
Custom = "Custom",
}

type Props = {
organizationName: string;
organization: APIOrganization;
};

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,
isFetchingData: false,
isDeleting: false,
organization: null,
activeUsersCount: 1,
pricingPlanStatus: null,
};

formRef = React.createRef<FormInstance>();

componentDidMount() {
this.fetchData();
}

componentDidUpdate(_prevProps: Props, prevState: State) {
if (this.formRef.current != null) {
// initialValues only works on the first render. Afterwards, values need to be updated
// using setFieldsValue
if (
prevState.displayName.length === 0 &&
this.state.displayName.length > 0 &&
this.formRef.current.getFieldValue("displayName") !== this.state.displayName
) {
this.formRef.current.setFieldsValue({
displayName: this.state.displayName,
});
}

if (
prevState.newUserMailingList.length === 0 &&
this.state.newUserMailingList.length > 0 &&
this.formRef.current.getFieldValue("newUserMailingList") !== this.state.newUserMailingList
) {
this.formRef.current.setFieldsValue({
newUserMailingList: this.state.newUserMailingList,
});
}
}
}

async fetchData() {
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;
this.setState({
displayName,
pricingPlan: coalesce(PricingPlanEnum, pricingPlan),
newUserMailingList,
isFetchingData: false,
organization,
pricingPlanStatus,
activeUsersCount: getActiveUserCount(users),
});
Expand All @@ -119,7 +79,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 @@ -131,7 +91,7 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
title: (
<p>
Deleting an organization cannot be undone. Are you certain you want to delete the
organization {this.state.displayName}? <br />
organization {this.props.organization.displayName}? <br />
Attention: You will be logged out.
</p>
),
Expand All @@ -142,7 +102,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,17 +111,12 @@ 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.state.pricingPlan ||
!this.state.pricingPlanStatus
)
if (this.state.isFetchingData || !this.props.organization || !this.state.pricingPlanStatus)
return (
<div
className="container"
Expand All @@ -188,36 +143,36 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
>
<Row style={{ color: "#aaa", fontSize: "12" }}>Your Organization</Row>
<Row style={{ marginBottom: 20 }}>
<h2>{this.state.displayName}</h2>
<h2>{this.props.organization.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}
layout="vertical"
ref={this.formRef}
initialValues={{
displayName: this.state.displayName,
newUserMailingList: this.state.newUserMailingList,
displayName: this.props.organization.displayName,
newUserMailingList: this.props.organization.newUserMailingList,
}}
>
<FormItem label="Organization ID">
<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 +258,9 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
}
}

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

const connector = connect(mapStateToProps);
export default connector(OrganizationEditView);
16 changes: 16 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,19 @@ export function hasPricingPlanExceededStorage(organization: APIOrganization): bo
export function isUserAllowedToRequestUpgrades(user: APIUser): boolean {
return user.isAdmin || user.isOrganizationOwner;
}

const PLAN_TO_RANK = {
[PricingPlanEnum.Basic]: 0,
[PricingPlanEnum.Team]: 1,
[PricingPlanEnum.TeamTrial]: 1,
[PricingPlanEnum.Power]: 2,
[PricingPlanEnum.PowerTrial]: 2,
[PricingPlanEnum.Custom]: 2,
};

export function isPricingPlanGreaterEqualThan(
planA: PricingPlanEnum,
planB: PricingPlanEnum,
): boolean {
return PLAN_TO_RANK[planA] >= PLAN_TO_RANK[planB];
}
26 changes: 14 additions & 12 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 All @@ -58,7 +65,6 @@ type State = {
activationFilter: Array<"true" | "false">;
searchQuery: string;
domainToEdit: string | null | undefined;
maxUserCountPerOrganization: number;
};
const persistence = new Persistence<Pick<State, "searchQuery" | "activationFilter">>(
{
Expand All @@ -80,7 +86,6 @@ class UserListView extends React.PureComponent<Props, State> {
searchQuery: "",
singleSelectedUser: null,
domainToEdit: null,
maxUserCountPerOrganization: Number.POSITIVE_INFINITY,
};

componentDidMount() {
Expand All @@ -103,15 +108,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 @@ -279,7 +280,7 @@ class UserListView extends React.PureComponent<Props, State> {
message="You reached the maximum number of users"
description={`You organization reached the maxmium number of users included in your current plan. Consider upgrading your WEBKNOSSOS plan to accommodate more users or deactivate some user accounts. Email invites are disabled in the meantime. Your organization currently has ${getActiveUserCount(
this.state.users,
)} active users of ${this.state.maxUserCountPerOrganization} allowed by your plan.`}
)} active users of ${this.props.activeOrganization.includedUsers} allowed by your plan.`}
type="error"
showIcon
style={{
Expand Down Expand Up @@ -342,7 +343,7 @@ class UserListView extends React.PureComponent<Props, State> {
};
const noOtherUsers = this.state.users.length < 2;
const isUserInvitesDisabled =
getActiveUserCount(this.state.users) >= this.state.maxUserCountPerOrganization;
getActiveUserCount(this.state.users) >= this.props.activeOrganization.includedUsers;

return (
<div className="container test-UserListView">
Expand Down Expand Up @@ -394,7 +395,7 @@ class UserListView extends React.PureComponent<Props, State> {
</Button>
<InviteUsersModal
currentUserCount={getActiveUserCount(this.state.users)}
maxUserCountPerOrganization={this.state.maxUserCountPerOrganization}
maxUserCountPerOrganization={this.props.activeOrganization.includedUsers}
isOpen={this.state.isInviteModalOpen}
organizationName={this.props.activeUser.organization}
handleVisibleChange={(visible) => {
Expand Down Expand Up @@ -681,6 +682,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
Loading

0 comments on commit d195800

Please sign in to comment.