diff --git a/app/controllers/OrganizationController.scala b/app/controllers/OrganizationController.scala index c16d79ee9fb..240b772da0e 100755 --- a/app/controllers/OrganizationController.scala +++ b/app/controllers/OrganizationController.scala @@ -154,7 +154,7 @@ class OrganizationController @Inject()( _ <- Fox.fromBool(request.identity.isAdminOf(organization._id)) ?~> "notAllowed" ~> FORBIDDEN _ <- organizationDAO.updateFields(organization._id, name, newUserMailingList) updated <- organizationDAO.findOne(organization._id) - organizationJson <- organizationService.publicWrites(updated) + organizationJson <- organizationService.publicWrites(updated, Some(request.identity)) } yield Ok(organizationJson) } } diff --git a/biome.json b/biome.json index 56d96c037c1..221b190e61d 100644 --- a/biome.json +++ b/biome.json @@ -100,12 +100,10 @@ }, "overrides": [ { - "include": [ - "**/package.json" - ], + "include": ["**/package.json"], "formatter": { "lineWidth": 1 } } ] -} \ No newline at end of file +} diff --git a/frontend/javascripts/admin/account/account_auth_token_view.tsx b/frontend/javascripts/admin/account/account_auth_token_view.tsx new file mode 100644 index 00000000000..e1bb6f77358 --- /dev/null +++ b/frontend/javascripts/admin/account/account_auth_token_view.tsx @@ -0,0 +1,108 @@ +import { ExportOutlined, SwapOutlined } from "@ant-design/icons"; +import { getAuthToken, revokeAuthToken } from "admin/rest_api"; +import { Button, Col, Row, Spin, Typography } from "antd"; +import { useWkSelector } from "libs/react_hooks"; +import Toast from "libs/toast"; +import { useEffect, useState } from "react"; +import { SettingsCard } from "./helpers/settings_card"; +import { SettingsTitle } from "./helpers/settings_title"; + +const { Text } = Typography; + +function AccountAuthTokenView() { + const activeUser = useWkSelector((state) => state.activeUser); + const [isLoading, setIsLoading] = useState(true); + const [currentToken, setCurrentToken] = useState(""); + + useEffect(() => { + fetchData(); + }, []); + + async function fetchData(): Promise { + try { + const token = await getAuthToken(); + setCurrentToken(token); + } catch (error) { + Toast.error("Failed to fetch auth token. Please refresh the page to try again."); + console.error("Failed to fetch auth token:", error); + } finally { + setIsLoading(false); + } + } + + const handleRevokeToken = async (): Promise => { + try { + setIsLoading(true); + await revokeAuthToken(); + const token = await getAuthToken(); + setCurrentToken(token); + } finally { + setIsLoading(false); + } + }; + + const APIitems = [ + { + title: "Auth Token", + value: ( + + {currentToken} + + ), + }, + { + title: "Token Revocation", + explanation: + "Revoke your token if it has been compromised or if you suspect someone else has gained access to it. This will invalidate all active sessions.", + value: ( + + ), + }, + ...(activeUser + ? [ + { + title: "Organization ID", + value: ( + + {activeUser.organization} + + ), + }, + ] + : []), + { + title: "API Documentation", + value: ( + + Read the docs + + ), + }, + ]; + + return ( +
+ + + + {APIitems.map((item) => ( + + + + ))} + + +
+ ); +} + +export default AccountAuthTokenView; diff --git a/frontend/javascripts/admin/account/account_password_view.tsx b/frontend/javascripts/admin/account/account_password_view.tsx new file mode 100644 index 00000000000..9d74a6e6546 --- /dev/null +++ b/frontend/javascripts/admin/account/account_password_view.tsx @@ -0,0 +1,200 @@ +import { EditOutlined, LockOutlined } from "@ant-design/icons"; +import { changePassword, logoutUser } from "admin/rest_api"; +import { Alert, Button, Col, Form, Input, Row, Space } from "antd"; +import Toast from "libs/toast"; +import messages from "messages"; +import { useState } from "react"; +import { useHistory } from "react-router-dom"; +import { logoutUserAction } from "viewer/model/actions/user_actions"; +import Store from "viewer/store"; +import { SettingsCard } from "./helpers/settings_card"; +import { SettingsTitle } from "./helpers/settings_title"; +const FormItem = Form.Item; +const { Password } = Input; + +const MIN_PASSWORD_LENGTH = 8; + +function AccountPasswordView() { + const history = useHistory(); + const [form] = Form.useForm(); + const [isResetPasswordVisible, setResetPasswordVisible] = useState(false); + + function onFinish(formValues: Record) { + changePassword(formValues) + .then(async () => { + Toast.success(messages["auth.reset_pw_confirmation"]); + await logoutUser(); + Store.dispatch(logoutUserAction()); + history.push("/auth/login"); + }) + .catch((error) => { + console.error("Password change failed:", error); + Toast.error("Failed to change password. Please try again."); + }); + } + + function checkPasswordsAreMatching(value: string, otherPasswordFieldKey: string[]) { + const otherFieldValue = form.getFieldValue(otherPasswordFieldKey); + + if (value && otherFieldValue) { + if (value !== otherFieldValue) { + return Promise.reject(new Error(messages["auth.registration_password_mismatch"])); + } else if (form.getFieldError(otherPasswordFieldKey).length > 0) { + // If the other password field still has errors, revalidate it. + form.validateFields([otherPasswordFieldKey]); + } + } + + return Promise.resolve(); + } + + function getPasswordComponent() { + return isResetPasswordVisible ? ( +
+ + + } + placeholder="Old Password" + /> + + + checkPasswordsAreMatching(value, ["password", "password2"]), + }, + ]} + > + + } + placeholder="New Password" + /> + + + checkPasswordsAreMatching(value, ["password", "password1"]), + }, + ]} + > + + } + placeholder="Confirm New Password" + /> + + + + + + + + + + ) : ( + "***********" + ); + } + + function handleResetPassword() { + setResetPasswordVisible(true); + } + + const passKeyList = [ + { + title: "Coming soon", + value: "Passwordless login with passkeys is coming soon", + // action: - - - {activeUser != null && ( - <> -

Organization ID

-
- - - - -
- - - - ); -} - -export default ChangePasswordView; diff --git a/frontend/javascripts/admin/organization/organization_cards.tsx b/frontend/javascripts/admin/organization/organization_cards.tsx index ec5b9adf43f..6b857970c2b 100644 --- a/frontend/javascripts/admin/organization/organization_cards.tsx +++ b/frontend/javascripts/admin/organization/organization_cards.tsx @@ -1,21 +1,12 @@ -import { - FieldTimeOutlined, - PlusCircleOutlined, - RocketOutlined, - SafetyOutlined, -} from "@ant-design/icons"; -import { Alert, Button, Card, Col, Progress, Row, Tooltip } from "antd"; +import { FieldTimeOutlined, PlusCircleOutlined } from "@ant-design/icons"; +import { Alert, Button, Card, Col, Row } from "antd"; import { formatDateInLocalTimeZone } from "components/formatted_date"; import dayjs from "dayjs"; -import { formatCountToDataAmountUnit, formatCreditsString } from "libs/format_utils"; import { useWkSelector } from "libs/react_hooks"; -import type React from "react"; import type { APIOrganization } from "types/api_types"; import Constants from "viewer/constants"; import { PricingPlanEnum, - hasPricingPlanExceededStorage, - hasPricingPlanExceededUsers, hasPricingPlanExpired, isUserAllowedToRequestUpgrades, powerPlanFeatures, @@ -23,126 +14,89 @@ import { } from "./pricing_plan_utils"; import UpgradePricingPlanModal from "./upgrade_plan_modal"; -export function TeamAndPowerPlanUpgradeCards({ - teamUpgradeCallback, +export function TeamPlanUpgradeCard({ teamUpgradeCallback }: { teamUpgradeCallback: () => void }) { + return ( + + Buy Upgrade + , + ]} + > +
    + {teamPlanFeatures.map((feature) => ( +
  • {feature}
  • + ))} +
+
+ ); +} + +export function PowerPlanUpgradeCard({ powerUpgradeCallback, + description, }: { - teamUpgradeCallback: () => void; powerUpgradeCallback: () => void; + description?: string; }) { return ( - - - - Buy Upgrade - , - ]} - > -
    - {teamPlanFeatures.map((feature) => ( -
  • {feature}
  • - ))} -
-
- - - - Buy Upgrade - , - ]} - > -
    - {powerPlanFeatures.map((feature) => ( -
  • {feature}
  • - ))} -
-
- -
+ + Buy Upgrade + , + ]} + > + {description ?

{description}

: null} +
    + {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 = ( + return ( - -

- Upgrading your WEBKNOSSOS plan will unlock more advanced features and increase your user - and storage quotas. -

- -
    - {powerPlanFeatures.map((feature) => ( -
  • {feature}
  • - ))} -
-
- - - + + + UpgradePricingPlanModal.upgradePricingPlan(organization, PricingPlanEnum.Power) + } + />
); } return ( - -

- Upgrading your WEBKNOSSOS plan will unlock more advanced features and increase your user and - storage quotas. -

- {cardBody} -
+ + + + UpgradePricingPlanModal.upgradePricingPlan(organization, PricingPlanEnum.Team) + } + /> + + + + UpgradePricingPlanModal.upgradePricingPlan(organization, PricingPlanEnum.Power) + } + /> + + ); } @@ -150,7 +104,7 @@ export function PlanExpirationCard({ organization }: { organization: APIOrganiza if (organization.paidUntil === Constants.MAXIMUM_DATE_TIMESTAMP) return null; return ( - + Your current plan is paid until{" "} @@ -170,158 +124,6 @@ export function PlanExpirationCard({ organization }: { organization: APIOrganiza ); } -export function PlanDashboardCard({ - organization, - activeUsersCount, -}: { - organization: APIOrganization; - activeUsersCount: number; -}) { - const usedUsersPercentage = (activeUsersCount / organization.includedUsers) * 100; - const usedStoragePercentage = - (organization.usedStorageBytes / organization.includedStorageBytes) * 100; - - const hasExceededUserLimit = hasPricingPlanExceededUsers(organization, activeUsersCount); - const hasExceededStorageLimit = hasPricingPlanExceededStorage(organization); - - const maxUsersCountLabel = - organization.includedUsers === Number.POSITIVE_INFINITY ? "∞" : organization.includedUsers; - - const includedStorageLabel = - organization.includedStorageBytes === Number.POSITIVE_INFINITY - ? "∞" - : formatCountToDataAmountUnit(organization.includedStorageBytes, true); - - const usedStorageLabel = formatCountToDataAmountUnit(organization.usedStorageBytes, true); - - 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 - } - > - Buy Upgrade - , - ]; - upgradeStorageAction = [ - UpgradePricingPlanModal.upgradePricingPlan(organization) - : UpgradePricingPlanModal.upgradeStorageQuota - } - > - Buy Upgrade - , - ]; - upgradePlanAction = [ - [ - - Compare Plans - , - ], - ]; - } - const buyMoreCreditsAction = [ - - - , - ]; - - 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 -
- - - - -

- {organization.creditBalance != null - ? formatCreditsString(organization.creditBalance) - : "No information access"} -

-
- WEBKNOSSOS Credits -
- -
- - ); -} - export function PlanExceededAlert({ organization }: { organization: APIOrganization }) { const hasPlanExpired = hasPricingPlanExpired(organization); const activeUser = useWkSelector((state) => state.activeUser); @@ -379,21 +181,21 @@ export function PlanAboutToExceedAlert({ organization }: { organization: APIOrga ), }); - // 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: ( - // - // ), - // }); - // } + 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 ( <> diff --git a/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx new file mode 100644 index 00000000000..3f3646e3f2f --- /dev/null +++ b/frontend/javascripts/admin/organization/organization_danger_zone_view.tsx @@ -0,0 +1,66 @@ +import { SettingsCard } from "admin/account/helpers/settings_card"; +import { SettingsTitle } from "admin/account/helpers/settings_title"; +import { deleteOrganization } from "admin/rest_api"; +import { Button, Typography } from "antd"; +import { confirmAsync } from "dashboard/dataset/helper_components"; +import { useState } from "react"; +import type { APIOrganization } from "types/api_types"; + +export function OrganizationDangerZoneView({ organization }: { organization: APIOrganization }) { + const [isDeleting, setIsDeleting] = useState(false); + + async function handleDeleteButtonClicked(): Promise { + const isDeleteConfirmed = await confirmAsync({ + title: "Danger Zone", + content: ( +
+ + You will lose access to all the datasets and annotations uploaded/created as part of + this organization! + + + Unless you are part of another WEBKNOSSOS organization, you can NOT login again with + this account and will lose access to WEBKNOSSOS. + +

+ Deleting an organization{" "} + cannot be undone. Are you certain you + want to delete the organization {organization.name}? +

+
+ ), + okText: <>Yes, delete this organization now and log me out., + okType: "danger", + width: 500, + }); + + if (isDeleteConfirmed) { + setIsDeleting(true); + await deleteOrganization(organization.id); + setIsDeleting(false); + window.location.replace(`${window.location.origin}`); + } + } + return ( + <> + + + Delete Organization + + } + /> + + ); +} diff --git a/frontend/javascripts/admin/organization/organization_edit_view.tsx b/frontend/javascripts/admin/organization/organization_edit_view.tsx deleted file mode 100644 index a96dfcd7ebd..00000000000 --- a/frontend/javascripts/admin/organization/organization_edit_view.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { - CopyOutlined, - IdcardOutlined, - MailOutlined, - SaveOutlined, - TagOutlined, - UserOutlined, -} from "@ant-design/icons"; -import { - deleteOrganization, - getPricingPlanStatus, - getUsers, - updateOrganization, -} from "admin/rest_api"; -import { Button, Card, Col, Form, Input, Row, Skeleton, Space, Typography } from "antd"; -import { confirmAsync } from "dashboard/dataset/helper_components"; -import Toast from "libs/toast"; -import { useEffect, useState } from "react"; -import { connect } from "react-redux"; -import type { APIOrganization, APIPricingPlanStatus } from "types/api_types"; -import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors"; -import type { WebknossosState } from "viewer/store"; -import { - PlanAboutToExceedAlert, - PlanDashboardCard, - PlanExceededAlert, - PlanExpirationCard, - PlanUpgradeCard, -} from "./organization_cards"; -import { getActiveUserCount } from "./pricing_plan_utils"; - -const FormItem = Form.Item; - -type Props = { - organization: APIOrganization; -}; - -type FormValues = { - displayName: string; - newUserMailingList: string; -}; - -const OrganizationEditView = ({ organization }: Props) => { - const [isFetchingData, setIsFetchingData] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [activeUsersCount, setActiveUsersCount] = useState(1); - const [pricingPlanStatus, setPricingPlanStatus] = useState(null); - - const [form] = Form.useForm(); - - useEffect(() => { - fetchData(); - }, []); - - async function fetchData() { - setIsFetchingData(true); - const [users, pricingPlanStatus] = await Promise.all([getUsers(), getPricingPlanStatus()]); - - setPricingPlanStatus(pricingPlanStatus); - setActiveUsersCount(getActiveUserCount(users)); - setIsFetchingData(false); - } - - async function onFinish(formValues: FormValues) { - await updateOrganization( - organization.id, - formValues.displayName, - formValues.newUserMailingList, - ); - Toast.success("Organization settings were saved successfully."); - } - - async function handleDeleteButtonClicked(): Promise { - const isDeleteConfirmed = await confirmAsync({ - title: "Danger Zone", - content: ( -
- - You will lose access to all the datasets and annotations uploaded/created as part of - this organization! - - - Unless you are part of another WEBKNOSSOS organization, you can NOT login again with - this account and will lose access to WEBKNOSSOS. - -

- Deleting an organization{" "} - cannot be undone. Are you certain you - want to delete the organization {organization.name}? -

-
- ), - okText: <>Yes, delete this organization now and log me out., - okType: "danger", - width: 500, - }); - - if (isDeleteConfirmed) { - setIsDeleting(true); - await deleteOrganization(organization.id); - setIsDeleting(false); - window.location.replace(`${window.location.origin}/dashboard`); - } - } - - async function handleCopyNameButtonClicked(): Promise { - await navigator.clipboard.writeText(organization.id); - Toast.success("Copied organization name to the clipboard."); - } - - const OrgaNameRegexPattern = /^[A-Za-z0-9\\-_\\. ß]+$/; - - if (isFetchingData || !organization || !pricingPlanStatus) - return ( -
- -
- ); - - return ( -
- Your Organization - -

{organization.name}

-
- {pricingPlanStatus.isExceeded ? : null} - {pricingPlanStatus.isAlmostExceeded && !pricingPlanStatus.isExceeded ? ( - - ) : null} - - - - -
- - - } - value={organization.id} - style={{ - width: "calc(100% - 31px)", - }} - readOnly - disabled - /> - - -
- - - - Delete this organization including all annotations, uploaded datasets, and associated - user accounts. Careful, this action can NOT be undone. - - - - - - -
- ); -}; - -const mapStateToProps = (state: WebknossosState): Props => ({ - organization: enforceActiveOrganization(state.activeOrganization), -}); -export default connect(mapStateToProps)(OrganizationEditView); diff --git a/frontend/javascripts/admin/organization/organization_notifications_view.tsx b/frontend/javascripts/admin/organization/organization_notifications_view.tsx new file mode 100644 index 00000000000..f903c7e29f2 --- /dev/null +++ b/frontend/javascripts/admin/organization/organization_notifications_view.tsx @@ -0,0 +1,118 @@ +import { MailOutlined, SaveOutlined } from "@ant-design/icons"; +import { SettingsCard } from "admin/account/helpers/settings_card"; +import { SettingsTitle } from "admin/account/helpers/settings_title"; +import { updateOrganization } from "admin/rest_api"; +import { getUsers } from "admin/rest_api"; +import { Button, Col, Form, Input, Row } from "antd"; +import Toast from "libs/toast"; +import { useEffect, useState } from "react"; +import type { APIOrganization } from "types/api_types"; +import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; +import { Store } from "viewer/singletons"; + +const FormItem = Form.Item; + +type FormValues = { + displayName: string; + newUserMailingList: string; +}; + +export function OrganizationNotificationsView({ organization }: { organization: APIOrganization }) { + const [form] = Form.useForm(); + const [ownerEmail, setOwnerEmail] = useState(""); + + useEffect(() => { + async function fetchOwnerEmail() { + const users = await getUsers(); + const owner = users.find( + (user) => user.isOrganizationOwner && user.organization === organization.id, + ); + if (owner) { + setOwnerEmail(owner.email); + } + } + fetchOwnerEmail(); + }, [organization.id]); + + async function onFinish(formValues: FormValues) { + const updatedOrganization = await updateOrganization( + organization.id, + organization.name, + formValues.newUserMailingList, + ); + Store.dispatch(setActiveOrganizationAction(updatedOrganization)); + Toast.success("Notification settings were saved successfully."); + } + + function getNewUserNotificationsSettings() { + return ( +
+ + + } + placeholder="email@example.com" + style={{ minWidth: 250 }} + /> + + +
+ ); + } + + return ( + <> + + + + + + + + + + + + + + ); +} diff --git a/frontend/javascripts/admin/organization/organization_overview_view.tsx b/frontend/javascripts/admin/organization/organization_overview_view.tsx new file mode 100644 index 00000000000..8f3013555c4 --- /dev/null +++ b/frontend/javascripts/admin/organization/organization_overview_view.tsx @@ -0,0 +1,202 @@ +import { PlusOutlined } from "@ant-design/icons"; +import { SettingsTitle } from "admin/account/helpers/settings_title"; +import { getPricingPlanStatus, getUsers, updateOrganization } from "admin/rest_api"; +import { Button, Col, Row, Spin, Tooltip, Typography } from "antd"; +import { formatCountToDataAmountUnit } from "libs/format_utils"; +import Toast from "libs/toast"; +import { useEffect, useState } from "react"; +import type { APIOrganization, APIPricingPlanStatus } from "types/api_types"; +import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; +import { Store } from "viewer/singletons"; +import { SettingsCard } from "../account/helpers/settings_card"; +import { + PlanAboutToExceedAlert, + PlanExceededAlert, + PlanExpirationCard, + PlanUpgradeCard, +} from "./organization_cards"; +import { PricingPlanEnum, getActiveUserCount } from "./pricing_plan_utils"; +import UpgradePricingPlanModal from "./upgrade_plan_modal"; + +const ORGA_NAME_REGEX_PATTERN = /^[A-Za-z0-9\-_. ß]+$/; + +export function OrganizationOverviewView({ organization }: { organization: APIOrganization }) { + const [isFetchingData, setIsFetchingData] = useState(true); + const [activeUsersCount, setActiveUsersCount] = useState(1); + const [pricingPlanStatus, setPricingPlanStatus] = useState(null); + + useEffect(() => { + fetchData(); + }, []); + + async function fetchData() { + setIsFetchingData(true); + const [users, pricingPlanStatus] = await Promise.all([getUsers(), getPricingPlanStatus()]); + + setPricingPlanStatus(pricingPlanStatus); + setActiveUsersCount(getActiveUserCount(users)); + setIsFetchingData(false); + } + + async function setOrganizationName(newOrgaName: string) { + if (!ORGA_NAME_REGEX_PATTERN.test(newOrgaName)) { + Toast.error( + "Organization name can only contain letters, numbers, spaces, and the following special characters: - _ . ß", + ); + return; + } + + const updatedOrganization = await updateOrganization( + organization.id, + newOrgaName, + organization.newUserMailingList, + ); + Store.dispatch(setActiveOrganizationAction(updatedOrganization)); + } + + const maxUsersCountLabel = + organization.includedUsers === Number.POSITIVE_INFINITY ? "∞" : organization.includedUsers; + + const includedStorageLabel = + organization.includedStorageBytes === Number.POSITIVE_INFINITY + ? "∞" + : formatCountToDataAmountUnit(organization.includedStorageBytes, true); + + const usedStorageLabel = formatCountToDataAmountUnit(organization.usedStorageBytes, true); + + let upgradeUsersAction: React.ReactNode = null; + let upgradeStorageAction: React.ReactNode = null; + + if ( + organization.pricingPlan === PricingPlanEnum.Basic || + organization.pricingPlan === PricingPlanEnum.Team || + organization.pricingPlan === PricingPlanEnum.TeamTrial + ) { + upgradeUsersAction = ( +