Skip to content

Commit

Permalink
feat(console,core,phrases): add quota guard for cloud collaboration i…
Browse files Browse the repository at this point in the history
…n console
  • Loading branch information
charIeszhao committed Apr 7, 2024
1 parent 3160b40 commit febb18f
Show file tree
Hide file tree
Showing 75 changed files with 736 additions and 191 deletions.
2 changes: 1 addition & 1 deletion packages/connectors/connector-logto-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@
"access": "public"
},
"devDependencies": {
"@logto/cloud": "0.2.5-1807f9c"
"@logto/cloud": "0.2.5-ab8a489"
}
}
2 changes: 1 addition & 1 deletion packages/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/console/src/consts/quota-item-phrases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -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<
Expand All @@ -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<
Expand All @@ -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',
};
1 change: 1 addition & 0 deletions packages/console/src/consts/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const defaultSubscriptionPlan: SubscriptionPlan = {
ssoEnabled: true,
ticketSupportResponseTime: 48,
thirdPartyApplicationsLimit: null,
tenantMembersLimit: null,
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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: [
Expand All @@ -263,27 +271,34 @@ function PlanComparisonTable() {
</tr>
</thead>
<tbody>
{tables.map(({ title, rows }) => (
<Fragment key={title}>
<tr>
<td colSpan={4} className={styles.groupLabel}>
<DynamicT forKey={`subscription.quota_table.${title}`} />
</td>
</tr>
{rows.map(({ name, data }) => (
<tr key={`${title}-${name}`}>
<td className={styles.quotaKeyColumn}>
<TableDataWrapper isLeftAligned value={name} />
{tables.map(({ title, rows }) => {
console.log('title:', title);
console.log('rows:', rows);
return (
<Fragment key={title}>
<tr>
<td colSpan={4} className={styles.groupLabel}>
<DynamicT forKey={`subscription.quota_table.${title}`} />
</td>
{data.map((value) => (
<td key={value}>
<TableDataWrapper value={value} />
</td>
))}
</tr>
))}
</Fragment>
))}
{rows.map(({ name, data }) => {
console.log(`${title}-${name}`);
return (
<tr key={`${title}-${name}`}>
<td className={styles.quotaKeyColumn}>
<TableDataWrapper isLeftAligned value={name} />
</td>
{data.map((value) => (
<td key={value}>
<TableDataWrapper value={value} />
</td>
))}
</tr>
);
})}
</Fragment>
);
})}
</tbody>
</table>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<QuotaGuardFooter>
<Trans
components={{
a: <ContactUsPhraseLink />,
}}
>
{t('tenant_members')}
</Trans>
</QuotaGuardFooter>
);
}

if (
planId === ReservedPlanId.Development &&
(hasTenantMembersReachedLimit || usage + newInvitationCount > limit)
) {
// Display a custom "Contact us" footer instead of asking for upgrade
return (
<div className={styles.container}>
<div className={styles.description}>
{t('tenant_members_dev_plan', { limit: quota.tenantMembersLimit })}
</div>
<LinkButton
size="large"
type="primary"
title="general.contact_us_action"
href={contactEmailLink}
/>
</div>
);
}

return (
<Button
size="large"
type="primary"
title="tenant_members.invite_members"
isLoading={isLoading}
onClick={onSubmit}
/>
);
}

export default Footer;
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
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';
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';
Expand All @@ -18,17 +16,16 @@ 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;
};

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();
Expand All @@ -44,8 +41,8 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const {
control,
handleSubmit,
setError,
reset,
watch,
formState: { errors },
} = formMethods;

Expand All @@ -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', {
Expand All @@ -107,14 +88,14 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
}}
>
<ModalLayout
size="large"
title="tenant_members.invite_modal.title"
subtitle="tenant_members.invite_modal.subtitle"
footer={
<Button
size="large"
type="primary"
title="tenant_members.invite_members"
<Footer
newInvitationCount={watch('emails').length}
isLoading={isLoading}
onClick={onSubmit}
onSubmit={onSubmit}
/>
}
onClose={onClose}
Expand Down
Loading

0 comments on commit febb18f

Please sign in to comment.