+}
\ No newline at end of file
diff --git a/app/views/mail/upgradePricingPlanToPower.scala.html b/app/views/mail/upgradePricingPlanToPower.scala.html
new file mode 100644
index 00000000000..fda8718ec30
--- /dev/null
+++ b/app/views/mail/upgradePricingPlanToPower.scala.html
@@ -0,0 +1,10 @@
+@( name: String)
+
+@emailBaseTemplate() {
+
Hi @{name}
+
Thank you for requesting an upgrade to a webKnossos Power plan. Our sales team will be in contact with
+ you shortly with a formal offer.
+
+
+
With best regards, the webKnossos-Team
+}
\ No newline at end of file
diff --git a/app/views/mail/upgradePricingPlanToTeam.scala.html b/app/views/mail/upgradePricingPlanToTeam.scala.html
new file mode 100644
index 00000000000..81a9504b8eb
--- /dev/null
+++ b/app/views/mail/upgradePricingPlanToTeam.scala.html
@@ -0,0 +1,11 @@
+@( name: String)
+
+@emailBaseTemplate() {
+
Hi @{name}
+
Thank you for requesting an upgrade to a webKnossos Team plan. Our sales team will be in contact with
+ you shortly with a formal offer.
+
+
+
+
With best regards, the webKnossos-Team
+}
\ No newline at end of file
diff --git a/app/views/mail/upgradePricingPlanUsers.scala.html b/app/views/mail/upgradePricingPlanUsers.scala.html
new file mode 100644
index 00000000000..8023ffed33f
--- /dev/null
+++ b/app/views/mail/upgradePricingPlanUsers.scala.html
@@ -0,0 +1,11 @@
+@( name: String, requestedUsers: Int)
+
+@emailBaseTemplate() {
+
Hi @{name}
+
Thank you for requesting to upgrade your webKnossos plan with more users. Our sales team will be in contact with
+ you shortly with a formal offer.
+
+
Requested additional users: @{requestedUsers}
+
+
With best regards, the webKnossos-Team
+}
\ No newline at end of file
diff --git a/conf/evolutions/094-pricing-plans.sql b/conf/evolutions/094-pricing-plans.sql
new file mode 100644
index 00000000000..a8f061b72ad
--- /dev/null
+++ b/conf/evolutions/094-pricing-plans.sql
@@ -0,0 +1,45 @@
+
+START TRANSACTION;
+
+ALTER TABLE webknossos.organizations
+ADD paidUntil TIMESTAMPTZ DEFAULT NULL,
+ADD includedUsers INTEGER DEFAULT NULL,
+ADD includedStorage BIGINT DEFAULT NULL;
+
+-- Drop dependent views
+DROP VIEW webknossos.userinfos;
+DROP VIEW webknossos.organizations_;
+
+-- Edit pricing plans enum
+ALTER TYPE webknossos.PRICING_PLANS RENAME TO prizing_plans_old;
+CREATE TYPE webknossos.PRICING_PLANS AS ENUM ('Basic', 'Team', 'Power', 'Team_Trial', 'Power_Trial', 'Custom');
+ALTER TABLE webknossos.organizations
+ ALTER COLUMN pricingPLan DROP DEFAULT,
+ ALTER COLUMN pricingPlan TYPE webknossos.PRICING_PLANS USING
+ CASE pricingPlan
+ WHEN 'Basic'::webknossos.prizing_plans_old THEN 'Basic'::webknossos.PRICING_PLANS
+ WHEN 'Premium'::webknossos.prizing_plans_old THEN 'Team'::webknossos.PRICING_PLANS
+ WHEN 'Pilot'::webknossos.prizing_plans_old THEN 'Team'::webknossos.PRICING_PLANS
+ ELSE 'Custom'::webknossos.PRICING_PLANS
+ END,
+ ALTER COLUMN pricingPlan SET DEFAULT 'Custom'::webknossos.PRICING_PLANS;
+DROP TYPE webknossos.prizing_plans_old;
+
+UPDATE webknossos.organizations SET includedUsers = 3, includedStorage = 5e10 WHERE pricingplan = 'Basic'::webknossos.PRICING_PLANS;
+UPDATE webknossos.organizations SET includedUsers = 5, includedStorage = 1e12 WHERE pricingplan = 'Team'::webknossos.PRICING_PLANS;
+
+-- Recreate views
+CREATE VIEW webknossos.organizations_ AS SELECT * FROM webknossos.organizations WHERE NOT isDeleted;
+CREATE VIEW webknossos.userInfos AS
+SELECT
+ u._id AS _user, m.email, u.firstName, u.lastname, o.displayName AS organization_displayName,
+ u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser,
+ u._organization, o.name AS organization_name, u.created AS user_created,
+ m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity
+FROM webknossos.users_ u
+ JOIN webknossos.organizations_ o ON u._organization = o._id
+ JOIN webknossos.multiUsers_ m on u._multiUser = m._id;
+
+UPDATE webknossos.releaseInformation SET schemaVersion = 94;
+
+COMMIT TRANSACTION;
diff --git a/conf/evolutions/reversions/094-pricing-plans.sql b/conf/evolutions/reversions/094-pricing-plans.sql
new file mode 100644
index 00000000000..fa39d0f0114
--- /dev/null
+++ b/conf/evolutions/reversions/094-pricing-plans.sql
@@ -0,0 +1,40 @@
+BEGIN transaction;
+
+-- Drop dependent views
+DROP VIEW webknossos.userinfos;
+DROP VIEW webknossos.organizations_;
+
+ALTER TABLE webknossos.organizations
+ DROP COLUMN paidUntil,
+ DROP COLUMN includedUsers,
+ DROP COLUMN includedStorage;
+
+-- Edit pricing plans enum
+ALTER TYPE webknossos.PRICING_PLANS RENAME TO prizing_plans_old;
+CREATE TYPE webknossos.PRICING_PLANS AS ENUM ('Basic', 'Premium', 'Pilot', 'Custom');
+ALTER TABLE webknossos.organizations
+ ALTER COLUMN pricingPLan DROP DEFAULT,
+ ALTER COLUMN pricingPlan TYPE webknossos.PRICING_PLANS USING
+ CASE pricingPlan
+ WHEN 'Basic'::webknossos.prizing_plans_old THEN 'Basic'::webknossos.PRICING_PLANS
+ WHEN 'Team'::webknossos.prizing_plans_old THEN 'Premium'::webknossos.PRICING_PLANS
+ ELSE 'Custom'::webknossos.PRICING_PLANS
+ END,
+ ALTER COLUMN pricingPlan SET DEFAULT 'Custom'::webknossos.PRICING_PLANS;
+DROP TYPE webknossos.prizing_plans_old;
+
+-- Recreate views
+CREATE VIEW webknossos.organizations_ AS SELECT * FROM webknossos.organizations WHERE NOT isDeleted;
+CREATE VIEW webknossos.userInfos AS
+SELECT
+ u._id AS _user, m.email, u.firstName, u.lastname, o.displayName AS organization_displayName,
+ u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser,
+ u._organization, o.name AS organization_name, u.created AS user_created,
+ m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity
+FROM webknossos.users_ u
+ JOIN webknossos.organizations_ o ON u._organization = o._id
+ JOIN webknossos.multiUsers_ m on u._multiUser = m._id;
+
+UPDATE webknossos.releaseInformation SET schemaVersion = 93;
+
+COMMIT;
diff --git a/conf/messages b/conf/messages
index c6b3bf4ff3a..c9c936e24fa 100644
--- a/conf/messages
+++ b/conf/messages
@@ -37,6 +37,8 @@ organization.list.failed=Failed to retrieve list of organizations.
organization.name.invalid=This organization name contains illegal characters. Please only use letters and numbers.
organization.name.alreadyInUse=This name is already claimed by a different organization and not available anymore. Please choose a different name.
organization.alreadyJoined=Your account is already associated with the selected organization.
+organization.users.userLimitReached=Cannot add user because it would exceed the limit of included users in this plan.
+organization.pricingUpgrades.notAuthorized=You are not authorized to request any changes to your organization webKnossos plan. Please ask the organization owner for permission.
termsOfService.versionMismatch=Terms of service version mismatch. Current version is {0}, received acceptance for {1}
diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes
index 5f334a74fd8..4859cf5f6af 100644
--- a/conf/webknossos.latest.routes
+++ b/conf/webknossos.latest.routes
@@ -230,6 +230,11 @@ GET /organizations/:organizationName
PATCH /organizations/:organizationName controllers.OrganizationController.update(organizationName: String)
DELETE /organizations/:organizationName controllers.OrganizationController.delete(organizationName: String)
GET /operatorData controllers.OrganizationController.getOperatorData
+POST /pricing/requestExtension controllers.OrganizationController.sendExtendPricingPlanEmail
+POST /pricing/requestUpgrade controllers.OrganizationController.sendUpgradePricingPlanEmail(requestedPlan: String)
+POST /pricing/requestUsers controllers.OrganizationController.sendUpgradePricingPlanUsersEmail(requestedUsers: Int)
+POST /pricing/requestStorage controllers.OrganizationController.sendUpgradePricingPlanStorageEmail(requestedStorage: Int)
+GET /pricing/status controllers.OrganizationController.pricingStatus
GET /termsOfService controllers.OrganizationController.getTermsOfService
POST /termsOfService/accept controllers.OrganizationController.acceptTermsOfService(version: Int)
GET /termsOfService/acceptanceNeeded controllers.OrganizationController.termsOfServiceAcceptanceNeeded
diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts
index ce0d83cd94c..c7e372e41f3 100644
--- a/frontend/javascripts/admin/admin_rest_api.ts
+++ b/frontend/javascripts/admin/admin_rest_api.ts
@@ -63,10 +63,12 @@ import type {
VoxelyticsWorkflowReport,
VoxelyticsChunkStatistics,
ShortLink,
+ APIOrganizationStorageInfo,
+ APIPricingPlanStatus,
} from "types/api_flow_types";
import { APIAnnotationTypeEnum } from "types/api_flow_types";
import type { Vector3, Vector6 } from "oxalis/constants";
-import { ControlModeEnum } from "oxalis/constants";
+import Constants, { ControlModeEnum } from "oxalis/constants";
import type {
DatasetConfiguration,
PartialDatasetConfiguration,
@@ -1928,8 +1930,14 @@ export function sendInvitesForOrganization(
});
}
-export function getOrganization(organizationName: string): Promise {
- return Request.receiveJSON(`/api/organizations/${organizationName}`);
+export async function getOrganization(organizationName: string): Promise {
+ const organization = await Request.receiveJSON(`/api/organizations/${organizationName}`);
+ return {
+ ...organization,
+ paidUntil: organization.paidUntil ?? Constants.MAXIMUM_DATE_TIMESTAMP,
+ includedStorage: organization.includedStorage ?? Number.POSITIVE_INFINITY,
+ includedUsers: organization.includedUsers ?? Number.POSITIVE_INFINITY,
+ };
}
export async function checkAnyOrganizationExists(): Promise {
@@ -1982,6 +1990,42 @@ export async function isWorkflowAccessibleBySwitching(
return Request.receiveJSON(`/api/auth/accessibleBySwitching?workflowHash=${workflowHash}`);
}
+export async function getOrganizationStorageSpace(
+ _organizationName: string,
+): Promise {
+ // TODO switch to a real API. See PR #6614
+ const usedStorageMB = 0;
+ return Promise.resolve({ usedStorageSpace: usedStorageMB });
+}
+
+export async function sendUpgradePricingPlanEmail(requestedPlan: string): Promise {
+ return Request.receiveJSON(`/api/pricing/requestUpgrade?requestedPlan=${requestedPlan}`, {
+ method: "POST",
+ });
+}
+
+export async function sendExtendPricingPlanEmail(): Promise {
+ return Request.receiveJSON("/api/pricing/requestExtension", {
+ method: "POST",
+ });
+}
+
+export async function sendUpgradePricingPlanUserEmail(requestedUsers: number): Promise {
+ return Request.receiveJSON(`/api/pricing/requestUsers?requestedUsers=${requestedUsers}`, {
+ method: "POST",
+ });
+}
+
+export async function sendUpgradePricingPlanStorageEmail(requestedStorage: number): Promise {
+ return Request.receiveJSON(`/api/pricing/requestStorage?requestedStorage=${requestedStorage}`, {
+ method: "POST",
+ });
+}
+
+export async function getPricingPlanStatus(): Promise {
+ return Request.receiveJSON("/api/pricing/status");
+}
+
// ### BuildInfo webknossos
export function getBuildInfo(): Promise {
return Request.receiveJSON("/api/buildinfo", {
diff --git a/frontend/javascripts/admin/onboarding.tsx b/frontend/javascripts/admin/onboarding.tsx
index 09b9bf76c05..e878dacf336 100644
--- a/frontend/javascripts/admin/onboarding.tsx
+++ b/frontend/javascripts/admin/onboarding.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { Form, Modal, Input, Button, Row, Col, Steps, Card, AutoComplete } from "antd";
+import { Form, Modal, Input, Button, Row, Col, Steps, Card, AutoComplete, Alert } from "antd";
import {
CloudUploadOutlined,
TeamOutlined,
@@ -12,9 +12,9 @@ import {
CodeOutlined,
CustomerServiceOutlined,
PlusOutlined,
+ UserAddOutlined,
} from "@ant-design/icons";
-import type { RouteComponentProps } from "react-router-dom";
-import { withRouter } from "react-router-dom";
+import { Link, RouteComponentProps, withRouter } from "react-router-dom";
import { connect } from "react-redux";
import type { APIUser, APIDataStore } from "types/api_flow_types";
import type { OxalisState } from "oxalis/store";
@@ -27,6 +27,7 @@ import RegistrationForm from "admin/auth/registration_form";
import CreditsFooter from "components/credits_footer";
import Toast from "libs/toast";
import features from "features";
+import { maxInludedUsersInBasicPlan } from "admin/organization/pricing_plan_utils";
const { Step } = Steps;
const FormItem = Form.Item;
@@ -222,22 +223,30 @@ export class InviteUsersModal extends React.Component<
visible?: boolean;
handleVisibleChange?: (...args: Array) => any;
destroy?: (...args: Array) => any;
+ organizationName: string;
+ currentUserCount: number;
+ maxUserCountPerOrganization: number;
},
InviteUsersModalState
> {
state: InviteUsersModalState = {
inviteesString: "",
};
- sendInvite = async () => {
- const addresses = this.state.inviteesString.split(/[,\s]+/);
- const incorrectAddresses = addresses.filter((address) => !address.includes("@"));
- if (incorrectAddresses.length > 0) {
- Toast.error(
- `Couldn't recognize this email address: ${incorrectAddresses[0]}. No emails were sent.`,
- );
- return;
- }
+ static defaultProps = {
+ currentUserCount: 1,
+ maxUserCountPerOrganization: maxInludedUsersInBasicPlan, // default for Basic Plan
+ };
+
+ extractEmailAddresses(): string[] {
+ return this.state.inviteesString
+ .split(/[,\s]+/)
+ .map((a) => a.trim())
+ .filter((lines) => lines.includes("@"));
+ }
+
+ sendInvite = async () => {
+ const addresses = this.extractEmailAddresses();
await sendInvitesForOrganization(addresses, true);
Toast.success("An invitation was sent to the provided email addresses.");
@@ -248,12 +257,36 @@ export class InviteUsersModal extends React.Component<
if (this.props.destroy != null) this.props.destroy();
};
- getContent() {
+ getContent(isInvitesDisabled: boolean) {
+ const exceedingUserLimitAlert = isInvitesDisabled ? (
+
+
+
+ }
+ />
+ ) : null;
+
return (
- Send invites to the following email addresses. Multiple addresses should be separated with a
- comma, a space or a new line. Note that new users have limited permissions by default which
- is why their role and team assignments should be doublechecked after account activation.
+
+ Send an email to invite your colleagues and collaboration partners to your organization.
+ Share datasets, collaboratively work on annotations, and organize complex analysis
+ projects.
+
+
Multiple email addresses should be separated with a comma, a space or a new line.
+
+ Note that new users have limited access permissions by default. Please doublecheck their
+ roles and team assignments after they join your organization.
+
+ {exceedingUserLimitAlert}
=
+ this.props.maxUserCountPerOrganization;
+
return (
+ Invite Users
+ >
+ }
width={600}
footer={
-
);
}
@@ -564,9 +605,10 @@ class OnboardingView extends React.PureComponent {
})
}
>
- Invite users
+ Invite users to work collaboratively
{" "}
this.setState({
diff --git a/frontend/javascripts/admin/organization/organization_cards.tsx b/frontend/javascripts/admin/organization/organization_cards.tsx
new file mode 100644
index 00000000000..48dae909ce7
--- /dev/null
+++ b/frontend/javascripts/admin/organization/organization_cards.tsx
@@ -0,0 +1,386 @@
+import {
+ FieldTimeOutlined,
+ PlusCircleOutlined,
+ RocketOutlined,
+ SafetyOutlined,
+} from "@ant-design/icons";
+import { Alert, Button, Card, Col, Progress, Row } from "antd";
+import { formatDateInLocalTimeZone } from "components/formatted_date";
+import moment from "moment";
+import Constants from "oxalis/constants";
+import { OxalisState } from "oxalis/store";
+import React from "react";
+import { useSelector } from "react-redux";
+import { APIOrganization } from "types/api_flow_types";
+import { PricingPlanEnum } from "./organization_edit_view";
+import {
+ hasPricingPlanExceededStorage,
+ hasPricingPlanExceededUsers,
+ hasPricingPlanExpired,
+ isUserAllowedToRequestUpgrades,
+ powerPlanFeatures,
+ teamPlanFeatures,
+} from "./pricing_plan_utils";
+import UpgradePricingPlanModal from "./upgrade_plan_modal";
+
+export function TeamAndPowerPlanUpgradeCards({
+ teamUpgradeCallback,
+ powerUpgradeCallback,
+}: {
+ teamUpgradeCallback: () => void;
+ powerUpgradeCallback: () => void;
+}) {
+ return (
+
+
+ Delete this organization including all annotations, uploaded datasets, and associated
+ user accounts. Careful, this action can NOT be undone.
+
+
{
>
Delete Organization
-
-
+
+
);
diff --git a/frontend/javascripts/admin/organization/pricing_plan_utils.ts b/frontend/javascripts/admin/organization/pricing_plan_utils.ts
new file mode 100644
index 00000000000..defa27dc889
--- /dev/null
+++ b/frontend/javascripts/admin/organization/pricing_plan_utils.ts
@@ -0,0 +1,46 @@
+import { APIOrganization, APIOrganizationStorageInfo, APIUser } from "types/api_flow_types";
+import { PricingPlanEnum } from "./organization_edit_view";
+
+export const teamPlanFeatures = [
+ "Collaborative Annotation",
+ "Project Management",
+ "Dataset Management and Access Control",
+ "5 Users / 1TB Storage (upgradable)",
+ "Priority Email Support",
+ "Everything from Basic plan",
+];
+export const powerPlanFeatures = [
+ "Unlimited Users",
+ "Segmentation Proof-Reading Tool",
+ "On-premise or dedicated hosting solutions available",
+ "Integration with your HPC and storage servers",
+ "Everything from Team and Basic plans",
+];
+
+export const maxInludedUsersInBasicPlan = 3;
+
+export function getActiveUserCount(users: APIUser[]): number {
+ return users.filter((user) => user.isActive && !user.isSuperUser).length;
+}
+
+export function hasPricingPlanExpired(organization: APIOrganization): boolean {
+ return Date.now() > organization.paidUntil;
+}
+
+export function hasPricingPlanExceededUsers(
+ organization: APIOrganization,
+ activeUserCount: number,
+): boolean {
+ return activeUserCount > organization.includedUsers;
+}
+
+export function hasPricingPlanExceededStorage(
+ organization: APIOrganization,
+ usedStorageSpaceMB: number,
+): boolean {
+ return usedStorageSpaceMB > organization.includedStorage;
+}
+
+export function isUserAllowedToRequestUpgrades(user: APIUser): boolean {
+ return user.isAdmin || user.isOrganizationOwner;
+}
diff --git a/frontend/javascripts/admin/organization/upgrade_plan_modal.tsx b/frontend/javascripts/admin/organization/upgrade_plan_modal.tsx
new file mode 100644
index 00000000000..9449049995e
--- /dev/null
+++ b/frontend/javascripts/admin/organization/upgrade_plan_modal.tsx
@@ -0,0 +1,322 @@
+import React, { useRef } from "react";
+import { Button, Divider, InputNumber, Modal } from "antd";
+import moment from "moment";
+import {
+ DatabaseOutlined,
+ FieldTimeOutlined,
+ RocketOutlined,
+ UserAddOutlined,
+} from "@ant-design/icons";
+import { APIOrganization } from "types/api_flow_types";
+import { formatDateInLocalTimeZone } from "components/formatted_date";
+import {
+ sendExtendPricingPlanEmail,
+ sendUpgradePricingPlanEmail,
+ sendUpgradePricingPlanStorageEmail,
+ sendUpgradePricingPlanUserEmail,
+} from "admin/admin_rest_api";
+import { powerPlanFeatures, teamPlanFeatures } from "./pricing_plan_utils";
+import { PricingPlanEnum } from "./organization_edit_view";
+import renderIndependently from "libs/render_independently";
+import Toast from "libs/toast";
+import { TeamAndPowerPlanUpgradeCards } from "./organization_cards";
+import messages from "messages";
+
+const ModalInformationFooter = (
+ <>
+
+
+ Requesting an upgrade to your organization's webKnossos plan will send an email to the
+ webKnossos sales team. We typically respond within one business day. See our{" "}
+ FAQ for more information.
+
+ Extend your plan now for uninterrupted access to webKnossos.
+
+
+ Expired plans will be downgraded to the Basic plan and you might lose access to some
+ webKnossos features and see restrictions on the number of permitted user accounts and your
+ included storage space quota.
+
+
+ Your current plan is paid until:{" "}
+ {formatDateInLocalTimeZone(organization.paidUntil, "YYYY-MM-DD")}
+
+ You can increase the number of users allowed to join your organization by either buying
+ single user upgrades or by upgrading your webKnossos plan to “Power” for unlimited users.
+
+ You can increase your storage limit for your organization by either buying additional
+ storage upgrades or by upgrading your webKnossos plan to “Power” for custom dataset
+ hosting solution, e.g. streaming data from your storage server / the cloud.
+
+
Add additional storage (in Terabyte):
+
+
+
+ {ModalInformationFooter}
+
+
+ );
+}
+
+function upgradePricingPlan(
+ organization: APIOrganization,
+ targetPlan?: PricingPlanEnum | "TeamAndPower",
+) {
+ let target = targetPlan;
+
+ if (targetPlan === undefined) {
+ switch (organization.pricingPlan) {
+ case PricingPlanEnum.Basic: {
+ target = "TeamAndPower";
+ break;
+ }
+ case PricingPlanEnum.Team:
+ case PricingPlanEnum.TeamTrial: {
+ target = PricingPlanEnum.Power;
+ break;
+ }
+ case PricingPlanEnum.Custom:
+ default:
+ return;
+ }
+ }
+
+ let title = `Upgrade to ${PricingPlanEnum.Team} Plan`;
+ let okButtonCallback: (() => void) | undefined = () => {
+ sendUpgradePricingPlanEmail(PricingPlanEnum.Team);
+ Toast.success(messages["organization.plan.upgrage_request_sent"]);
+ };
+ let modalBody = (
+ <>
+