Skip to content

Commit

Permalink
Add owner information to organization (#6811)
Browse files Browse the repository at this point in the history
* added orga owner to organization REST route

* add organization owner info to orga page

* provide restricted plan message with owner information

* make option to upgrade more prominent

* replace tooltips with popovers

* convert owner name information to string

* updated changelog

* remove unused comment

* Update app/models/user/User.scala

Co-authored-by: Florian M <[email protected]>

* Update app/models/organization/OrganizationService.scala

Co-authored-by: Florian M <[email protected]>

* applied PR feedback

* foo

* applied PR feedback

* fix tests

---------

Co-authored-by: Florian M <[email protected]>
  • Loading branch information
hotzenklotz and fm3 authored Feb 8, 2023
1 parent 53bd336 commit b3673cd
Show file tree
Hide file tree
Showing 13 changed files with 178 additions and 43 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Changed
- Limit paid team sharing features to respective organization plans. [#6767](https://github.com/scalableminds/webknossos/pull/6776)
- Rewrite the database tools in `tools/postgres` to JavaScript and adding support for non-default Postgres username-password combinations. [#6803](https://github.com/scalableminds/webknossos/pull/6803)
- Added owner name to organization page. [#6811](https://github.com/scalableminds/webknossos/pull/6811)

### Fixed
- Fixed saving allowed teams in dataset settings. [#6817](https://github.com/scalableminds/webknossos/pull/6817)
Expand Down
9 changes: 7 additions & 2 deletions app/models/organization/OrganizationService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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, UserDAO}
import models.user.{Invite, MultiUserDAO, User, UserDAO, UserService}
import play.api.libs.json.{JsObject, Json}
import utils.{ObjectId, WkConf}

Expand All @@ -22,13 +22,15 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
dataStoreDAO: DataStoreDAO,
folderDAO: FolderDAO,
folderService: FolderService,
userService: UserService,
rpc: RPC,
initialDataService: InitialDataService,
conf: WkConf,
)(implicit ec: ExecutionContext)
extends FoxImplicits {

def publicWrites(organization: Organization, requestingUser: Option[User] = None): Fox[JsObject] = {

val adminOnlyInfo = if (requestingUser.exists(_.isAdminOf(organization._id))) {
Json.obj(
"newUserMailingList" -> organization.newUserMailingList,
Expand All @@ -38,6 +40,8 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
} else Json.obj()
for {
usedStorageBytes <- organizationDAO.getUsedStorage(organization._id)
ownerBox <- userDAO.findOwnerByOrg(organization._id).futureBox
ownerNameOpt = ownerBox.toOption.map(o => s"${o.firstName} ${o.lastName}")
} yield
Json.obj(
"id" -> organization._id.toString,
Expand All @@ -49,7 +53,8 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
"paidUntil" -> organization.paidUntil,
"includedUsers" -> organization.includedUsers,
"includedStorageBytes" -> organization.includedStorageBytes,
"usedStorageBytes" -> usedStorageBytes
"usedStorageBytes" -> usedStorageBytes,
"ownerName" -> ownerNameOpt
) ++ adminOnlyInfo
}

Expand Down
12 changes: 12 additions & 0 deletions app/models/user/User.scala
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
parsed <- Fox.combined(r.toList.map(parse))
} yield parsed

def findOwnerByOrg(organizationId: ObjectId): Fox[User] =
for {
r <- run(q"""select $columns
from $existingCollectionName
where isOrganizationOwner
and not isDeactivated
and _organization = $organizationId
order by _id
limit 1""".as[UsersRow])
parsed <- parseFirst(r, organizationId)
} yield parsed

def findOneByOrgaAndMultiUser(organizationId: ObjectId, multiUserId: ObjectId)(
implicit ctx: DBAccessContext): Fox[User] =
for {
Expand Down
10 changes: 10 additions & 0 deletions frontend/javascripts/admin/organization/organization_edit_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CopyOutlined,
SaveOutlined,
IdcardOutlined,
UserOutlined,
} from "@ant-design/icons";
import { confirmAsync } from "dashboard/dataset/helper_components";
import {
Expand Down Expand Up @@ -169,13 +170,22 @@ class OrganizationEditView extends React.PureComponent<Props, State> {
width: "calc(100% - 31px)",
}}
readOnly
disabled
/>
<Button
onClick={this.handleCopyNameButtonClicked}
icon={<CopyOutlined className="without-icon-margin" />}
/>
</Input.Group>
</FormItem>
<FormItem label="Organization Owner">
<Input
prefix={<UserOutlined />}
value={this.props.organization.ownerName}
readOnly
disabled
/>
</FormItem>
<FormItem
label="Organization Name"
name="displayName"
Expand Down
25 changes: 25 additions & 0 deletions frontend/javascripts/admin/organization/pricing_plan_utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import messages from "messages";
import { APIOrganization, APIUser } from "types/api_flow_types";

export enum PricingPlanEnum {
Expand Down Expand Up @@ -83,3 +84,27 @@ export function isFeatureAllowedByPricingPlan(

return isPricingPlanGreaterEqualThan(organization.pricingPlan, requiredPricingPlan);
}

export function getFeatureNotAvailableInPlanMessage(
requiredPricingPlan: PricingPlanEnum,
organization: APIOrganization | null,
activeUser: APIUser | null | undefined,
) {
if (activeUser?.isOrganizationOwner) {
return messages["organization.plan.feature_not_available.owner"](requiredPricingPlan);
}

let organizationOwnerName = "";
// expected naming schema for owner: "(M. Mustermann)" | ""
if (organization?.ownerName) {
{
const [firstName, ...rest] = organization.ownerName.split(" ");
organizationOwnerName = `(${firstName[0]}. ${rest.join(" ")})`;
}
}

return messages["organization.plan.feature_not_available"](
requiredPricingPlan,
organizationOwnerName,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ function UpgradePricingPlanModal({
) : null}
</>
}
zIndex={10000} // overlay everything
>
<div
style={{
Expand Down
124 changes: 92 additions & 32 deletions frontend/javascripts/components/pricing_enforcers.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import React from "react";
import { useSelector } from "react-redux";
import { Tooltip, Menu, MenuItemProps, Alert, ButtonProps, Button } from "antd";
import { Menu, MenuItemProps, Alert, ButtonProps, Button, Result, Popover } from "antd";
import { LockOutlined } from "@ant-design/icons";
import {
getFeatureNotAvailableInPlanMessage,
isFeatureAllowedByPricingPlan,
PricingPlanEnum,
} from "admin/organization/pricing_plan_utils";
import { isUserAllowedToRequestUpgrades } from "admin/organization/pricing_plan_utils";
import { Link } from "react-router-dom";
import messages from "messages";
import type { MenuClickEventHandler } from "rc-menu/lib/interface";
import type { OxalisState } from "oxalis/store";
import { rgbToHex } from "libs/utils";
import { PRIMARY_COLOR } from "oxalis/constants";
import UpgradePricingPlanModal from "admin/organization/upgrade_plan_modal";
import { APIOrganization, APIUser } from "types/api_flow_types";

const PRIMARY_COLOR_HEX = rgbToHex(PRIMARY_COLOR);

const popOverStyle = { color: "white", maxWidth: 250 };

const handleMouseClick = (event: React.MouseEvent) => {
event.preventDefault();
Expand All @@ -24,18 +32,42 @@ const handleMenuClick: MenuClickEventHandler = (info) => {

type RequiredPricingProps = { requiredPricingPlan: PricingPlanEnum };

function getUpgradeNowButton(
activeUser: APIUser | null | undefined,
activeOrganization: APIOrganization | null,
) {
return activeUser && activeOrganization && isUserAllowedToRequestUpgrades(activeUser) ? (
<div style={{ marginTop: 8 }}>
<Button
size="small"
onClick={() => UpgradePricingPlanModal.upgradePricingPlan(activeOrganization)}
>
Upgrade Now
</Button>
</div>
) : null;
}

export const PricingEnforcedMenuItem: React.FunctionComponent<
RequiredPricingProps & MenuItemProps
> = ({ children, requiredPricingPlan, ...menuItemProps }) => {
const activeUser = useSelector((state: OxalisState) => state.activeUser);
const activeOrganization = useSelector((state: OxalisState) => state.activeOrganization);
const isFeatureAllowed = isFeatureAllowedByPricingPlan(activeOrganization, requiredPricingPlan);

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

return (
<Tooltip
title={messages["organization.plan.feature_not_available"](requiredPricingPlan)}
<Popover
color={PRIMARY_COLOR_HEX}
content={
<div style={popOverStyle}>
{getFeatureNotAvailableInPlanMessage(requiredPricingPlan, activeOrganization, activeUser)}
{getUpgradeNowButton(activeUser, activeOrganization)}
</div>
}
placement="right"
trigger="hover"
>
<Menu.Item
onClick={handleMenuClick}
Expand All @@ -48,7 +80,7 @@ export const PricingEnforcedMenuItem: React.FunctionComponent<
{children}
<LockOutlined style={{ marginLeft: 5 }} />
</Menu.Item>
</Tooltip>
</Popover>
);
};

Expand All @@ -57,21 +89,29 @@ export const PricingEnforcedButton: React.FunctionComponent<RequiredPricingProps
requiredPricingPlan,
...buttonProps
}) => {
const activeUser = useSelector((state: OxalisState) => state.activeUser);
const activeOrganization = useSelector((state: OxalisState) => state.activeOrganization);
const isFeatureAllowed = isFeatureAllowedByPricingPlan(activeOrganization, requiredPricingPlan);

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

return (
<Tooltip
title={messages["organization.plan.feature_not_available"](requiredPricingPlan)}
placement="right"
<Popover
color={PRIMARY_COLOR_HEX}
content={
<div style={popOverStyle}>
{getFeatureNotAvailableInPlanMessage(requiredPricingPlan, activeOrganization, activeUser)}
{getUpgradeNowButton(activeUser, activeOrganization)}
</div>
}
placement="bottom"
trigger="hover"
>
<Button {...buttonProps} disabled>
{children}
<LockOutlined style={{ marginLeft: 5 }} />
</Button>
</Tooltip>
</Popover>
);
};

Expand All @@ -80,6 +120,7 @@ export const PricingEnforcedBlur: React.FunctionComponent<RequiredPricingProps>
requiredPricingPlan,
...restProps
}) => {
const activeUser = useSelector((state: OxalisState) => state.activeUser);
const activeOrganization = useSelector((state: OxalisState) => state.activeOrganization);
const isFeatureAllowed = isFeatureAllowedByPricingPlan(activeOrganization, requiredPricingPlan);

Expand All @@ -100,7 +141,16 @@ export const PricingEnforcedBlur: React.FunctionComponent<RequiredPricingProps>
);

return (
<Tooltip title={messages["organization.plan.feature_not_available"](requiredPricingPlan)}>
<Popover
color={PRIMARY_COLOR_HEX}
content={
<div style={popOverStyle}>
{getFeatureNotAvailableInPlanMessage(requiredPricingPlan, activeOrganization, activeUser)}
{getUpgradeNowButton(activeUser, activeOrganization)}
</div>
}
trigger="hover"
>
<div style={{ position: "relative", cursor: "not-allowed" }}>
<div
style={{
Expand All @@ -122,44 +172,54 @@ export const PricingEnforcedBlur: React.FunctionComponent<RequiredPricingProps>
>
<Alert
showIcon
message={messages["organization.plan.feature_not_available"](requiredPricingPlan)}
message={getFeatureNotAvailableInPlanMessage(
requiredPricingPlan,
activeOrganization,
activeUser,
)}
icon={<LockOutlined />}
/>
</div>
</div>
</Tooltip>
</Popover>
);
};

export function PageUnavailableForYourPlanView() {
export function PageUnavailableForYourPlanView({
requiredPricingPlan,
}: {
requiredPricingPlan: PricingPlanEnum;
}) {
const activeUser = useSelector((state: OxalisState) => state.activeUser);
const activeOrganization = useSelector((state: OxalisState) => state.activeOrganization);

const linkToOrganizationSettings =
activeUser && activeOrganization && isUserAllowedToRequestUpgrades(activeUser) ? (
<Link to={`/organizations/${activeOrganization.name}`}>Go to Organization Settings</Link>
) : null;
<Link to={`/organizations/${activeOrganization.name}`}>
<Button>Go to Organization Settings</Button>
</Link>
) : undefined;

return (
<div className="container">
<Alert
style={{
maxWidth: "500px",
margin: "0 auto",
}}
message="Feature not available"
description={
<>
<p>
The requested feature is not available in your WEBKNOSSOS organization. Consider
upgrading to a higher WEBKNOSSOS plan to unlock it or ask your organization's owner to
upgrade.
</p>
{linkToOrganizationSettings}
</>
<Result
status="warning"
title="Feature not available"
subTitle={
<p style={{ maxWidth: "500px", margin: "0 auto" }}>
{getFeatureNotAvailableInPlanMessage(
requiredPricingPlan,
activeOrganization,
activeUser,
)}
</p>
}
type="error"
showIcon
extra={[
<Link to="/">
<Button type="primary">Return to Dashboard</Button>
</Link>,
linkToOrganizationSettings,
]}
/>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion frontend/javascripts/components/secured_route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ class SecuredRoute extends React.PureComponent<SecuredRouteProps, State> {
this.props.requiredPricingPlan,
)
) {
return <PageUnavailableForYourPlanView />;
return (
<PageUnavailableForYourPlanView
requiredPricingPlan={this.props.requiredPricingPlan}
/>
);
}

if (Component != null) {
Expand Down
9 changes: 7 additions & 2 deletions frontend/javascripts/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,11 @@ instead. Only enable this option if you understand its effect. All layers will n
"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.",
"organization.plan.feature_not_available": (requiredPlan: string) =>
`This feature is not available in your organization's plan. Ask your organization owner to upgrade at least a ${requiredPlan} plan.`,
"organization.plan.feature_not_available": (
requiredPlan: string,
organizationOwnerName: string,
) =>
`This feature is not available in your organization's plan. Ask the owner of your organization ${organizationOwnerName} to upgrade to a ${requiredPlan} plan or higher.`,
"organization.plan.feature_not_available.owner": (requiredPlan: string) =>
`This feature is not available in your organization's plan. Consider upgrading to a ${requiredPlan} plan or higher.`,
};
2 changes: 1 addition & 1 deletion frontend/javascripts/oxalis/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export type TypedArray =

export type TypedArrayWithoutBigInt = Exclude<TypedArray, BigUint64Array>;

export const PRIMARY_COLOR = [86, 96, 255];
export const PRIMARY_COLOR: Vector3 = [86, 96, 255];

export enum LOG_LEVELS {
NOTSET = "NOTSET",
Expand Down
Loading

0 comments on commit b3673cd

Please sign in to comment.