0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat: add add-on feature notice/tag

This commit is contained in:
Darcy Ye 2024-08-06 12:04:49 +08:00
parent a731b09122
commit e8ed7178c1
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
46 changed files with 894 additions and 48 deletions
packages
console/src
components
AddOnNoticeFooter
ApplicationCreation/CreateForm
FeatureTag
ds-components/ModalLayout
hooks
pages
ApiResources/components/CreateForm
CustomizeJwt
EnterpriseSso
Mfa
MfaForm
PageWrapper
OrganizationTemplate
Organizations
TenantSettings/TenantMembers
phrases/src/locales
de/translation/admin-console/upsell
en/translation/admin-console/upsell
es/translation/admin-console/upsell
fr/translation/admin-console/upsell
it/translation/admin-console/upsell
ja/translation/admin-console/upsell
ko/translation/admin-console/upsell
pl-pl/translation/admin-console/upsell
pt-br/translation/admin-console/upsell
pt-pt/translation/admin-console/upsell
ru/translation/admin-console/upsell
tr-tr/translation/admin-console/upsell
zh-cn/translation/admin-console/upsell
zh-hk/translation/admin-console/upsell
zh-tw/translation/admin-console/upsell

View file

@ -0,0 +1,17 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
align-items: center;
gap: _.unit(6);
padding: _.unit(6);
background-color: var(--color-info-container);
margin: 0 _.unit(-6) _.unit(-6);
flex: 1; // Should display in full width
.description {
flex: 1;
flex-shrink: 0;
font: var(--font-body-2);
}
}

View file

@ -0,0 +1,30 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { type ReactNode } from 'react';
import Button from '@/ds-components/Button';
import styles from './index.module.scss';
type Props = {
readonly children: ReactNode;
readonly isLoading?: boolean;
readonly buttonTitle?: AdminConsoleKey;
readonly onClick: () => void;
};
function AddOnNoticeFooter({ children, isLoading, onClick, buttonTitle }: Props) {
return (
<div className={styles.container}>
<div className={styles.description}>{children}</div>
<Button
size="large"
type="primary"
title={buttonTitle ?? 'upsell.upgrade_plan'}
isLoading={isLoading}
onClick={onClick}
/>
</div>
);
}
export default AddOnNoticeFooter;

View file

@ -0,0 +1,3 @@
.strong {
font-weight: 500;
}

View file

@ -2,14 +2,18 @@ import { ApplicationType, ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
import TextLink from '@/ds-components/TextLink';
import useApplicationsUsage from '@/hooks/use-applications-usage';
import styles from './index.module.scss';
type Props = {
readonly selectedType?: ApplicationType;
readonly isLoading: boolean;
@ -18,17 +22,43 @@ type Props = {
};
function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props) {
const { currentPlan, currentSku } = useContext(SubscriptionDataContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' });
const { currentPlan, currentSku, logtoSkus } = useContext(SubscriptionDataContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell' });
const {
hasAppsReachedLimit,
hasMachineToMachineAppsReachedLimit,
hasThirdPartyAppsReachedLimit,
} = useApplicationsUsage();
const addOnUnitPrice = logtoSkus.find(({ id }) => id === currentPlan.id)?.unitPrice ?? 0;
if (selectedType) {
const { id: planId, name: planName, quota } = currentPlan;
if (
selectedType === ApplicationType.MachineToMachine &&
isDevFeaturesEnabled &&
planId === ReservedPlanId.Pro
) {
return (
<AddOnNoticeFooter
isLoading={isLoading}
buttonTitle="applications.create"
onClick={onClickCreate}
>
<Trans
components={{
span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t('add_on.footer.machine_to_machine_app', {
price: Number(addOnUnitPrice) / 100,
})}
</Trans>
</AddOnNoticeFooter>
);
}
if (
selectedType === ApplicationType.MachineToMachine &&
hasMachineToMachineAppsReachedLimit &&
@ -42,7 +72,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
a: <ContactUsPhraseLink />,
}}
>
{t('machine_to_machine_feature')}
{t('paywall.machine_to_machine_feature')}
</Trans>
</QuotaGuardFooter>
);
@ -57,7 +87,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
a: <ContactUsPhraseLink />,
}}
>
{t('third_party_apps')}
{t('paywall.third_party_apps')}
</Trans>
</QuotaGuardFooter>
);
@ -72,7 +102,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
planName: <PlanName skuId={currentSku.id} name={planName} />,
}}
>
{t('applications', { count: quota.applicationsLimit ?? 0 })}
{t('paywall.applications', { count: quota.applicationsLimit ?? 0 })}
</Trans>
</QuotaGuardFooter>
);

View file

@ -1,7 +1,8 @@
import { type AdminConsoleKey } from '@logto/phrases';
import type { Application } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { type ReactElement, useMemo } from 'react';
import { ApplicationType, ReservedPlanId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { type ReactElement, useContext, useMemo } from 'react';
import { useController, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -9,6 +10,8 @@ import Modal from 'react-modal';
import { useSWRConfig } from 'swr';
import { GtagConversionId, reportConversion } from '@/components/Conversion/utils';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
@ -52,6 +55,9 @@ function CreateForm({
} = useForm<FormData>({
defaultValues: { type: defaultCreateType, isThirdParty: isDefaultCreateThirdParty },
});
const {
currentSubscription: { planId },
} = useContext(SubscriptionDataContext);
const { user } = useCurrentUser();
const { mutate: mutateGlobal } = useSWRConfig();
@ -115,6 +121,9 @@ function CreateForm({
<ModalLayout
title="applications.create"
subtitle={subtitleElement}
paywall={conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ReservedPlanId.Pro
)}
size={defaultCreateType ? 'medium' : 'large'}
footer={
<Footer

View file

@ -0,0 +1,19 @@
import classNames from 'classnames';
/**
* AddOnTag static component
*
* Used to indicate that a feature is add-on feature and will be charged according to usage.
*/
import styles from './index.module.scss';
type Props = {
readonly className?: string;
};
function AddOnTag({ className }: Props) {
return <div className={classNames(styles.tag, styles.beta, className)}>Add-on</div>;
}
export default AddOnTag;

View file

@ -1,9 +1,12 @@
import { type ReservedPlanId } from '@logto/schemas';
import { ReservedPlanId } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import AddOnTag from './AddOnTag';
import styles from './index.module.scss';
export { default as BetaTag } from './BetaTag';
@ -50,6 +53,9 @@ export type Props = {
function FeatureTag(props: Props) {
const { className } = props;
const { isDevTenant } = useContext(TenantsContext);
const {
currentSubscription: { planId },
} = useContext(SubscriptionDataContext);
const { isVisible, plan } = props;
@ -59,6 +65,11 @@ function FeatureTag(props: Props) {
return null;
}
// Show the add-on tag for Pro plan when dev features are enabled.
if (isDevFeaturesEnabled && planId === ReservedPlanId.Pro) {
return <AddOnTag className={className} />;
}
return <div className={classNames(styles.tag, className)}>{plan}</div>;
}

View file

@ -17,7 +17,7 @@ export type Props = {
readonly className?: string;
readonly size?: 'medium' | 'large' | 'xlarge';
readonly headerIcon?: ReactElement;
} & Pick<CardTitleProps, 'learnMoreLink' | 'title' | 'subtitle' | 'isWordWrapEnabled'>;
} & Pick<CardTitleProps, 'learnMoreLink' | 'title' | 'subtitle' | 'isWordWrapEnabled' | 'paywall'>;
function ModalLayout({
children,

View file

@ -19,6 +19,7 @@ const userPreferencesGuard = z.object({
managementApiAcknowledged: z.boolean().optional(),
roleWithManagementApiAccessNotificationAcknowledged: z.boolean().optional(),
m2mRoleNotificationAcknowledged: z.boolean().optional(),
mfaUpsellNoticeAcknowledged: z.boolean().optional(),
});
type UserPreferences = z.infer<typeof userPreferencesGuard>;

View file

@ -2,14 +2,18 @@ import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import PlanName from '@/components/PlanName';
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import Button from '@/ds-components/Button';
import TextLink from '@/ds-components/TextLink';
import useApiResourcesUsage from '@/hooks/use-api-resources-usage';
import styles from './index.module.scss';
type Props = {
readonly isCreationLoading: boolean;
readonly onClickCreate: () => void;
@ -19,10 +23,13 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentPlan,
logtoSkus,
currentSubscription: { planId },
currentSubscriptionUsage: { resourcesLimit },
currentSku,
} = useContext(SubscriptionDataContext);
const { hasReachedLimit } = useApiResourcesUsage();
const addOnUnitPrice = logtoSkus.find(({ id }) => id === planId)?.unitPrice ?? 0;
if (
hasReachedLimit &&
@ -47,6 +54,27 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
);
}
if (isDevFeaturesEnabled && planId === ReservedPlanId.Pro) {
return (
<AddOnNoticeFooter
isLoading={isCreationLoading}
buttonTitle="api_resources.create"
onClick={onClickCreate}
>
<Trans
components={{
span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t('upsell.add_on.footer.api_resource', {
price: Number(addOnUnitPrice) / 100,
})}
</Trans>
</AddOnNoticeFooter>
);
}
return (
<Button
isLoading={isCreationLoading}

View file

@ -0,0 +1,3 @@
.strong {
font-weight: 500;
}

View file

@ -1,10 +1,14 @@
import { isValidUrl } from '@logto/core-kit';
import { type Resource } from '@logto/schemas';
import { ReservedPlanId, type Resource } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useContext } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import TextInput from '@/ds-components/TextInput';
@ -26,6 +30,9 @@ type Props = {
function CreateForm({ onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentSubscription: { planId },
} = useContext(SubscriptionDataContext);
const {
handleSubmit,
@ -60,6 +67,9 @@ function CreateForm({ onClose }: Props) {
<ModalLayout
title="api_resources.create"
subtitle="api_resources.subtitle"
paywall={conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ReservedPlanId.Pro
)}
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
onClose={onClose}
>

View file

@ -11,10 +11,11 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { getPagePath } from '@/pages/CustomizeJwt/utils/path';
type Props = {
readonly isDisabled: boolean;
readonly tokenType: LogtoJwtTokenKeyType;
};
function CreateButton({ tokenType }: Props) {
function CreateButton({ isDisabled, tokenType }: Props) {
const link = getPagePath(tokenType, 'create');
const { navigate } = useTenantPathname();
const { show } = useConfirmModal();
@ -58,6 +59,7 @@ function CreateButton({ tokenType }: Props) {
<Button
type="primary"
title="jwt_claims.custom_jwt_create_button"
disabled={isDevFeaturesEnabled && isDisabled}
onClick={onCreateButtonClick}
/>
);

View file

@ -0,0 +1,52 @@
@use '@/scss/underscore' as _;
.inlineNotification {
padding: _.unit(3) _.unit(4);
font: var(--font-body-2);
display: flex;
border-radius: 8px;
align-items: center;
gap: _.unit(3);
&.shadow {
border: 1px solid var(--color-border);
box-shadow: var(--shadow-1);
}
.icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.content {
flex: 1;
overflow: hidden;
overflow-wrap: break-word;
// Cancel the margin from MDX generated paragraphs
&:has(> p) {
margin: _.unit(-4) 0;
}
}
&.info {
background: var(--color-info-container);
.icon {
color: var(--color-on-info-container);
}
}
&.alert {
background: var(--color-alert-container);
}
&.success {
background: var(--color-success-container);
}
&.error {
background: var(--color-error-container);
}
}

View file

@ -0,0 +1,48 @@
import classNames from 'classnames';
import { Trans, useTranslation } from 'react-i18next';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import Button from '@/ds-components/Button';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import styles from './index.module.scss';
type Props = {
readonly isVisible: boolean;
readonly className?: string;
};
function UpsellNotice({ isVisible, className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
if (!isVisible) {
return null;
}
return (
<div className={classNames(styles.inlineNotification, styles.info, styles.plain, className)}>
<div className={styles.content}>
<Trans
components={{
a: <ContactUsPhraseLink />,
}}
>
{t('upsell.paywall.custom_jwt.description')}
</Trans>
</div>
<div className={styles.action}>
<Button
title="upsell.upgrade_plan"
type="primary"
size="medium"
onClick={() => {
navigate('/tenant-settings/subscription');
}}
/>
</div>
</div>
);
}
export default UpsellNotice;

View file

@ -10,6 +10,10 @@
margin-bottom: _.unit(4);
}
.inlineNotice {
margin-bottom: _.unit(4);
}
.container {
display: flex;
flex-direction: column;

View file

@ -4,7 +4,7 @@ import { useCallback, useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
import { isCloud } from '@/consts/env';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle';
@ -13,6 +13,7 @@ import FormField from '@/ds-components/FormField';
import CreateButton from './CreateButton';
import CustomizerItem from './CustomizerItem';
import DeleteConfirmModal from './DeleteConfirmModal';
import UpsellNotice from './UpsellNotice';
import styles from './index.module.scss';
import useJwtCustomizer from './use-jwt-customizer';
@ -20,8 +21,15 @@ function CustomizeJwt() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { isDevTenant } = useContext(TenantsContext);
const { currentPlan } = useContext(SubscriptionDataContext);
const isCustomJwtEnabled = !isCloud || currentPlan.quota.customJwtEnabled;
const {
currentPlan,
currentSubscription: { planId },
currentSubscriptionQuota: { customJwtEnabled },
} = useContext(SubscriptionDataContext);
const isCustomJwtEnabled =
!isCloud || (isDevFeaturesEnabled ? customJwtEnabled : currentPlan.quota.customJwtEnabled);
const showPaywall = planId === ReservedPlanId.Free;
const [deleteModalTokenType, setDeleteModalTokenType] = useState<LogtoJwtTokenKeyType>();
@ -40,6 +48,9 @@ function CustomizeJwt() {
subtitle="jwt_claims.description"
className={styles.header}
/>
{isDevFeaturesEnabled && (
<UpsellNotice isVisible={showPaywall} className={styles.inlineNotice} />
)}
<div className={styles.container}>
{isLoading && (
<>
@ -60,7 +71,10 @@ function CustomizeJwt() {
onDelete={onDeleteHandler}
/>
) : (
<CreateButton tokenType={LogtoJwtTokenKeyType.AccessToken} />
<CreateButton
isDisabled={showPaywall}
tokenType={LogtoJwtTokenKeyType.AccessToken}
/>
)}
</FormField>
</FormCard>
@ -75,7 +89,10 @@ function CustomizeJwt() {
onDelete={onDeleteHandler}
/>
) : (
<CreateButton tokenType={LogtoJwtTokenKeyType.ClientCredentials} />
<CreateButton
isDisabled={showPaywall}
tokenType={LogtoJwtTokenKeyType.ClientCredentials}
/>
)}
</FormField>
</FormCard>

View file

@ -1,5 +1,9 @@
@use '@/scss/underscore' as _;
.strong {
font-weight: 500;
}
.textDivider {
font: var(--font-body-2);
color: var(--color-text-secondary);

View file

@ -1,8 +1,10 @@
import {
ReservedPlanId,
type RequestErrorBody,
type SsoConnectorProvidersResponse,
type SsoConnectorWithProviderConfig,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { HTTPError } from 'ky';
import { useContext, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
@ -10,6 +12,7 @@ import { Trans, useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import useSWR from 'swr';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import Skeleton from '@/components/CreateConnectorForm/Skeleton';
import { getConnectorRadioGroupSize } from '@/components/CreateConnectorForm/utils';
@ -21,6 +24,7 @@ import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import TextInput from '@/ds-components/TextInput';
import TextLink from '@/ds-components/TextLink';
import useApi, { type RequestError } from '@/hooks/use-api';
import modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
@ -42,7 +46,12 @@ const duplicateConnectorNameErrorCode = 'single_sign_on.duplicate_connector_name
function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const {
currentPlan,
logtoSkus,
currentSubscription: { planId },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const [selectedProviderName, setSelectedProviderName] = useState<string>();
const isSsoEnabled =
@ -51,6 +60,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
? currentSubscriptionQuota.enterpriseSsoLimit === null ||
currentSubscriptionQuota.enterpriseSsoLimit > 0
: currentPlan.quota.ssoEnabled);
const addOnUnitPrice = logtoSkus.find(({ id }) => id === planId)?.unitPrice ?? 0;
const { data, error } = useSWR<SsoConnectorProvidersResponse, RequestError>(
'api/sso-connector-providers'
@ -133,8 +143,31 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
>
<ModalLayout
title="enterprise_sso.create_modal.title"
paywall={conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ReservedPlanId.Pro
)}
footer={
isSsoEnabled ? (
conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && (
<AddOnNoticeFooter
buttonTitle="enterprise_sso.create_modal.create_button_text"
onClick={onSubmit}
>
<Trans
components={{
span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t('upsell.add_on.footer.enterprise_sso', {
price: Number(addOnUnitPrice) / 100,
planName: t('subscription.pro_plan'),
})}
</Trans>
</AddOnNoticeFooter>
)
) ??
(isSsoEnabled ? (
<Button
title="enterprise_sso.create_modal.create_button_text"
type="primary"
@ -157,7 +190,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) {
{t('upsell.paywall.sso_connectors')}
</Trans>
</QuotaGuardFooter>
)
))
}
size="xlarge"
onClose={onClose}

View file

@ -11,7 +11,7 @@ import EnterpriseSsoConnectorEmpty from '@/assets/images/sso-connector-empty.svg
import ItemPreview from '@/components/ItemPreview';
import ListPage from '@/components/ListPage';
import { defaultPageSize } from '@/consts';
import { isCloud } from '@/consts/env';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
@ -36,13 +36,21 @@ function EnterpriseSso() {
const { navigate } = useTenantPathname();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { isDevTenant } = useContext(TenantsContext);
const { currentPlan } = useContext(SubscriptionDataContext);
const {
currentPlan,
currentSubscription: { planId },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
page: 1,
});
const isSsoEnabled = !isCloud || currentPlan.quota.ssoEnabled;
const isSsoEnabled =
!isCloud ||
(isDevFeaturesEnabled
? currentSubscriptionQuota.enterpriseSsoLimit !== 0
: currentPlan.quota.ssoEnabled);
const url = buildUrl('api/sso-connectors', {
page: String(page),
@ -59,7 +67,9 @@ function EnterpriseSso() {
return (
<ListPage
title={{
paywall: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro),
paywall: isDevFeaturesEnabled
? conditional(planId === ReservedPlanId.Pro && ReservedPlanId.Pro)
: conditional((!isSsoEnabled || isDevTenant) && ReservedPlanId.Pro),
title: 'enterprise_sso.title',
subtitle: 'enterprise_sso.subtitle',
}}

View file

@ -0,0 +1,52 @@
import { ReservedPlanId } from '@logto/schemas';
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import InlineNotification from '@/ds-components/InlineNotification';
import TextLink from '@/ds-components/TextLink';
import useUserPreferences from '@/hooks/use-user-preferences';
type Props = {
readonly className?: string;
};
function UpsellNotice({ className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
currentSubscription: { planId },
logtoSkus,
} = useContext(SubscriptionDataContext);
const {
data: { mfaUpsellNoticeAcknowledged },
update,
} = useUserPreferences();
const addOnUnitPrice = logtoSkus.find(({ id }) => id === planId)?.unitPrice ?? 0;
if (planId !== ReservedPlanId.Pro || mfaUpsellNoticeAcknowledged) {
return null;
}
return (
<InlineNotification
action="general.got_it"
className={className}
onClick={() => {
void update({ mfaUpsellNoticeAcknowledged: true });
}}
>
<Trans
components={{
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t('upsell.add_on.mfa_inline_notification', {
price: Number(addOnUnitPrice) / 100,
planName: String(t('subscription.pro_plan')),
})}
</Trans>
</InlineNotification>
);
}
export default UpsellNotice;

View file

@ -1,5 +1,9 @@
@use '@/scss/underscore' as _;
.upsellNotice {
margin-bottom: _.unit(4);
}
.factorField {
display: flex;
flex-direction: column;

View file

@ -22,6 +22,7 @@ import { trySubmitSafe } from '@/utils/form';
import { type MfaConfigForm, type MfaConfig } from '../types';
import FactorLabel from './FactorLabel';
import UpsellNotice from './UpsellNotice';
import { policyOptionTitleMap } from './constants';
import styles from './index.module.scss';
import { convertMfaFormToConfig, convertMfaConfigToForm, validateBackupCodeFactor } from './utils';
@ -84,6 +85,7 @@ function MfaForm({ data, onMfaUpdated }: Props) {
return (
<>
<UpsellNotice className={styles.upsellNotice} />
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}

View file

@ -3,7 +3,7 @@ import { cond } from '@silverhand/essentials';
import { useContext, type ReactNode } from 'react';
import PageMeta from '@/components/PageMeta';
import { isCloud } from '@/consts/env';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle';
@ -16,14 +16,23 @@ type Props = {
function PageWrapper({ children }: Props) {
const { isDevTenant } = useContext(TenantsContext);
const { currentPlan } = useContext(SubscriptionDataContext);
const isMfaEnabled = !isCloud || currentPlan.quota.mfaEnabled;
const {
currentPlan,
currentSubscription: { planId },
currentSubscriptionQuota: { mfaEnabled },
} = useContext(SubscriptionDataContext);
const isMfaEnabled =
!isCloud || (isDevFeaturesEnabled ? mfaEnabled : currentPlan.quota.mfaEnabled);
return (
<div className={styles.container}>
<PageMeta titleKey="mfa.title" />
<CardTitle
paywall={cond((!isMfaEnabled || isDevTenant) && ReservedPlanId.Pro)}
paywall={
isDevFeaturesEnabled
? cond(planId === ReservedPlanId.Pro && ReservedPlanId.Pro)
: cond((!isMfaEnabled || isDevTenant) && ReservedPlanId.Pro)
}
title="mfa.title"
subtitle="mfa.description"
className={styles.cardTitle}

View file

@ -32,7 +32,11 @@ const basePathname = '/organization-template';
function OrganizationTemplate() {
const { getDocumentationUrl } = useDocumentationUrl();
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const {
currentPlan,
currentSubscription: { planId },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const { isDevTenant } = useContext(TenantsContext);
const isOrganizationsDisabled =
isCloud &&
@ -56,7 +60,11 @@ function OrganizationTemplate() {
href: getDocumentationUrl(organizationTemplateLink),
targetBlank: 'noopener',
}}
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)}
paywall={
isDevFeaturesEnabled
? cond(planId === ReservedPlanId.Pro && ReservedPlanId.Pro)
: cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)
}
/>
<Button
title="application_details.check_guide"

View file

@ -0,0 +1,3 @@
.strong {
font-weight: 500;
}

View file

@ -1,9 +1,11 @@
import { type Organization, type CreateOrganization } from '@logto/schemas';
import { type Organization, type CreateOrganization, ReservedPlanId } from '@logto/schemas';
import { cond, conditional } from '@silverhand/essentials';
import { useContext, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
@ -12,10 +14,13 @@ import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import TextInput from '@/ds-components/TextInput';
import TextLink from '@/ds-components/TextLink';
import useApi from '@/hooks/use-api';
import modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
import styles from './index.module.scss';
type Props = {
readonly isOpen: boolean;
readonly onClose: (createdId?: string) => void;
@ -24,12 +29,18 @@ type Props = {
function CreateOrganizationModal({ isOpen, onClose }: Props) {
const api = useApi();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const {
currentPlan,
currentSubscription: { planId },
logtoSkus,
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const isOrganizationsDisabled =
isCloud &&
!(isDevFeaturesEnabled
? currentSubscriptionQuota.organizationsEnabled
: currentPlan.quota.organizationsEnabled);
const addOnUnitPrice = logtoSkus.find(({ id }) => id === planId)?.unitPrice ?? 0;
const {
reset,
@ -66,8 +77,32 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
>
<ModalLayout
title="organizations.create_organization"
paywall={conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ReservedPlanId.Pro
)}
footer={
isOrganizationsDisabled ? (
cond(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && (
<AddOnNoticeFooter
isLoading={isSubmitting}
buttonTitle="general.create"
onClick={submit}
>
<Trans
components={{
span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t('upsell.add_on.footer.organization', {
price: Number(addOnUnitPrice) / 100,
planName: t('subscription.pro_plan'),
})}
</Trans>
</AddOnNoticeFooter>
)
) ??
(isOrganizationsDisabled ? (
<QuotaGuardFooter>
<Trans
components={{
@ -84,7 +119,7 @@ function CreateOrganizationModal({ isOpen, onClose }: Props) {
isLoading={isSubmitting}
onClick={submit}
/>
)
))
}
onClose={onClose}
>

View file

@ -25,7 +25,11 @@ const organizationsPathname = '/organizations';
function Organizations() {
const { getDocumentationUrl } = useDocumentationUrl();
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const {
currentPlan,
currentSubscription: { planId },
currentSubscriptionQuota,
} = useContext(SubscriptionDataContext);
const { isDevTenant } = useContext(TenantsContext);
const { navigate } = useTenantPathname();
@ -60,7 +64,11 @@ function Organizations() {
<PageMeta titleKey="organizations.page_title" />
<div className={pageLayout.headline}>
<CardTitle
paywall={cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)}
paywall={
isDevFeaturesEnabled
? cond(planId === ReservedPlanId.Pro && ReservedPlanId.Pro)
: cond((isOrganizationsDisabled || isDevTenant) && ReservedPlanId.Pro)
}
title="organizations.title"
subtitle="organizations.subtitle"
learnMoreLink={{

View file

@ -1,4 +1,5 @@
import { TenantRole } from '@logto/schemas';
import { ReservedPlanId, TenantRole } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
@ -6,10 +7,14 @@ import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
import AddOnNoticeFooter from '@/components/AddOnNoticeFooter';
import { isDevFeaturesEnabled } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import Select, { type Option } from '@/ds-components/Select';
import TextLink from '@/ds-components/TextLink';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import modalStyles from '@/scss/modal.module.scss';
@ -26,13 +31,18 @@ type Props = {
};
function InviteMemberModal({ isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentTenantId } = useContext(TenantsContext);
const [isLoading, setIsLoading] = useState(false);
const cloudApi = useAuthedCloudApi();
const { parseEmailOptions } = useEmailInputUtils();
const { show } = useConfirmModal();
const {
currentSubscription: { planId },
logtoSkus,
} = useContext(SubscriptionDataContext);
const addOnUnitPrice = logtoSkus.find(({ id }) => id === planId)?.unitPrice ?? 0;
const formMethods = useForm<InviteMemberForm>({
defaultValues: {
@ -57,8 +67,8 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const roleOptions: Array<Option<TenantRole>> = useMemo(
() => [
{ value: TenantRole.Admin, title: t('admin') },
{ value: TenantRole.Collaborator, title: t('collaborator') },
{ value: TenantRole.Admin, title: t('tenant_members.admin') },
{ value: TenantRole.Collaborator, title: t('tenant_members.collaborator') },
],
[t]
);
@ -68,7 +78,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const [result] = await show({
ModalContent: () => (
<Trans components={{ ul: <ul className={styles.list} />, li: <li /> }}>
{t('assign_admin_confirm')}
{t('tenant_members.assign_admin_confirm')}
</Trans>
),
confirmButtonText: 'general.confirm',
@ -89,7 +99,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
})
)
);
toast.success(t('messages.invitation_sent'));
toast.success(t('tenant_members.messages.invitation_sent'));
onClose(true);
} finally {
setIsLoading(false);
@ -108,13 +118,37 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
<ModalLayout
size="large"
title="tenant_members.invite_modal.title"
paywall={conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ReservedPlanId.Pro
)}
subtitle="tenant_members.invite_modal.subtitle"
footer={
<Footer
newInvitationCount={watch('emails').length}
isLoading={isLoading}
onSubmit={onSubmit}
/>
conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && (
<AddOnNoticeFooter
isLoading={isLoading}
buttonTitle="tenant_members.invite_members"
onClick={onSubmit}
>
<Trans
components={{
span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t('upsell.add_on.footer.tenant_members', {
price: Number(addOnUnitPrice) / 100,
})}
</Trans>
</AddOnNoticeFooter>
)
) ?? (
<Footer
newInvitationCount={watch('emails').length}
isLoading={isLoading}
onSubmit={onSubmit}
/>
)
}
onClose={onClose}
>
@ -126,7 +160,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
rules={{
validate: (value): string | true => {
if (value.length === 0) {
return t('errors.email_required');
return t('tenant_members.errors.email_required');
}
const { errorMessage } = parseEmailOptions(value);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
@ -137,7 +171,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
<InviteEmailsInput
values={value}
error={errors.emails?.message}
placeholder={t('invite_modal.email_input_placeholder')}
placeholder={t('tenant_members.invite_modal.email_input_placeholder')}
parseEmailOptions={parseEmailOptions}
onChange={onChange}
/>

View file

@ -62,3 +62,7 @@
padding-inline-start: _.unit(1);
}
}
.strong {
font-weight: 500;
}

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,18 @@
const add_on = {
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -1,3 +1,4 @@
import add_on from './add-on.js';
import featured_plan_content from './featured-plan-content.js';
import paywall from './paywall.js';
@ -41,6 +42,7 @@ const upsell = {
'You have surpassed your {{item}} quota limit. Logto will add charges for the usage beyond your quota limit. Charging will commence on the day the new add-on pricing design is released. <a>Learn more</a>',
paywall,
featured_plan_content,
add_on,
};
export default Object.freeze(upsell);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);

View file

@ -0,0 +1,24 @@
const add_on = {
/** UNTRANSLATED */
mfa_inline_notification:
'MFA is a ${{price, number}} per mo add-on for the {{planName}} plan. First month prorated based on your billing cycle. <a>Learn more</a>',
footer: {
/** UNTRANSLATED */
api_resource:
'Additional resources cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
machine_to_machine_app:
'Additional machine-to-machine apps cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
enterprise_sso:
'Enterprise SSO cost <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};
export default Object.freeze(add_on);