diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 0751e3394d2..3e7f44604df 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -13,7 +13,19 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added ### Changed +- The log viewer in the Voxelytics workflow reporting now uses a virtualized list. [#6579](https://github.com/scalableminds/webknossos/pull/6579) +- Node positions are always handled as integers. They have always been persisted as integers by the server, anyway, but the session in which a node was created handled the position as floating point in earlier versions. [#6589](https://github.com/scalableminds/webknossos/pull/6589) +- Jobs can no longer be started on datastores without workers. [#6595](https://github.com/scalableminds/webknossos/pull/6595) +- When downloading volume annotations with volume data skipped, the nml volume tag is now included anyway (but has no location attribute in this case). [#6566](https://github.com/scalableminds/webknossos/pull/6566) +- Re-phrased some backend (error) messages to improve clarity and provide helping hints. [#6616](https://github.com/scalableminds/webknossos/pull/6616) +- The layer visibility is now encoded in the sharing link. The user opening the link will see the same layers that were visible when copying the link. [#6634](https://github.com/scalableminds/webknossos/pull/6634) +- Voxelytics workflows can now be viewed by anyone with the link who is in the right organization. [#6622](https://github.com/scalableminds/webknossos/pull/6622) +- webKnossos is now able to recover from a lost webGL context. [#6663](https://github.com/scalableminds/webknossos/pull/6663) +- Bulk task creation now needs the taskTypeId, the task type summary will no longer be accepted. [#6640](https://github.com/scalableminds/webknossos/pull/6640) +- Error handling and reporting is more robust now. [#6700](https://github.com/scalableminds/webknossos/pull/6700) +- The Quick-Select settings are opened (and closed) automatically when labeling with the preview mode. That way, bulk labelings with preview mode don't require constantly opening the settings manually. [#6706](https://github.com/scalableminds/webknossos/pull/6706) - Improved performance of opening a dataset or annotation. [#6711](https://github.com/scalableminds/webknossos/pull/6711) +- Redesigned organization page to include more infos on organization users, storage, webKnossos plan and provided opportunities to upgrade. [#6602](https://github.com/scalableminds/webknossos/pull/6602) ### Fixed - Fixed the validation of some neuroglancer URLs during import. [#6722](https://github.com/scalableminds/webknossos/pull/6722) diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index d449a85d922..4aa62a49c5e 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -9,3 +9,5 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md). [Commits](https://github.com/scalableminds/webknossos/compare/23.01.0...HEAD) ### Postgres Evolutions: + +- [094-pricing-plans.sql](conf/evolutions/reversions/094-pricing-plans.sql) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 559aa1003d4..3181305727e 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -99,6 +99,8 @@ class AuthenticationController @Inject()( organization <- organizationService.findOneByInviteByNameOrDefault( inviteBox.toOption, organizationName)(GlobalAccessContext) ?~> Messages("organization.notFound", signUpData.organization) + _ <- organizationService + .assertUsersCanBeAdded(organization)(GlobalAccessContext, ec) ?~> "organization.users.userLimitReached" autoActivate = inviteBox.toOption.map(_.autoActivate).getOrElse(organization.enableAutoVerify) _ <- createUser(organization, email, @@ -338,6 +340,10 @@ class AuthenticationController @Inject()( invite <- inviteDAO.findOneByTokenValue(inviteToken) ?~> "invite.invalidToken" organization <- organizationDAO.findOne(invite._organization)(GlobalAccessContext) ?~> "invite.invalidToken" _ <- userService.assertNotInOrgaYet(request.identity._multiUser, organization._id) + requestingMultiUser <- multiUserDAO.findOne(request.identity._multiUser) + _ <- Fox.runIf(!requestingMultiUser.isSuperUser)( + organizationService + .assertUsersCanBeAdded(organization)(GlobalAccessContext, ec)) ?~> "organization.users.userLimitReached" _ <- userService.joinOrganization(request.identity, organization._id, autoActivate = invite.autoActivate) _ = analyticsService.track(JoinOrganizationEvent(request.identity, organization)) userEmail <- userService.emailFor(request.identity) diff --git a/app/controllers/InitialDataController.scala b/app/controllers/InitialDataController.scala index 02e63ca9b28..4a8d25cd94f 100644 --- a/app/controllers/InitialDataController.scala +++ b/app/controllers/InitialDataController.scala @@ -67,13 +67,18 @@ Samplecountry """ private val organizationTeamId = ObjectId.generate private val defaultOrganization = - Organization(ObjectId.generate, - "sample_organization", - additionalInformation, - "/assets/images/oxalis.svg", - "Sample Organization", - PricingPlan.Custom, - ObjectId.generate) + Organization( + ObjectId.generate, + "sample_organization", + additionalInformation, + "/assets/images/oxalis.svg", + "Sample Organization", + PricingPlan.Custom, + None, + None, + None, + ObjectId.generate + ) private val organizationTeam = Team(organizationTeamId, defaultOrganization._id, "Default", isOrganizationTeam = true) private val userId = ObjectId.generate diff --git a/app/controllers/OrganizationController.scala b/app/controllers/OrganizationController.scala index 41d54d59608..3b303e47fb9 100755 --- a/app/controllers/OrganizationController.scala +++ b/app/controllers/OrganizationController.scala @@ -1,17 +1,21 @@ package controllers +import akka.actor.ActorSystem import com.mohiva.play.silhouette.api.Silhouette import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContext} import com.scalableminds.util.tools.{Fox, FoxImplicits} import javax.inject.Inject import models.organization.{OrganizationDAO, OrganizationService} -import models.user.{InviteDAO, MultiUserDAO, UserDAO} +import models.user.{InviteDAO, MultiUserDAO, UserDAO, UserService} +import models.team.{PricingPlan} import oxalis.security.{WkEnv, WkSilhouetteEnvironment} import play.api.i18n.Messages import play.api.libs.functional.syntax._ import play.api.libs.json.{JsNull, JsValue, Json, __} import play.api.mvc.{Action, AnyContent} import utils.WkConf +import scala.concurrent.duration._ +import oxalis.mail.{DefaultMails, Send} import scala.concurrent.ExecutionContext @@ -22,11 +26,15 @@ class OrganizationController @Inject()(organizationDAO: OrganizationDAO, userDAO: UserDAO, multiUserDAO: MultiUserDAO, wkSilhouetteEnvironment: WkSilhouetteEnvironment, + userService: UserService, + defaultMails: DefaultMails, + actorSystem: ActorSystem, sil: Silhouette[WkEnv])(implicit ec: ExecutionContext) extends Controller with FoxImplicits { private val combinedAuthenticatorService = wkSilhouetteEnvironment.combinedAuthenticatorService + private lazy val Mailer = actorSystem.actorSelection("/user/mailActor") def organizationsIsEmpty: Action[AnyContent] = Action.async { implicit request => for { @@ -134,7 +142,7 @@ class OrganizationController @Inject()(organizationDAO: OrganizationDAO, organization <- organizationDAO.findOneByName(organizationName) ?~> Messages("organization.notFound", organizationName) ~> NOT_FOUND _ <- bool2Fox(request.identity.isAdminOf(organization._id)) ?~> "notAllowed" ~> FORBIDDEN - _ = logger.info(s"Deleting organizaion ${organization._id}") + _ = logger.info(s"Deleting organization ${organization._id}") _ <- organizationDAO.deleteOne(organization._id) _ <- userDAO.deleteAllWithOrganization(organization._id) _ <- multiUserDAO.removeLastLoggedInIdentitiesWithOrga(organization._id) @@ -146,4 +154,91 @@ class OrganizationController @Inject()(organizationDAO: OrganizationDAO, ((__ \ 'displayName).read[String] and (__ \ 'newUserMailingList).read[String]).tupled + def sendExtendPricingPlanEmail(): Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + _ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized") + organization <- organizationDAO + .findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND + userEmail <- userService.emailFor(request.identity) + _ = Mailer ! Send(defaultMails.extendPricingPlanMail(request.identity, userEmail)) + _ = Mailer ! Send( + defaultMails.upgradePricingPlanRequestMail(request.identity, + userEmail, + organization.displayName, + "Extend webKnossos plan by a year")) + } yield Ok + } + + def sendUpgradePricingPlanEmail(requestedPlan: String): Action[AnyContent] = sil.SecuredAction.async { + implicit request => + for { + _ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized") + organization <- organizationDAO + .findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND + userEmail <- userService.emailFor(request.identity) + requestedPlan <- PricingPlan.fromString(requestedPlan) + mail = if (requestedPlan == PricingPlan.Team) { + defaultMails.upgradePricingPlanToTeamMail _ + } else { + defaultMails.upgradePricingPlanToTeamMail _ + } + _ = Mailer ! Send(mail(request.identity, userEmail)) + _ = Mailer ! Send( + defaultMails.upgradePricingPlanRequestMail(request.identity, + userEmail, + organization.displayName, + s"Upgrade webKnossos Plan to $requestedPlan")) + } yield Ok + } + + def sendUpgradePricingPlanUsersEmail(requestedUsers: Int): Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + _ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized") + organization <- organizationDAO.findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND + userEmail <- userService.emailFor(request.identity) + _ = Mailer ! Send(defaultMails.upgradePricingPlanUsersMail(request.identity, userEmail, requestedUsers)) + _ = Mailer ! Send( + defaultMails.upgradePricingPlanRequestMail(request.identity, + userEmail, + organization.displayName, + s"Purchase $requestedUsers additional users")) + } yield Ok + } + + def sendUpgradePricingPlanStorageEmail(requestedStorage: Int): Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + _ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized") + organization <- organizationDAO.findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND + userEmail <- userService.emailFor(request.identity) + _ = Mailer ! Send(defaultMails.upgradePricingPlanStorageMail(request.identity, userEmail, requestedStorage)) + _ = Mailer ! Send( + defaultMails.upgradePricingPlanRequestMail(request.identity, + userEmail, + organization.displayName, + s"Purchase $requestedStorage TB additional storage")) + } yield Ok + } + + def pricingStatus: Action[AnyContent] = + sil.SecuredAction.async { implicit request => + for { + organization <- organizationDAO.findOne(request.identity._organization) + activeUserCount <- userDAO.countAllForOrganization(request.identity._organization) + // Note that this does not yet account for storage + isExceeded = organization.includedUsers.exists(userLimit => activeUserCount > userLimit) || organization.paidUntil + .exists(_.isPast) + isAlmostExceeded = (activeUserCount > 1 && organization.includedUsers.exists(userLimit => + activeUserCount > userLimit - 2)) || organization.paidUntil.exists(paidUntil => + (paidUntil - (6 * 7 days)).isPast) + } yield + Ok( + Json.obj( + "pricingPlan" -> organization.pricingPlan, + "isExceeded" -> isExceeded, + "isAlmostExceeded" -> isAlmostExceeded + )) + } + } diff --git a/app/models/organization/Organization.scala b/app/models/organization/Organization.scala index 66da497d7f6..600b66fe925 100755 --- a/app/models/organization/Organization.scala +++ b/app/models/organization/Organization.scala @@ -22,6 +22,9 @@ case class Organization( logoUrl: String, displayName: String, pricingPlan: PricingPlan, + paidUntil: Option[Instant], + includedUsers: Option[Int], // None means unlimited + includedStorage: Option[Long], // None means unlimited _rootFolder: ObjectId, newUserMailingList: String = "", overTimeMailingList: String = "", @@ -51,6 +54,9 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont r.logourl, r.displayname, pricingPlan, + r.paiduntil.map(Instant.fromSql), + r.includedusers, + r.includedstorage, ObjectId(r._Rootfolder), r.newusermailinglist, r.overtimemailinglist, @@ -88,18 +94,15 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont def insertOne(o: Organization): Fox[Unit] = for { - _ <- run(sqlu"""INSERT INTO webknossos.organizations( - _id, name, additionalInformation, logoUrl, displayName, _rootFolder, newUserMailingList, overTimeMailingList, - enableAutoVerify, lastTermsOfServiceAcceptanceTime, lastTermsOfServiceAcceptanceVersion, - created, isDeleted - ) - VALUES( - ${o._id}, ${o.name}, ${o.additionalInformation}, ${o.logoUrl}, ${o.displayName}, - ${o._rootFolder}, ${o.newUserMailingList}, ${o.overTimeMailingList}, ${o.enableAutoVerify}, - ${o.lastTermsOfServiceAcceptanceTime}, - ${o.lastTermsOfServiceAcceptanceVersion}, - ${o.created}, ${o.isDeleted} - ) + _ <- run(sqlu"""INSERT INTO webknossos.organizations + (_id, name, additionalInformation, logoUrl, displayName, _rootFolder, + newUserMailingList, overTimeMailingList, enableAutoVerify, + pricingplan, paidUntil, includedusers, includedstorage, lastTermsOfServiceAcceptanceTime, lastTermsOfServiceAcceptanceVersion, created, isDeleted) + VALUES + (${o._id.id}, ${o.name}, ${o.additionalInformation}, ${o.logoUrl}, ${o.displayName}, ${o._rootFolder}, + ${o.newUserMailingList}, ${o.overTimeMailingList}, ${o.enableAutoVerify}, + '#${o.pricingPlan}', ${o.paidUntil}, ${o.includedUsers}, ${o.includedStorage}, ${o.lastTermsOfServiceAcceptanceTime}, + ${o.lastTermsOfServiceAcceptanceVersion}, ${o.created}, ${o.isDeleted}) """) } yield () diff --git a/app/models/organization/OrganizationService.scala b/app/models/organization/OrganizationService.scala index f537bb05a90..c3daf0399d6 100644 --- a/app/models/organization/OrganizationService.scala +++ b/app/models/organization/OrganizationService.scala @@ -9,7 +9,7 @@ import javax.inject.Inject import models.binary.{DataStore, DataStoreDAO} import models.folder.{Folder, FolderDAO, FolderService} import models.team.{PricingPlan, Team, TeamDAO} -import models.user.{Invite, MultiUserDAO, User} +import models.user.{Invite, MultiUserDAO, User, UserDAO} import play.api.libs.json.{JsObject, Json} import utils.{ObjectId, WkConf} @@ -17,6 +17,7 @@ import scala.concurrent.{ExecutionContext, Future} class OrganizationService @Inject()(organizationDAO: OrganizationDAO, multiUserDAO: MultiUserDAO, + userDAO: UserDAO, teamDAO: TeamDAO, dataStoreDAO: DataStoreDAO, folderDAO: FolderDAO, @@ -31,7 +32,6 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, val adminOnlyInfo = if (requestingUser.exists(_.isAdminOf(organization._id))) { Json.obj( "newUserMailingList" -> organization.newUserMailingList, - "pricingPlan" -> organization.pricingPlan, "lastTermsOfServiceAcceptanceTime" -> organization.lastTermsOfServiceAcceptanceTime, "lastTermsOfServiceAcceptanceVersion" -> organization.lastTermsOfServiceAcceptanceVersion ) @@ -42,7 +42,11 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, "name" -> organization.name, "additionalInformation" -> organization.additionalInformation, "enableAutoVerify" -> organization.enableAutoVerify, - "displayName" -> organization.displayName + "displayName" -> organization.displayName, + "pricingPlan" -> organization.pricingPlan, + "paidUntil" -> organization.paidUntil, + "includedUsers" -> organization.includedUsers, + "includedStorage" -> organization.includedStorage.map(bytes => bytes / 1000000) ) ++ adminOnlyInfo ) } @@ -82,15 +86,22 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, .replaceAll(" ", "_") existingOrganization <- organizationDAO.findOneByName(organizationName)(GlobalAccessContext).futureBox _ <- bool2Fox(existingOrganization.isEmpty) ?~> "organization.name.alreadyInUse" - initialPricingPlan = if (conf.Features.isDemoInstance) PricingPlan.Basic else PricingPlan.Custom + initialPricingParameters = if (conf.Features.isDemoInstance) (PricingPlan.Basic, Some(3), Some(50000000000L)) + else (PricingPlan.Custom, None, None) organizationRootFolder = Folder(ObjectId.generate, folderService.defaultRootName) - organization = Organization(ObjectId.generate, - organizationName, - "", - "", - organizationDisplayName, - initialPricingPlan, - organizationRootFolder._id) + + organization = Organization( + ObjectId.generate, + organizationName, + "", + "", + organizationDisplayName, + initialPricingParameters._1, + None, + initialPricingParameters._2, + initialPricingParameters._3, + organizationRootFolder._id + ) organizationTeam = Team(ObjectId.generate, organization._id, "Default", isOrganizationTeam = true) _ <- folderDAO.insertAsRoot(organizationRootFolder) _ <- organizationDAO.insertOne(organization) @@ -111,4 +122,13 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO, } yield () } + def assertUsersCanBeAdded(organization: Organization, usersToAddCount: Int = 1)(implicit ctx: DBAccessContext, + ec: ExecutionContext): Fox[Unit] = + for { + _ <- organizationDAO.findOne(organization._id) + userCount <- userDAO.countAllForOrganization(organization._id) + _ <- Fox.runOptional(organization.includedUsers)(includedUsers => + bool2Fox(userCount + usersToAddCount <= includedUsers)) + } yield () + } diff --git a/app/models/team/PricingPlan.scala b/app/models/team/PricingPlan.scala index 734bd28288b..e3d9d150f7e 100644 --- a/app/models/team/PricingPlan.scala +++ b/app/models/team/PricingPlan.scala @@ -4,5 +4,5 @@ import com.scalableminds.util.enumeration.ExtendedEnumeration object PricingPlan extends ExtendedEnumeration { type PricingPlan = Value - val Basic, Premium, Pilot, Custom = Value + val Basic, Team, Power, Team_Trial, Power_Trial, Custom = Value } diff --git a/app/models/user/User.scala b/app/models/user/User.scala index 384a8eea6bd..0ac9718979e 100755 --- a/app/models/user/User.scala +++ b/app/models/user/User.scala @@ -195,7 +195,7 @@ class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext) def countAllForOrganization(organizationId: ObjectId): Fox[Int] = for { resultList <- run( - sql"select count(_id) from #$existingCollectionName where _organization = $organizationId and not isUnlisted" + sql"select count(_id) from #$existingCollectionName where _organization = $organizationId and not isDeactivated and not isUnlisted" .as[Int]) result <- resultList.headOption } yield result diff --git a/app/oxalis/mail/DefaultMails.scala b/app/oxalis/mail/DefaultMails.scala index 6b77b597509..c21af30064e 100755 --- a/app/oxalis/mail/DefaultMails.scala +++ b/app/oxalis/mail/DefaultMails.scala @@ -100,4 +100,56 @@ class DefaultMails @Inject()(conf: WkConf) { bodyHtml = html.mail.help(user.name, organizationDisplayName, message).body, recipients = List("hello@webknossos.org", userEmail) ) + + def extendPricingPlanMail(user: User, userEmail: String): Mail = + Mail( + from = defaultSender, + subject = "webKnossos Plan Extension Request", + bodyHtml = html.mail.extendPricingPlan(user.name).body, + recipients = List(userEmail) + ) + + def upgradePricingPlanToTeamMail(user: User, userEmail: String): Mail = + Mail( + from = defaultSender, + subject = "webKnossos Plan Upgrade Request", + bodyHtml = html.mail.upgradePricingPlanToTeam(user.name).body, + recipients = List(userEmail) + ) + + def upgradePricingPlanToPowerMail(user: User, userEmail: String): Mail = + Mail( + from = defaultSender, + subject = "webKnossos Plan Upgrade Request", + bodyHtml = html.mail.upgradePricingPlanToPower(user.name).body, + recipients = List(userEmail) + ) + + def upgradePricingPlanUsersMail(user: User, userEmail: String, requestedUsers: Int): Mail = + Mail( + from = defaultSender, + subject = "Request to upgrade webKnossos users", + bodyHtml = html.mail.upgradePricingPlanUsers(user.name, requestedUsers).body, + recipients = List(userEmail) + ) + + def upgradePricingPlanStorageMail(user: User, userEmail: String, requestedStorage: Int): Mail = + Mail( + from = defaultSender, + subject = "Request to upgrade webKnossos storage", + bodyHtml = html.mail.upgradePricingPlanStorage(user.name, requestedStorage).body, + recipients = List(userEmail) + ) + + def upgradePricingPlanRequestMail(user: User, + userEmail: String, + organizationDisplayName: String, + messageBody: String): Mail = + Mail( + from = defaultSender, + subject = "Request to upgrade webKnossos plan", + bodyHtml = html.mail.upgradePricingPlanRequest(user.name, organizationDisplayName, messageBody).body, + recipients = List("hello@webknossos.org") + ) + } diff --git a/app/views/mail/extendPricingPlan.scala.html b/app/views/mail/extendPricingPlan.scala.html new file mode 100644 index 00000000000..053850e8e9d --- /dev/null +++ b/app/views/mail/extendPricingPlan.scala.html @@ -0,0 +1,9 @@ +@( name: String) + +@emailBaseTemplate() { +

Hi @{name}

+

Thank you for requesting to extend your current webKnossos 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/upgradePricingPlanRequest.scala.html b/app/views/mail/upgradePricingPlanRequest.scala.html new file mode 100644 index 00000000000..640ac844761 --- /dev/null +++ b/app/views/mail/upgradePricingPlanRequest.scala.html @@ -0,0 +1,11 @@ +@(name: String, organizationDisplayName: String, messageBody: String) + +@emailBaseTemplate() { +

Hi webKnossos Sales Team

+

There is a new request to upgrade a webKnossos organization with additional plan/features.

+

User: @{name}

+

Organization: @{organizationDisplayName}

+

Request: @{messageBody}

+ +

With best regards,
webKnossos

+} \ No newline at end of file diff --git a/app/views/mail/upgradePricingPlanStorage.scala.html b/app/views/mail/upgradePricingPlanStorage.scala.html new file mode 100644 index 00000000000..6f48de71e74 --- /dev/null +++ b/app/views/mail/upgradePricingPlanStorage.scala.html @@ -0,0 +1,10 @@ +@(name: String, requestedStorage: Int) + +@emailBaseTemplate() { +

Hi @{name}

+

Thank you for requesting to upgrade your webKnossos plan with more storage. Our sales team will be in contact with you shortly with a formal offer.

+ +

Requested additional storage: @{requestedStorage}TB

+ +

With best regards,
the webKnossos-Team

+} \ 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={ - } onCancel={() => { @@ -287,7 +328,7 @@ export class InviteUsersModal extends React.Component< if (this.props.destroy != null) this.props.destroy(); }} > - {this.getContent()} + {this.getContent(isInvitesDisabled)} ); } @@ -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 ( + + + + Request Upgrade + , + ]} + > +
    + {teamPlanFeatures.map((feature) => ( +
  • {feature}
  • + ))} +
+
+ + + + Request Upgrade + , + ]} + > +
    + {powerPlanFeatures.map((feature) => ( +
  • {feature}
  • + ))} +
+
+ +
+ ); +} + +export function PlanUpgradeCard({ organization }: { organization: APIOrganization }) { + if ( + organization.pricingPlan === PricingPlanEnum.Power || + organization.pricingPlan === PricingPlanEnum.PowerTrial || + organization.pricingPlan === PricingPlanEnum.Custom + ) + return null; + + let title = "Upgrade to unlock more features"; + let cardBody = ( + + UpgradePricingPlanModal.upgradePricingPlan(organization, PricingPlanEnum.Team) + } + powerUpgradeCallback={() => + UpgradePricingPlanModal.upgradePricingPlan(organization, PricingPlanEnum.Power) + } + /> + ); + + if ( + organization.pricingPlan === PricingPlanEnum.Team || + organization.pricingPlan === PricingPlanEnum.TeamTrial + ) { + title = `Upgrade to ${PricingPlanEnum.Power} Plan`; + cardBody = ( + + +

+ Upgrading your webKnossos plan will unlock more advanced features and increase your user + and storage quotas. +

+

+

    + {powerPlanFeatures.map((feature) => ( +
  • {feature}
  • + ))} +
+

+ + + + +
+ ); + } + + return ( + +

+ Upgrading your webKnossos plan will unlock more advanced features and increase your user and + storage quotas. +

+ {cardBody} +
+ ); +} + +export function PlanExpirationCard({ organization }: { organization: APIOrganization }) { + if (organization.paidUntil === Constants.MAXIMUM_DATE_TIMESTAMP) return null; + + return ( + + + + Your current plan is paid until{" "} + {formatDateInLocalTimeZone(organization.paidUntil, "YYYY-MM-DD")} + + + + + + + ); +} + +export function PlanDashboardCard({ + organization, + activeUsersCount, + usedStorageSpace, +}: { + organization: APIOrganization; + activeUsersCount: number; + usedStorageSpace: number; +}) { + const usedUsersPercentage = (activeUsersCount / organization.includedUsers) * 100; + const usedStoragePercentage = (usedStorageSpace / organization.includedStorage) * 100; + + const hasExceededUserLimit = hasPricingPlanExceededUsers(organization, activeUsersCount); + const hasExceededStorageLimit = hasPricingPlanExceededStorage(organization, usedStorageSpace); + + const maxUsersCountLabel = + organization.includedUsers === Number.POSITIVE_INFINITY ? "∞" : organization.includedUsers; + + let includedStorageLabel = + organization.pricingPlan === PricingPlanEnum.Basic + ? `${(organization.includedStorage / 1000).toFixed(0)}GB` + : `${(organization.includedStorage / 1000 ** 2).toFixed(0)}TB`; + includedStorageLabel = + organization.includedStorage === Number.POSITIVE_INFINITY ? "∞" : includedStorageLabel; + + const usedStorageLabel = + organization.pricingPlan === PricingPlanEnum.Basic + ? `${(usedStorageSpace / 1000).toFixed(1)}` + : `${(usedStorageSpace / 1000 ** 2).toFixed(1)}`; + + const storageLabel = `${usedStorageLabel}/${includedStorageLabel}`; + + const redStrokeColor = "#ff4d4f"; + const greenStrokeColor = "#52c41a"; + + let upgradeUsersAction: React.ReactNode[] = []; + let upgradeStorageAction: React.ReactNode[] = []; + let upgradePlanAction: React.ReactNode[] = []; + + if ( + organization.pricingPlan === PricingPlanEnum.Basic || + organization.pricingPlan === PricingPlanEnum.Team || + organization.pricingPlan === PricingPlanEnum.TeamTrial + ) { + upgradeUsersAction = [ + UpgradePricingPlanModal.upgradePricingPlan(organization) + : UpgradePricingPlanModal.upgradeUserQuota + } + > + Upgrade + , + ]; + upgradeStorageAction = [ + UpgradePricingPlanModal.upgradePricingPlan(organization) + : UpgradePricingPlanModal.upgradeStorageQuota + } + > + Upgrade + , + ]; + upgradePlanAction = [ + [ + + Compare Plans + , + ], + ]; + } + + return ( + + + + + `${activeUsersCount}/${maxUsersCountLabel}`} + strokeColor={hasExceededUserLimit ? redStrokeColor : greenStrokeColor} + status={hasExceededUserLimit ? "exception" : "active"} + /> + + Users + + + + + + storageLabel} + strokeColor={hasExceededStorageLimit ? redStrokeColor : greenStrokeColor} + status={hasExceededStorageLimit ? "exception" : "active"} + /> + + Storage + + + + + +

{organization.pricingPlan}

+
+ Current Plan +
+ +
+ ); +} + +export function PlanExceededAlert({ organization }: { organization: APIOrganization }) { + const hasPlanExpired = hasPricingPlanExpired(organization); + const activeUser = useSelector((state: OxalisState) => state.activeUser); + + const message = hasPlanExpired + ? "Your webKnossos plan has expired. Renew your plan now to avoid being downgraded, users being blocked, and losing access to features." + : "Your organization is using more users or storage space than included in your current plan. Upgrade now to avoid your account from being blocked."; + const actionButton = hasPlanExpired ? ( + + ) : ( + + ); + + return ( + + ); +} + +export function PlanAboutToExceedAlert({ organization }: { organization: APIOrganization }) { + const alerts = []; + const activeUser = useSelector((state: OxalisState) => state.activeUser); + const isAboutToExpire = + moment.duration(moment(organization.paidUntil).diff(moment())).asWeeks() <= 6 && + !hasPricingPlanExpired(organization); + + if (isAboutToExpire) + alerts.push({ + message: + "Your webKnossos plan is about to expire soon. Renew your plan now to avoid being downgraded, users being blocked, and losing access to features.", + actionButton: ( + + ), + }); + else { + alerts.push({ + message: + "Your organization is about to exceed the storage space included in your current plan. Upgrade now to avoid your account from being blocked.", + actionButton: ( + + ), + }); + } + + return ( + <> + {alerts.map((alert) => ( + + ))} + + ); +} diff --git a/frontend/javascripts/admin/organization/organization_edit_view.tsx b/frontend/javascripts/admin/organization/organization_edit_view.tsx index 37800bd84c3..5718c3fd254 100644 --- a/frontend/javascripts/admin/organization/organization_edit_view.tsx +++ b/frontend/javascripts/admin/organization/organization_edit_view.tsx @@ -1,29 +1,56 @@ import { RouteComponentProps, withRouter } from "react-router-dom"; -import { Form, Button, Card, Input, Row, FormInstance } from "antd"; -import { MailOutlined, TagOutlined, CopyOutlined, KeyOutlined } from "@ant-design/icons"; +import { Form, Button, Card, Input, Row, FormInstance, Col, Skeleton } from "antd"; +import { + MailOutlined, + TagOutlined, + CopyOutlined, + SaveOutlined, + IdcardOutlined, +} from "@ant-design/icons"; import React from "react"; import { confirmAsync } from "dashboard/dataset/helper_components"; -import { getOrganization, deleteOrganization, updateOrganization } from "admin/admin_rest_api"; +import { + getOrganization, + deleteOrganization, + updateOrganization, + getUsers, + getPricingPlanStatus, + getOrganizationStorageSpace, +} 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, + PlanDashboardCard, + PlanExceededAlert, + PlanExpirationCard, + PlanUpgradeCard, +} from "./organization_cards"; +import { getActiveUserCount } from "./pricing_plan_utils"; const FormItem = Form.Item; export enum PricingPlanEnum { Basic = "Basic", - Premium = "Premium", - Pilot = "Pilot", + Team = "Team", + Power = "Power", + TeamTrial = "Team_Trial", + PowerTrial = "Power_Trial", Custom = "Custom", } -export type PricingPlan = keyof typeof PricingPlanEnum; type Props = { organizationName: string; }; type State = { displayName: string; newUserMailingList: string; - pricingPlan: PricingPlan | null | undefined; + pricingPlan: PricingPlanEnum | null | undefined; isFetchingData: boolean; isDeleting: boolean; + organization: APIOrganization | null; + activeUsersCount: number; + pricingPlanStatus: APIPricingPlanStatus | null; + usedStorageSpace: number | null; }; class OrganizationEditView extends React.PureComponent { @@ -33,6 +60,10 @@ class OrganizationEditView extends React.PureComponent { pricingPlan: null, isFetchingData: false, isDeleting: false, + organization: null, + activeUsersCount: 1, + pricingPlanStatus: null, + usedStorageSpace: null, }; formRef = React.createRef(); @@ -70,14 +101,23 @@ class OrganizationEditView extends React.PureComponent { this.setState({ isFetchingData: true, }); - const { displayName, newUserMailingList, pricingPlan } = await getOrganization( - this.props.organizationName, - ); + const [organization, users, pricingPlanStatus, usedStorageSpace] = await Promise.all([ + getOrganization(this.props.organizationName), + getUsers(), + getPricingPlanStatus(), + getOrganizationStorageSpace(this.props.organizationName), + ]); + + const { displayName, newUserMailingList, pricingPlan } = organization; this.setState({ displayName, pricingPlan: coalesce(PricingPlanEnum, pricingPlan), newUserMailingList, isFetchingData: false, + organization, + pricingPlanStatus, + activeUsersCount: getActiveUserCount(users), + usedStorageSpace: usedStorageSpace.usedStorageSpace, }); } @@ -88,8 +128,9 @@ class OrganizationEditView extends React.PureComponent { formValues.displayName, formValues.newUserMailingList, ); - window.location.replace(`${window.location.origin}/dashboard/`); + Toast.success("Organization settings were saved successfully."); }; + handleDeleteButtonClicked = async (): Promise => { const isDeleteConfirmed = await confirmAsync({ title: ( @@ -99,7 +140,7 @@ class OrganizationEditView extends React.PureComponent { Attention: You will be logged out.

), - okText: "Yes, Delete Organization now", + okText: "Yes, delete this organization now.", }); if (isDeleteConfirmed) { @@ -113,25 +154,66 @@ class OrganizationEditView extends React.PureComponent { window.location.replace(`${window.location.origin}/dashboard`); } }; + handleCopyNameButtonClicked = async (): Promise => { await navigator.clipboard.writeText(this.props.organizationName); - Toast.success("Organization name copied to clipboard"); + Toast.success("Copied organization name to the clipboard."); }; render() { + if ( + this.state.isFetchingData || + !this.state.organization || + !this.state.pricingPlan || + !this.state.pricingPlanStatus || + this.state.usedStorageSpace === null + ) + return ( +
+ +
+ ); + + const OrgaNameRegexPattern = /^[A-Za-z0-9\\-_\\. ß]+$/; + return (
+ Your Organization + +

{this.state.displayName}

+
+ {this.state.pricingPlanStatus.isExceeded ? ( + + ) : null} + {this.state.pricingPlanStatus.isAlmostExceeded && + !this.state.pricingPlanStatus.isExceeded ? ( + + ) : null} + + + Edit {this.state.displayName} } - style={{ - margin: "auto", - maxWidth: 800, - }} + title="Settings" + style={{ marginBottom: 20 }} + headStyle={{ backgroundColor: "rgb(250, 250, 250)" }} >
{ newUserMailingList: this.state.newUserMailingList, }} > - + } + prefix={} value={this.props.organizationName} style={{ width: "calc(100% - 31px)", @@ -159,21 +241,19 @@ class OrganizationEditView extends React.PureComponent { } - autoFocus disabled={this.state.isFetchingData} placeholder="Display Name" /> @@ -185,6 +265,7 @@ class OrganizationEditView extends React.PureComponent { { required: false, type: "email", + message: "Please provide a valid email address.", }, ]} > @@ -200,40 +281,27 @@ class OrganizationEditView extends React.PureComponent { placeholder="mail@example.com" /> - -
- - - {this.state.pricingPlan} - -
-
- - - - + + +
+ + + + Delete this organization including all annotations, uploaded datasets, and associated + user accounts. Careful, this action can NOT be undone. + + - - + +
); 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. +

+ +); + +function extendPricingPlan(organization: APIOrganization) { + const extendedDate = moment(organization.paidUntil).add(1, "year"); + + Modal.confirm({ + title: "Extend Current Plan", + okText: "Request Extension", + onOk: () => { + sendExtendPricingPlanEmail(); + Toast.success(messages["organization.plan.upgrage_request_sent"]); + }, + icon: , + width: 1000, + content: ( +
+

+ 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")} +

+

Buy extension until: {extendedDate.format("YYYY-MM-DD")}

+ {ModalInformationFooter} +
+ ), + }); +} + +function upgradeUserQuota() { + renderIndependently((destroyCallback) => ); +} + +function UpgradeUserQuotaModal({ destroy }: { destroy: () => void }) { + const userInputRef = useRef(null); + + const handleUserUpgrade = async () => { + if (userInputRef.current) { + const requestedUsers = parseInt(userInputRef.current.value); + await sendUpgradePricingPlanUserEmail(requestedUsers); + Toast.success(messages["organization.plan.upgrage_request_sent"]); + } + + destroy(); + }; + + return ( + + Upgrade User Quota + + } + okText={"Request More Users"} + onOk={handleUserUpgrade} + onCancel={destroy} + width={800} + open + > +
+

+ 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. +

+
Add additional user accounts:
+
+ +
+ {ModalInformationFooter} +
+
+ ); +} + +function upgradeStorageQuota() { + renderIndependently((destroyCallback) => ); +} +function UpgradeStorageQuotaModal({ destroy }: { destroy: () => void }) { + const storageInputRef = useRef(null); + + const handleStorageUpgrade = async () => { + if (storageInputRef.current) { + const requestedStorage = parseInt(storageInputRef.current.value); + await sendUpgradePricingPlanStorageEmail(requestedStorage); + Toast.success(messages["organization.plan.upgrage_request_sent"]); + } + + destroy(); + }; + + return ( + + Upgrade Storage Space + + } + okText={"Request More Storage Space"} + onOk={handleStorageUpgrade} + onCancel={destroy} + width={800} + open + > +
+

+ 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 = ( + <> +

Upgrade Highlights include:

+
    + {teamPlanFeatures.map((feature) => ( +
  • {feature}
  • + ))} +
+ + ); + + if (target === PricingPlanEnum.Power) { + title = `Upgrade to ${PricingPlanEnum.Power} Plan`; + okButtonCallback = () => { + sendUpgradePricingPlanEmail(PricingPlanEnum.Power); + Toast.success(messages["organization.plan.upgrage_request_sent"]); + }; + modalBody = ( + <> +

Upgrade Highlights include:

+
    + {powerPlanFeatures.map((feature) => ( +
  • {feature}
  • + ))} +
+ + ); + } + + renderIndependently((destroyCallback) => { + if (target === "TeamAndPower") { + title = "Upgrade to unlock more features"; + okButtonCallback = undefined; + modalBody = ( + { + sendUpgradePricingPlanEmail(PricingPlanEnum.Team); + Toast.success(messages["organization.plan.upgrage_request_sent"]); + destroyCallback(); + }} + powerUpgradeCallback={() => { + sendUpgradePricingPlanEmail(PricingPlanEnum.Power); + Toast.success(messages["organization.plan.upgrage_request_sent"]); + destroyCallback(); + }} + /> + ); + } + + return ( + + ); + }); +} + +function UpgradePricingPlanModal({ + title, + modalBody, + destroy, + okButtonCallback, +}: { + title: string; + modalBody: React.ReactElement; + destroy: () => void; + okButtonCallback: (() => void) | undefined; +}) { + const introSentence = + "Upgrading your webKnossos plan will unlock more advanced features and increase your user and storage quotas."; + + return ( + + {title} + + } + width={800} + onCancel={destroy} + footer={ + <> + + {okButtonCallback ? ( + + ) : null} + + } + > +
+

{introSentence}

+ {modalBody} + {ModalInformationFooter} +
+
+ ); +} + +export default { + upgradePricingPlan, + extendPricingPlan, + upgradeUserQuota, + upgradeStorageQuota, +}; diff --git a/frontend/javascripts/admin/time/time_line_view.tsx b/frontend/javascripts/admin/time/time_line_view.tsx index 31bf76860c6..da4621a7a2d 100644 --- a/frontend/javascripts/admin/time/time_line_view.tsx +++ b/frontend/javascripts/admin/time/time_line_view.tsx @@ -403,8 +403,6 @@ class TimeLineView extends React.PureComponent { rows={rows} timeAxisFormat={timeAxisFormat} dateRange={dateRange} - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. - timeTrackingData={timeTrackingData} /> ) : ( diff --git a/frontend/javascripts/admin/user/user_list_view.tsx b/frontend/javascripts/admin/user/user_list_view.tsx index 5cbae7673c1..dc6e5ae0cbb 100644 --- a/frontend/javascripts/admin/user/user_list_view.tsx +++ b/frontend/javascripts/admin/user/user_list_view.tsx @@ -25,12 +25,13 @@ import { InviteUsersModal } from "admin/onboarding"; import type { OxalisState } from "oxalis/store"; import { enforceActiveUser } from "oxalis/model/accessors/user_accessor"; import LinkButton from "components/link_button"; -import { getEditableUsers, updateUser } from "admin/admin_rest_api"; +import { getEditableUsers, getOrganization, updateUser } from "admin/admin_rest_api"; import { stringToColor } from "libs/format_utils"; import EditableTextLabel from "oxalis/view/components/editable_text_label"; import ExperienceModalView from "admin/user/experience_modal_view"; import Persistence from "libs/persistence"; import PermissionsAndTeamsModalView from "admin/user/permissions_and_teams_modal_view"; +import { getActiveUserCount } from "admin/organization/pricing_plan_utils"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import messages from "messages"; @@ -57,6 +58,7 @@ type State = { activationFilter: Array<"true" | "false">; searchQuery: string; domainToEdit: string | null | undefined; + maxUserCountPerOrganization: number; }; const persistence = new Persistence>( { @@ -78,6 +80,7 @@ class UserListView extends React.PureComponent { searchQuery: "", singleSelectedUser: null, domainToEdit: null, + maxUserCountPerOrganization: Number.POSITIVE_INFINITY, }; componentDidMount() { @@ -100,10 +103,15 @@ class UserListView extends React.PureComponent { this.setState({ isLoading: true, }); - const users = await getEditableUsers(); + const [users, organization] = await Promise.all([ + getEditableUsers(), + getOrganization(this.props.activeUser.organization), + ]); + this.setState({ isLoading: false, users, + maxUserCountPerOrganization: organization.includedUsers, }); } @@ -235,20 +243,16 @@ class UserListView extends React.PureComponent { ) : null; } - renderPlaceholder() { + renderInviteUsersAlert() { + const inviteUsersCallback = () => + this.setState({ + isInviteModalVisible: true, + }); + const noUsersMessage = ( - {"You can "} - - this.setState({ - isInviteModalVisible: true, - }) - } - > - invite more users - - {" to join your organization. After the users joined, you need to activate them manually."} + Invite colleagues and collaboration partners + {" to join your organization. Share datasets and collaboratively work on anntotiatons."} ); return this.state.isLoading ? null : ( @@ -260,6 +264,32 @@ class UserListView extends React.PureComponent { style={{ marginTop: 20, }} + action={ + + } + /> + ); + } + + renderUpgradePlanAlert() { + return ( + + + + } /> ); } @@ -311,6 +341,9 @@ class UserListView extends React.PureComponent { marginRight: 20, }; const noOtherUsers = this.state.users.length < 2; + const isUserInvitesDisabled = + getActiveUserCount(this.state.users) >= this.state.maxUserCountPerOrganization; + return (

Users

@@ -349,6 +382,7 @@ class UserListView extends React.PureComponent { { this.setState({ isInviteModalVisible: visible, @@ -387,7 +422,8 @@ class UserListView extends React.PureComponent {
- {noOtherUsers ? this.renderPlaceholder() : null} + {isUserInvitesDisabled ? this.renderUpgradePlanAlert() : null} + {noOtherUsers && !isUserInvitesDisabled ? this.renderInviteUsersAlert() : null} {this.renderNewUsersAlert()} diff --git a/frontend/javascripts/admin/welcome_ui.tsx b/frontend/javascripts/admin/welcome_ui.tsx index fa817fef0ca..70523b67e31 100644 --- a/frontend/javascripts/admin/welcome_ui.tsx +++ b/frontend/javascripts/admin/welcome_ui.tsx @@ -121,10 +121,15 @@ export const WhatsNextHeader = ({ activeUser, onDismiss }: WhatsNextHeaderProps) {isUserAdminOrTeamManager(activeUser) ? ( } onClick={() => { - renderIndependently((destroy) => ); + renderIndependently((destroy) => ( + + )); }} /> ) : null} diff --git a/frontend/javascripts/dashboard/dashboard_view.tsx b/frontend/javascripts/dashboard/dashboard_view.tsx index b577579b086..083d20f1b57 100644 --- a/frontend/javascripts/dashboard/dashboard_view.tsx +++ b/frontend/javascripts/dashboard/dashboard_view.tsx @@ -7,10 +7,21 @@ import React, { PureComponent, useContext } from "react"; import _ from "lodash"; import { setActiveUserAction } from "oxalis/model/actions/user_actions"; import { WhatsNextHeader } from "admin/welcome_ui"; -import type { APIUser } from "types/api_flow_types"; +import type { + APIOrganization, + APIOrganizationStorageInfo, + APIPricingPlanStatus, + APIUser, +} from "types/api_flow_types"; import type { OxalisState } from "oxalis/store"; import { enforceActiveUser } from "oxalis/model/accessors/user_accessor"; -import { getUser, updateNovelUserExperienceInfos } from "admin/admin_rest_api"; +import { + getOrganization, + getOrganizationStorageSpace, + getPricingPlanStatus, + getUser, + updateNovelUserExperienceInfos, +} from "admin/admin_rest_api"; import DashboardTaskListView from "dashboard/dashboard_task_list_view"; import DatasetView from "dashboard/dataset_view"; import DatasetCacheProvider, { @@ -22,8 +33,9 @@ import NmlUploadZoneContainer from "oxalis/view/nml_upload_zone_container"; import Request from "libs/request"; import UserLocalStorage from "libs/user_local_storage"; import features from "features"; -import { DatasetFolderView } from "./dataset_folder_view"; +import { PlanAboutToExceedAlert, PlanExceededAlert } from "admin/organization/organization_cards"; import { PortalTarget } from "oxalis/view/layouting/portal_utils"; +import { DatasetFolderView } from "./dataset_folder_view"; import { ActiveTabContext, RenderingTabContext } from "./dashboard_contexts"; type OwnProps = { @@ -44,7 +56,10 @@ type PropsWithRouter = Props & { type State = { activeTabKey: string; user: APIUser | null | undefined; + organization: APIOrganization | null; + pricingPlanStatus: APIPricingPlanStatus | null; }; + export const urlTokenToTabKeyMap = { publications: "publications", datasets: "datasets", @@ -90,14 +105,17 @@ class DashboardView extends PureComponent { (initialTabKey && initialTabKey in validTabKeys && initialTabKey) || (lastUsedTabKey && lastUsedTabKey in validTabKeys && lastUsedTabKey) || defaultTabKey; + this.state = { activeTabKey, user: null, + organization: null, + pricingPlanStatus: null, }; } componentDidMount() { - this.fetchUser(); + this.fetchData(); } componentDidUpdate(prevProps: PropsWithRouter) { @@ -108,11 +126,19 @@ class DashboardView extends PureComponent { } } - async fetchUser(): Promise { + async fetchData(): Promise { const user = this.props.userId != null ? await getUser(this.props.userId) : this.props.activeUser; + + const [organization, pricingPlanStatus] = await Promise.all([ + getOrganization(user.organization), + getPricingPlanStatus(), + ]); + this.setState({ user, + organization, + pricingPlanStatus, }); } @@ -257,15 +283,32 @@ class DashboardView extends PureComponent { User: {user.firstName} {user.lastName} ) : null; + const whatsNextBanner = !this.props.isAdminView && !activeUser.novelUserExperienceInfos.hasSeenDashboardWelcomeBanner ? ( ) : null; + this.state.pricingPlanStatus?.isAlmostExceeded; + + // ToDo enable components below once pricing goes live + const pricingPlanWarnings = + this.state.organization && + this.state.pricingPlanStatus?.isAlmostExceeded && + !this.state.pricingPlanStatus.isExceeded ? ( + + ) : null; + const pricingPlanErrors = + this.state.organization && this.state.pricingPlanStatus?.isExceeded ? ( + + ) : null; + return ( {whatsNextBanner}
+ {pricingPlanWarnings} + {pricingPlanErrors} {userHeader} diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 10648dc5a25..00fc0fa40c0 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -1074,7 +1074,7 @@ export function diffObjects( } export function coalesce(obj: { [key: string]: T }, field: T): T | null { - if (obj && typeof obj === "object" && (field in obj || field in Object.values(obj))) { + if (obj && typeof obj === "object" && (field in obj || Object.values(obj).includes(field))) { return field; } return null; diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx index 3ff35bc6f76..13808353061 100644 --- a/frontend/javascripts/messages.tsx +++ b/frontend/javascripts/messages.tsx @@ -433,4 +433,6 @@ instead. Only enable this option if you understand its effect. All layers will n "ui.moving_center_tab_into_border_error": "You cannot move this tab into a sidebar!", "ui.moving_border_tab_into_center_error": "You cannot move this tab out of this sidebar!", "ui.no_form_active": "Could not set the initial form values as the form could not be loaded.", + "organization.plan.upgrage_request_sent": + "An email with your upgrade request has been sent to the webKnossos sales team.", }; diff --git a/frontend/javascripts/navbar.tsx b/frontend/javascripts/navbar.tsx index c01a72dedce..6b65486a951 100644 --- a/frontend/javascripts/navbar.tsx +++ b/frontend/javascripts/navbar.tsx @@ -243,7 +243,7 @@ function AdministrationSubMenu({ {isAdmin && ( - Organization + Organization )} {features().voxelyticsEnabled && ( @@ -537,6 +537,11 @@ function LoggedInAvatar({ {orgDisplayName} + {activeOrganization && Utils.isUserAdmin(activeUser) ? ( + + Manage Organization + + ) : null} {isMultiMember ? ( /* The explicit width is a workaround for a layout bug (probably in antd) */ void; + const Constants = { ARBITRARY_VIEW: 4, DEFAULT_BORDER_WIDTH: 400, @@ -314,6 +315,7 @@ const Constants = { _2D: (process.env.IS_TESTING ? [512, 512, 1] : [768, 768, 1]) as Vector3, _3D: (process.env.IS_TESTING ? [64, 64, 32] : [96, 96, 96]) as Vector3, }, + MAXIMUM_DATE_TIMESTAMP: 8640000000000000, }; export default Constants; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_settings.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_settings.tsx index 3f6ed2f97d1..5320d9f92da 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_settings.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_settings.tsx @@ -155,8 +155,6 @@ class ConnectomeFilters extends React.Component { min={userSettings.particleSize.minimum} max={userSettings.particleSize.maximum} step={0.1} - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. - roundTo={1} value={particleSize} onChange={this.updateParticleSize} /> diff --git a/frontend/javascripts/router.tsx b/frontend/javascripts/router.tsx index 165c3c10bcc..c63c16dcf29 100644 --- a/frontend/javascripts/router.tsx +++ b/frontend/javascripts/router.tsx @@ -261,8 +261,6 @@ class ReactRouter extends React.Component { /> { if (isAuthenticated) { @@ -496,7 +494,7 @@ class ReactRouter extends React.Component { /> ( // @ts-expect-error ts-migrate(2339) FIXME: Property 'organizationName' does not exist on type... Remove this comment to see the full error message diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index fd37c254f2b..cdf03cd7256 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -8,7 +8,7 @@ import type { import type { ServerUpdateAction } from "oxalis/model/sagas/update_actions"; import type { SkeletonTracingStats } from "oxalis/model/accessors/skeletontracing_accessor"; import type { Vector3, Vector6, Point3, ColorObject } from "oxalis/constants"; -import { PricingPlan } from "admin/organization/organization_edit_view"; +import { PricingPlanEnum } from "admin/organization/organization_edit_view"; export type APIMessage = { [key in "info" | "warning" | "error"]?: string }; export type ElementClass = @@ -511,10 +511,22 @@ export type APIOrganization = { readonly name: string; readonly additionalInformation: string; readonly displayName: string; - readonly pricingPlan: PricingPlan; + readonly pricingPlan: PricingPlanEnum; readonly enableAutoVerify: boolean; readonly newUserMailingList: string; + readonly paidUntil: number; + readonly includedUsers: number; + readonly includedStorage: number; // megabytes }; +export type APIOrganizationStorageInfo = { + readonly usedStorageSpace: number; +}; +export type APIPricingPlanStatus = { + readonly pricingPlan: PricingPlanEnum; + readonly isExceeded: boolean; + readonly isAlmostExceeded: boolean; // stays true when isExceeded is true) +}; + export type APIBuildInfo = { webknossos: { name: string; diff --git a/public/images/background_neuron_meshes.webp b/public/images/background_neuron_meshes.webp new file mode 100644 index 00000000000..a7dbd3285b8 Binary files /dev/null and b/public/images/background_neuron_meshes.webp differ diff --git a/public/images/pricing/background_evaluation.jpeg b/public/images/pricing/background_evaluation.jpeg new file mode 100644 index 00000000000..8bf97313fcf Binary files /dev/null and b/public/images/pricing/background_evaluation.jpeg differ diff --git a/public/images/pricing/background_neuron_analysis.svg b/public/images/pricing/background_neuron_analysis.svg new file mode 100644 index 00000000000..1b13075d792 --- /dev/null +++ b/public/images/pricing/background_neuron_analysis.svg @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pricing/background_neuron_meshes.jpeg b/public/images/pricing/background_neuron_meshes.jpeg new file mode 100644 index 00000000000..3cdf0d1fdc0 Binary files /dev/null and b/public/images/pricing/background_neuron_meshes.jpeg differ diff --git a/public/images/pricing/background_neurons.jpeg b/public/images/pricing/background_neurons.jpeg new file mode 100644 index 00000000000..05313edf514 Binary files /dev/null and b/public/images/pricing/background_neurons.jpeg differ diff --git a/public/images/pricing/background_users.jpeg b/public/images/pricing/background_users.jpeg new file mode 100644 index 00000000000..e0b4c529ad7 Binary files /dev/null and b/public/images/pricing/background_users.jpeg differ diff --git a/test/db/organizations.csv b/test/db/organizations.csv index 7c0f1bce3ee..3f71d9783e4 100644 --- a/test/db/organizations.csv +++ b/test/db/organizations.csv @@ -1,3 +1,3 @@ -_id,name,additionalinformation,logoUrl,displayName,_rootFolder,newusermailinglist,overtimemailinglist,enableautoverify,pricingPlan,lastTermsOfServiceAcceptanceTime,lastTermsOfServiceAcceptanceVersion,created,isdeleted -'5ab0c6a674d0af7b003b23ac','Organization_X','lorem ipsum','/assets/images/mpi-logos.svg','Organization_X','570b9f4e4bb848d0885ea917','','',f,'Custom',,0,'2018-03-20 09:30:31.91+01',f -'6bb0c6a674d0af7b003b23bd','Organization_Y','foo bar','/assets/images/mpi-logos.svg','Organization_Y','570b9f4e4bb848d088a83aef','','',f,'Custom',,0,'2018-03-24 09:30:31.91+01',f \ No newline at end of file +_id,name,additionalinformation,logoUrl,displayName,_rootFolder,newusermailinglist,overtimemailinglist,enableautoverify,pricingPlan,paidUntil,includedUsers,includedStorage,created,isdeleted +'5ab0c6a674d0af7b003b23ac','Organization_X','lorem ipsum','/assets/images/mpi-logos.svg','Organization_X',570b9f4e4bb848d0885ea917,'','',f,'Custom',,,,,0,'2018-03-20 09:30:31.91+01',f +'6bb0c6a674d0af7b003b23bd','Organization_Y','foo bar','/assets/images/mpi-logos.svg','Organization_Y','570b9f4e4bb848d088a83aef','','',f,'Custom',,,,,0,'2018-03-24 09:30:31.91+01',f diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 2bebc7854a0..555eeb0be6e 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -19,7 +19,7 @@ START TRANSACTION; CREATE TABLE webknossos.releaseInformation ( schemaVersion BIGINT NOT NULL ); -INSERT INTO webknossos.releaseInformation(schemaVersion) values(93); +INSERT INTO webknossos.releaseInformation(schemaVersion) values(94); COMMIT TRANSACTION; @@ -274,7 +274,7 @@ CREATE TABLE webknossos.timespans( isDeleted BOOLEAN NOT NULL DEFAULT false ); -CREATE TYPE webknossos.PRICING_PLANS AS ENUM ('Basic', 'Premium', 'Pilot', 'Custom'); +CREATE TYPE webknossos.PRICING_PLANS AS ENUM ('Basic', 'Team', 'Power', 'Team_Trial', 'Power_Trial', 'Custom'); CREATE TABLE webknossos.organizations( _id CHAR(24) PRIMARY KEY, name VARCHAR(256) NOT NULL UNIQUE, @@ -286,6 +286,9 @@ CREATE TABLE webknossos.organizations( overTimeMailingList VARCHAR(512) NOT NULL DEFAULT '', enableAutoVerify BOOLEAN NOT NULL DEFAULT false, pricingPlan webknossos.PRICING_PLANS NOT NULL DEFAULT 'Custom', + paidUntil TIMESTAMPTZ DEFAULT NULL, + includedUsers INTEGER DEFAULT NULL, + includedStorage BIGINT DEFAULT NULL, lastTermsOfServiceAcceptanceTime TIMESTAMPTZ, lastTermsOfServiceAcceptanceVersion INT NOT NULL DEFAULT 0, created TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/util/src/main/scala/com/scalableminds/util/time/Instant.scala b/util/src/main/scala/com/scalableminds/util/time/Instant.scala index af67cc5306e..1bdef65a322 100644 --- a/util/src/main/scala/com/scalableminds/util/time/Instant.scala +++ b/util/src/main/scala/com/scalableminds/util/time/Instant.scala @@ -40,7 +40,7 @@ object Instant extends FoxImplicits { fromStringSync(instantLiteral).toFox def fromJoda(jodaDateTime: org.joda.time.DateTime): Instant = Instant(jodaDateTime.getMillis) def fromSql(sqlTime: java.sql.Timestamp): Instant = Instant(sqlTime.getTime) - def fromCalendar(calendarTime: java.util.Calendar) = Instant(calendarTime.getTimeInMillis) + def fromCalendar(calendarTime: java.util.Calendar): Instant = Instant(calendarTime.getTimeInMillis) private def fromStringSync(instantLiteral: String): Option[Instant] = tryo(java.time.Instant.parse(instantLiteral).toEpochMilli).toOption.map(timestamp => Instant(timestamp))