diff --git a/packages/connectors/connector-logto-email/package.json b/packages/connectors/connector-logto-email/package.json
index 10d2a7a91..8eeb46735 100644
--- a/packages/connectors/connector-logto-email/package.json
+++ b/packages/connectors/connector-logto-email/package.json
@@ -48,6 +48,6 @@
"access": "public"
},
"devDependencies": {
- "@logto/cloud": "0.2.5-1807f9c"
+ "@logto/cloud": "0.2.5-ab8a489"
}
}
diff --git a/packages/console/package.json b/packages/console/package.json
index b28527bfe..d9b9d59af 100644
--- a/packages/console/package.json
+++ b/packages/console/package.json
@@ -28,7 +28,7 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.4.0",
- "@logto/cloud": "0.2.5-1807f9c",
+ "@logto/cloud": "0.2.5-ab8a489",
"@logto/connector-kit": "workspace:^2.1.0",
"@logto/core-kit": "workspace:^2.3.0",
"@logto/language-kit": "workspace:^1.1.0",
diff --git a/packages/console/src/consts/quota-item-phrases.ts b/packages/console/src/consts/quota-item-phrases.ts
index 41ae37a20..a4ade8a7a 100644
--- a/packages/console/src/consts/quota-item-phrases.ts
+++ b/packages/console/src/consts/quota-item-phrases.ts
@@ -26,6 +26,7 @@ export const quotaItemPhrasesMap: Record<
mfaEnabled: 'mfa_enabled.name',
organizationsEnabled: 'organizations_enabled.name',
ssoEnabled: 'sso_enabled.name',
+ tenantMembersLimit: 'tenant_members_limit.name',
};
export const quotaItemUnlimitedPhrasesMap: Record<
@@ -52,6 +53,7 @@ export const quotaItemUnlimitedPhrasesMap: Record<
mfaEnabled: 'mfa_enabled.unlimited',
organizationsEnabled: 'organizations_enabled.unlimited',
ssoEnabled: 'sso_enabled.unlimited',
+ tenantMembersLimit: 'tenant_members_limit.unlimited',
};
export const quotaItemLimitedPhrasesMap: Record<
@@ -78,6 +80,7 @@ export const quotaItemLimitedPhrasesMap: Record<
mfaEnabled: 'mfa_enabled.limited',
organizationsEnabled: 'organizations_enabled.limited',
ssoEnabled: 'sso_enabled.limited',
+ tenantMembersLimit: 'tenant_members_limit.limited',
};
export const quotaItemNotEligiblePhrasesMap: Record<
@@ -104,4 +107,5 @@ export const quotaItemNotEligiblePhrasesMap: Record<
mfaEnabled: 'mfa_enabled.not_eligible',
organizationsEnabled: 'organizations_enabled.not_eligible',
ssoEnabled: 'sso_enabled.not_eligible',
+ tenantMembersLimit: 'tenant_members_limit.not_eligible',
};
diff --git a/packages/console/src/consts/tenants.ts b/packages/console/src/consts/tenants.ts
index fc54f4c98..119a2678b 100644
--- a/packages/console/src/consts/tenants.ts
+++ b/packages/console/src/consts/tenants.ts
@@ -66,6 +66,7 @@ export const defaultSubscriptionPlan: SubscriptionPlan = {
ssoEnabled: true,
ticketSupportResponseTime: 48,
thirdPartyApplicationsLimit: null,
+ tenantMembersLimit: null,
},
};
diff --git a/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx b/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx
index d69291f2d..e7c362920 100644
--- a/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx
+++ b/packages/console/src/pages/TenantSettings/Subscription/PlanComparisonTable/index.tsx
@@ -119,13 +119,15 @@ function PlanComparisonTable() {
const orgPermissions = t('organizations.org_permissions');
const jitProvisioning = t('organizations.just_in_time_provisioning');
- // Audit logs
- const auditLogRetention = t('audit_logs.retention');
+ // Developers and platform
+ const webhooks = t('developers_and_platform.hooks');
+ const auditLogRetention = t('developers_and_platform.audit_logs_retention');
const freePlanLogRetention = t('days', { count: freePlanAuditLogsRetentionDays });
const paidPlanLogRetention = t('days', { count: proPlanAuditLogsRetentionDays });
-
- // Webhooks
- const webhooks = t('hooks.hooks');
+ const jwtClaims = t('developers_and_platform.jwt_claims');
+ const tenantMembers = t('developers_and_platform.tenant_members');
+ const tenantMembersLimit = t('included', { value: 3 });
+ const tenantMembersPrice = t('per_member', { value: 8 });
// Compliance and support
const community = t('support.community');
@@ -228,15 +230,21 @@ function PlanComparisonTable() {
],
},
{
- title: 'audit_logs.title',
+ title: 'developers_and_platform.title',
rows: [
+ { name: webhooks, data: ['1', '10', contact] },
{ name: auditLogRetention, data: [freePlanLogRetention, paidPlanLogRetention, contact] },
+ { name: jwtClaims, data: ['✓', '✓', '✓'] },
+ {
+ name: tenantMembers,
+ data: [
+ '1',
+ `${tenantMembersLimit}|${paidAddOnFeatureTip}|${tenantMembersPrice}`,
+ contact,
+ ],
+ },
],
},
- {
- title: 'hooks.title',
- rows: [{ name: webhooks, data: ['1', '10', contact] }],
- },
{
title: 'support.title',
rows: [
@@ -275,8 +283,9 @@ function PlanComparisonTable() {
|
- {data.map((value) => (
-
+ {data.map((value, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ |
|
))}
diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/Footer/index.module.scss b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/Footer/index.module.scss
new file mode 100644
index 000000000..c085bcdad
--- /dev/null
+++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/Footer/index.module.scss
@@ -0,0 +1,16 @@
+@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);
+
+ .description {
+ flex: 1;
+ flex-shrink: 0;
+ font: var(--font-body-2);
+ }
+}
diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/Footer/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/Footer/index.tsx
new file mode 100644
index 000000000..dca3c0187
--- /dev/null
+++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/Footer/index.tsx
@@ -0,0 +1,74 @@
+import { ReservedPlanId } from '@logto/schemas';
+import { useContext } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+
+import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
+import QuotaGuardFooter from '@/components/QuotaGuardFooter';
+import { contactEmailLink } from '@/consts';
+import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
+import Button, { LinkButton } from '@/ds-components/Button';
+
+import useTenantMembersUsage from '../../hooks';
+
+import * as styles from './index.module.scss';
+
+type Props = {
+ newInvitationCount?: number;
+ isLoading: boolean;
+ onSubmit: () => void;
+};
+
+function Footer({ newInvitationCount = 0, isLoading, onSubmit }: Props) {
+ const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' });
+
+ const { currentPlan } = useContext(SubscriptionDataContext);
+ const { id: planId, quota } = currentPlan;
+
+ const { hasTenantMembersReachedLimit, limit, usage } = useTenantMembersUsage();
+
+ if (planId === ReservedPlanId.Free && hasTenantMembersReachedLimit) {
+ return (
+
+ ,
+ }}
+ >
+ {t('tenant_members')}
+
+
+ );
+ }
+
+ if (
+ planId === ReservedPlanId.Development &&
+ (hasTenantMembersReachedLimit || usage + newInvitationCount > limit)
+ ) {
+ // Display a custom "Contact us" footer instead of asking for upgrade
+ return (
+
+
+ {t('tenant_members_dev_plan', { limit: quota.tenantMembersLimit })}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default Footer;
diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx
index 844a2a420..5aa224167 100644
--- a/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx
+++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx
@@ -1,4 +1,4 @@
-import { ReservedPlanId, TenantRole } from '@logto/schemas';
+import { TenantRole } from '@logto/schemas';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
@@ -6,9 +6,7 @@ import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
-import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
-import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import Select, { type Option } from '@/ds-components/Select';
@@ -18,6 +16,8 @@ import InviteEmailsInput from '../InviteEmailsInput';
import useEmailInputUtils from '../InviteEmailsInput/hooks';
import { type InviteMemberForm } from '../types';
+import Footer from './Footer';
+
type Props = {
isOpen: boolean;
onClose: (isSuccessful?: boolean) => void;
@@ -25,10 +25,7 @@ type Props = {
function InviteMemberModal({ isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
- const { currentPlan } = useContext(SubscriptionDataContext);
const { currentTenantId } = useContext(TenantsContext);
- // TODO: @charles update with actual quota guard later
- const tenantMembersMaxLimit = currentPlan.id === ReservedPlanId.Free ? 1 : 3;
const [isLoading, setIsLoading] = useState(false);
const cloudApi = useAuthedCloudApi();
@@ -44,8 +41,8 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const {
control,
handleSubmit,
- setError,
reset,
+ watch,
formState: { errors },
} = formMethods;
@@ -66,22 +63,6 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const onSubmit = handleSubmit(async ({ emails, role }) => {
setIsLoading(true);
try {
- // Do not check seats for Pro plan for now
- if (currentPlan.id === ReservedPlanId.Free || currentPlan.id === ReservedPlanId.Development) {
- // Count the current tenant members
- const members = await cloudApi.get(`/api/tenants/:tenantId/members`, {
- params: { tenantId: currentTenantId },
- });
- // Check if it will exceed the tenant member limit
- if (emails.length + members.length > tenantMembersMaxLimit) {
- setError('emails', {
- type: 'custom',
- message: t('errors.max_member_limit', { limit: tenantMembersMaxLimit }),
- });
- return;
- }
- }
-
await Promise.all(
emails.map(async (email) =>
cloudApi.post('/api/tenants/:tenantId/invitations', {
@@ -107,14 +88,14 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
}}
>
}
onClose={onClose}
diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/hooks.ts b/packages/console/src/pages/TenantSettings/TenantMembers/hooks.ts
new file mode 100644
index 000000000..980268115
--- /dev/null
+++ b/packages/console/src/pages/TenantSettings/TenantMembers/hooks.ts
@@ -0,0 +1,66 @@
+import { OrganizationInvitationStatus } from '@logto/schemas';
+import { useContext, useMemo } from 'react';
+import useSWR from 'swr';
+
+import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
+import { type TenantInvitationResponse, type TenantMemberResponse } from '@/cloud/types/router';
+import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
+import { TenantsContext } from '@/contexts/TenantsProvider';
+import { type RequestError } from '@/hooks/use-api';
+import { hasReachedQuotaLimit, hasSurpassedQuotaLimit } from '@/utils/quota';
+
+const useTenantMembersUsage = () => {
+ const { currentPlan } = useContext(SubscriptionDataContext);
+ const { currentTenantId } = useContext(TenantsContext);
+
+ const cloudApi = useAuthedCloudApi();
+
+ const { data: members } = useSWR(
+ `api/tenants/:tenantId/members`,
+ async () =>
+ cloudApi.get('/api/tenants/:tenantId/members', { params: { tenantId: currentTenantId } })
+ );
+ const { data: invitations } = useSWR(
+ 'api/tenants/:tenantId/invitations',
+ async () =>
+ cloudApi.get('/api/tenants/:tenantId/invitations', { params: { tenantId: currentTenantId } })
+ );
+
+ const pendingInvitations = useMemo(
+ () => invitations?.filter(({ status }) => status === OrganizationInvitationStatus.Pending),
+ [invitations]
+ );
+
+ const usage = useMemo(() => {
+ return (members?.length ?? 0) + (pendingInvitations?.length ?? 0);
+ }, [members?.length, pendingInvitations?.length]);
+
+ const hasTenantMembersReachedLimit = useMemo(
+ () =>
+ hasReachedQuotaLimit({
+ quotaKey: 'tenantMembersLimit',
+ plan: currentPlan,
+ usage,
+ }),
+ [currentPlan, usage]
+ );
+
+ const hasTenantMembersSurpassedLimit = useMemo(
+ () =>
+ hasSurpassedQuotaLimit({
+ quotaKey: 'tenantMembersLimit',
+ plan: currentPlan,
+ usage,
+ }),
+ [currentPlan, usage]
+ );
+
+ return {
+ hasTenantMembersReachedLimit,
+ hasTenantMembersSurpassedLimit,
+ usage,
+ limit: currentPlan.quota.tenantMembersLimit ?? Number.POSITIVE_INFINITY,
+ };
+};
+
+export default useTenantMembersUsage;
diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/index.module.scss b/packages/console/src/pages/TenantSettings/TenantMembers/index.module.scss
index 582a82af7..f90c51781 100644
--- a/packages/console/src/pages/TenantSettings/TenantMembers/index.module.scss
+++ b/packages/console/src/pages/TenantSettings/TenantMembers/index.module.scss
@@ -5,6 +5,10 @@
flex-direction: column;
gap: _.unit(4);
+ .chargeNotification {
+ margin: _.unit(4) 0 0;
+ }
+
.tabButtons {
display: flex;
align-items: center;
diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/index.tsx
index f2dd90960..24aff9b71 100644
--- a/packages/console/src/pages/TenantSettings/TenantMembers/index.tsx
+++ b/packages/console/src/pages/TenantSettings/TenantMembers/index.tsx
@@ -7,6 +7,7 @@ import InvitationIcon from '@/assets/icons/invitation.svg';
import MembersIcon from '@/assets/icons/members.svg';
import PlusIcon from '@/assets/icons/plus.svg';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
+import ChargeNotification from '@/components/ChargeNotification';
import { TenantSettingsTabs } from '@/consts';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
@@ -18,11 +19,13 @@ import NotFound from '@/pages/NotFound';
import Invitations from './Invitations';
import InviteMemberModal from './InviteMemberModal';
import Members from './Members';
+import useTenantMembersUsage from './hooks';
import * as styles from './index.module.scss';
const invitationsRoute = 'invitations';
function TenantMembers() {
+ const { hasTenantMembersSurpassedLimit } = useTenantMembersUsage();
const { navigate, match } = useTenantPathname();
const [showInviteModal, setShowInviteModal] = useState(false);
const { canInviteMember } = useCurrentTenantScopes();
@@ -41,6 +44,12 @@ function TenantMembers() {
return (
+