mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): apply quota limit for roles (#4189)
This commit is contained in:
parent
b4d0995901
commit
591f78f743
11 changed files with 140 additions and 46 deletions
|
@ -13,7 +13,7 @@ import { ReservedPlanId } from '@/consts/subscriptions';
|
|||
import Button from '@/ds-components/Button';
|
||||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import { type ConnectorGroup } from '@/types/connector';
|
||||
import { isOverQuota } from '@/utils/quota';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
type Props = {
|
||||
isCreatingSocialConnector: boolean;
|
||||
|
@ -49,13 +49,13 @@ function Footer({
|
|||
[existingConnectors]
|
||||
);
|
||||
|
||||
const isStandardConnectorsOverQuota = isOverQuota({
|
||||
const isStandardConnectorsReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'standardConnectorsLimit',
|
||||
plan: currentPlan,
|
||||
usage: standardConnectorCount,
|
||||
});
|
||||
|
||||
const isSocialConnectorsOverQuota = isOverQuota({
|
||||
const isSocialConnectorsReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'socialConnectorsLimit',
|
||||
plan: currentPlan,
|
||||
usage: socialConnectorCount,
|
||||
|
@ -64,7 +64,7 @@ function Footer({
|
|||
if (isCreatingSocialConnector && currentPlan && selectedConnectorGroup) {
|
||||
const { id: planId, name: planName, quota } = currentPlan;
|
||||
|
||||
if (isStandardConnectorsOverQuota && selectedConnectorGroup.isStandard) {
|
||||
if (isStandardConnectorsReachLimit && selectedConnectorGroup.isStandard) {
|
||||
return (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
|
@ -86,7 +86,7 @@ function Footer({
|
|||
);
|
||||
}
|
||||
|
||||
if (isSocialConnectorsOverQuota && !selectedConnectorGroup.isStandard) {
|
||||
if (isSocialConnectorsReachLimit && !selectedConnectorGroup.isStandard) {
|
||||
return (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
|
|
|
@ -15,7 +15,7 @@ import useApi from '@/hooks/use-api';
|
|||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { isOverQuota } from '@/utils/quota';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
type Props = {
|
||||
resourceId: string;
|
||||
|
@ -50,7 +50,7 @@ function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Prop
|
|||
})
|
||||
);
|
||||
|
||||
const isScopesPerResourceOverQuota = isOverQuota({
|
||||
const isScopesPerResourceReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'scopesPerResourceLimit',
|
||||
plan: currentPlan,
|
||||
usage: totalResourceCount,
|
||||
|
@ -71,7 +71,7 @@ function CreatePermissionModal({ resourceId, totalResourceCount, onClose }: Prop
|
|||
subtitle="api_resource_details.permission.create_subtitle"
|
||||
learnMoreLink="https://docs.logto.io/docs/recipes/rbac/manage-permissions-and-roles#manage-role-permissions"
|
||||
footer={
|
||||
isScopesPerResourceOverQuota && currentPlan ? (
|
||||
isScopesPerResourceReachLimit && currentPlan ? (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
components={{
|
||||
|
|
|
@ -16,7 +16,7 @@ import TextLink from '@/ds-components/TextLink';
|
|||
import useApi from '@/hooks/use-api';
|
||||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { isOverQuota } from '@/utils/quota';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
|
@ -44,7 +44,7 @@ function CreateForm({ onClose }: Props) {
|
|||
const resourceCount =
|
||||
allResources?.filter(({ indicator }) => !isManagementApi(indicator)).length ?? 0;
|
||||
|
||||
const isResourcesOverQuota = isOverQuota({
|
||||
const isResourcesReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'resourcesLimit',
|
||||
plan: currentPlan,
|
||||
usage: resourceCount,
|
||||
|
@ -68,7 +68,7 @@ function CreateForm({ onClose }: Props) {
|
|||
title="api_resources.create"
|
||||
subtitle="api_resources.subtitle"
|
||||
footer={
|
||||
isResourcesOverQuota && currentPlan ? (
|
||||
isResourcesReachLimit && currentPlan ? (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
components={{
|
||||
|
|
|
@ -10,7 +10,7 @@ import { isProduction } from '@/consts/env';
|
|||
import { ReservedPlanId } from '@/consts/subscriptions';
|
||||
import Button from '@/ds-components/Button';
|
||||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import { isOverQuota } from '@/utils/quota';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
type Props = {
|
||||
selectedType?: ApplicationType;
|
||||
|
@ -34,13 +34,13 @@ function Footer({ selectedType, isLoading, onClickCreate }: Props) {
|
|||
|
||||
const nonM2mApplicationCount = allApplications ? allApplications.length - m2mAppCount : 0;
|
||||
|
||||
const isM2mAppsOverQuota = isOverQuota({
|
||||
const isM2mAppsReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'machineToMachineLimit',
|
||||
plan: currentPlan,
|
||||
usage: m2mAppCount,
|
||||
});
|
||||
|
||||
const isNonM2mAppsOverQuota = isOverQuota({
|
||||
const isNonM2mAppsReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'applicationsLimit',
|
||||
plan: currentPlan,
|
||||
usage: nonM2mApplicationCount,
|
||||
|
@ -49,7 +49,7 @@ function Footer({ selectedType, isLoading, onClickCreate }: Props) {
|
|||
if (currentPlan && selectedType) {
|
||||
const { id: planId, name: planName, quota } = currentPlan;
|
||||
|
||||
if (selectedType === ApplicationType.MachineToMachine && isM2mAppsOverQuota) {
|
||||
if (selectedType === ApplicationType.MachineToMachine && isM2mAppsReachLimit) {
|
||||
return (
|
||||
<QuotaGuardFooter>
|
||||
{quota.machineToMachineLimit === 0 && planId === ReservedPlanId.free ? (
|
||||
|
@ -74,7 +74,7 @@ function Footer({ selectedType, isLoading, onClickCreate }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
if (selectedType !== ApplicationType.MachineToMachine && isNonM2mAppsOverQuota) {
|
||||
if (selectedType !== ApplicationType.MachineToMachine && isNonM2mAppsReachLimit) {
|
||||
return (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
|
|
|
@ -1,24 +1,30 @@
|
|||
import type { ScopeResponse } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||
import RoleScopesTransfer from '@/components/RoleScopesTransfer';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
type Props = {
|
||||
roleId: string;
|
||||
totalRoleScopeCount: number;
|
||||
onClose: (success?: boolean) => void;
|
||||
};
|
||||
|
||||
function AssignPermissionsModal({ roleId, onClose }: Props) {
|
||||
function AssignPermissionsModal({ roleId, totalRoleScopeCount, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const { data: currentPlan } = useCurrentSubscriptionPlan();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [scopes, setScopes] = useState<ScopeResponse[]>([]);
|
||||
|
||||
|
@ -42,6 +48,18 @@ function AssignPermissionsModal({ roleId, onClose }: Props) {
|
|||
}
|
||||
};
|
||||
|
||||
const shouldBlockScopeAssignment = hasReachedQuotaLimit({
|
||||
quotaKey: 'scopesPerRoleLimit',
|
||||
plan: currentPlan,
|
||||
/**
|
||||
* If usage is equal to the limit, it means the current role has reached the maximum allowed scope.
|
||||
* Therefore, we should not assign any more scopes at this point.
|
||||
* However, the currently selected scopes haven't been assigned yet, so we subtract 1
|
||||
* to allow the assignment when the scope count is equal to the limit.
|
||||
*/
|
||||
usage: totalRoleScopeCount + scopes.length - 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen
|
||||
|
@ -58,15 +76,30 @@ function AssignPermissionsModal({ roleId, onClose }: Props) {
|
|||
learnMoreLink="https://docs.logto.io/docs/recipes/rbac/manage-permissions-and-roles#manage-role-permissions"
|
||||
size="large"
|
||||
footer={
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
disabled={scopes.length === 0}
|
||||
htmlType="submit"
|
||||
title="role_details.permission.confirm_assign"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={handleAssign}
|
||||
/>
|
||||
shouldBlockScopeAssignment && currentPlan ? (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={currentPlan.name} />,
|
||||
}}
|
||||
>
|
||||
{t('upsell.paywall.scopes_per_role', {
|
||||
count: currentPlan.quota.scopesPerRoleLimit ?? 0,
|
||||
})}
|
||||
</Trans>
|
||||
</QuotaGuardFooter>
|
||||
) : (
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
disabled={scopes.length === 0}
|
||||
htmlType="submit"
|
||||
title="role_details.permission.confirm_assign"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={handleAssign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
|
|
|
@ -114,9 +114,10 @@ function RolePermissions() {
|
|||
{t('role_details.permission.deletion_description')}
|
||||
</ConfirmModal>
|
||||
)}
|
||||
{isAssignPermissionsModalOpen && (
|
||||
{isAssignPermissionsModalOpen && totalCount !== undefined && (
|
||||
<AssignPermissionsModal
|
||||
roleId={roleId}
|
||||
totalRoleScopeCount={totalCount}
|
||||
onClose={(success) => {
|
||||
if (success) {
|
||||
void mutate();
|
||||
|
|
|
@ -2,8 +2,11 @@ import type { Role, ScopeResponse } from '@logto/schemas';
|
|||
import { internalRolePrefix } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||
import RoleScopesTransfer from '@/components/RoleScopesTransfer';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
|
@ -11,9 +14,12 @@ import ModalLayout from '@/ds-components/ModalLayout';
|
|||
import TextInput from '@/ds-components/TextInput';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useConfigs from '@/hooks/use-configs';
|
||||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
export type Props = {
|
||||
totalRoleCount: number;
|
||||
onClose: (createdRole?: Role) => void;
|
||||
};
|
||||
|
||||
|
@ -25,17 +31,20 @@ type CreateRolePayload = Pick<Role, 'name' | 'description'> & {
|
|||
scopeIds?: string[];
|
||||
};
|
||||
|
||||
function CreateRoleForm({ onClose }: Props) {
|
||||
function CreateRoleForm({ totalRoleCount, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data: currentPlan } = useCurrentSubscriptionPlan();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<CreateRoleFormData>();
|
||||
|
||||
const api = useApi();
|
||||
const { updateConfigs } = useConfigs();
|
||||
const roleScopes = watch('scopes', []);
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async ({ name, description, scopes }) => {
|
||||
|
@ -55,6 +64,24 @@ function CreateRoleForm({ onClose }: Props) {
|
|||
})
|
||||
);
|
||||
|
||||
const isRolesReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'rolesLimit',
|
||||
plan: currentPlan,
|
||||
usage: totalRoleCount,
|
||||
});
|
||||
|
||||
const isScopesPerReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'scopesPerRoleLimit',
|
||||
plan: currentPlan,
|
||||
/**
|
||||
* If usage is equal to the limit, it means the current role has reached the maximum allowed scope.
|
||||
* Therefore, we should not assign any more scopes at this point.
|
||||
* However, the currently selected scopes haven't been assigned yet, so we subtract 1
|
||||
* to allow the assignment when the scope count is equal to the limit.
|
||||
*/
|
||||
usage: roleScopes.length - 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalLayout
|
||||
title="roles.create_role_title"
|
||||
|
@ -62,14 +89,44 @@ function CreateRoleForm({ onClose }: Props) {
|
|||
learnMoreLink="https://docs.logto.io/docs/recipes/rbac/manage-permissions-and-roles#manage-roles"
|
||||
size="large"
|
||||
footer={
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
title="roles.create_role_button"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
<>
|
||||
{isRolesReachLimit && currentPlan && (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={currentPlan.name} />,
|
||||
}}
|
||||
>
|
||||
{t('upsell.paywall.roles', { count: currentPlan.quota.rolesLimit })}
|
||||
</Trans>
|
||||
</QuotaGuardFooter>
|
||||
)}
|
||||
{isScopesPerReachLimit && currentPlan && !isRolesReachLimit && (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={currentPlan.name} />,
|
||||
}}
|
||||
>
|
||||
{t('upsell.paywall.scopes_per_role', {
|
||||
count: currentPlan.quota.scopesPerRoleLimit ?? 0,
|
||||
})}
|
||||
</Trans>
|
||||
</QuotaGuardFooter>
|
||||
)}
|
||||
{!isRolesReachLimit && !isScopesPerReachLimit && (
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
title="roles.create_role_button"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
|
|
|
@ -12,10 +12,11 @@ import type { Props as CreateRoleFormProps } from '../CreateRoleForm';
|
|||
import CreateRoleForm from '../CreateRoleForm';
|
||||
|
||||
type Props = {
|
||||
totalRoleCount: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function CreateRoleModal({ onClose }: Props) {
|
||||
function CreateRoleModal({ totalRoleCount, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -49,7 +50,7 @@ function CreateRoleModal({ onClose }: Props) {
|
|||
}}
|
||||
/>
|
||||
) : (
|
||||
<CreateRoleForm onClose={onCreateFormClose} />
|
||||
<CreateRoleForm totalRoleCount={totalRoleCount} onClose={onCreateFormClose} />
|
||||
)}
|
||||
</ReactModal>
|
||||
);
|
||||
|
|
|
@ -145,8 +145,10 @@ function Roles() {
|
|||
onRetry: async () => mutate(undefined, true),
|
||||
}}
|
||||
widgets={
|
||||
isOnCreatePage && (
|
||||
isOnCreatePage &&
|
||||
totalCount !== undefined && (
|
||||
<CreateRoleModal
|
||||
totalRoleCount={totalCount}
|
||||
onClose={() => {
|
||||
navigate({ pathname: rolesPathname, search });
|
||||
}}
|
||||
|
|
|
@ -10,7 +10,7 @@ import ModalLayout from '@/ds-components/ModalLayout';
|
|||
import useApi from '@/hooks/use-api';
|
||||
import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { isOverQuota } from '@/utils/quota';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
import { type BasicWebhookFormType } from '../../types';
|
||||
import BasicWebhookForm from '../BasicWebhookForm';
|
||||
|
@ -31,7 +31,7 @@ function CreateForm({ totalWebhookCount, onClose }: Props) {
|
|||
const { data: currentPlan } = useCurrentSubscriptionPlan();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const shouldBlockCreation = isOverQuota({
|
||||
const shouldBlockCreation = hasReachedQuotaLimit({
|
||||
quotaKey: 'hooksLimit',
|
||||
usage: totalWebhookCount,
|
||||
plan: currentPlan,
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { isCloud, isProduction } from '@/consts/env';
|
||||
import { type SubscriptionPlan, type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||
|
||||
type IsOverQuotaParameters = {
|
||||
type HasReachedQuotaLimitParameters = {
|
||||
quotaKey: keyof SubscriptionPlanQuota;
|
||||
usage: number;
|
||||
plan?: SubscriptionPlan;
|
||||
};
|
||||
|
||||
export const isOverQuota = ({ quotaKey, usage, plan }: IsOverQuotaParameters) => {
|
||||
export const hasReachedQuotaLimit = ({ quotaKey, usage, plan }: HasReachedQuotaLimitParameters) => {
|
||||
/**
|
||||
* Todo: @xiaoyijun remove this condition on subscription features ready.
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue