0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): should limit only the number of free tenant (#4205)

* fix(console): update free tenant limit

* fix(console): use TenantResponse type

* chore(console): update dependency on @logto/cloud

* chore: remove import as
This commit is contained in:
Darcy Ye 2023-07-24 14:40:47 +08:00 committed by GitHub
parent 3ef8c06d4a
commit 350d070ef7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 71 additions and 70 deletions

View file

@ -26,7 +26,7 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.3.1",
"@logto/cloud": "0.2.5-2087c06",
"@logto/cloud": "0.2.5-70aa370",
"@logto/connector-kit": "workspace:^1.1.1",
"@logto/core-kit": "workspace:^2.0.1",
"@logto/language-kit": "workspace:^1.0.0",
@ -60,7 +60,7 @@
"@types/react-helmet": "^6.1.6",
"@types/react-modal": "^3.13.1",
"@types/react-syntax-highlighter": "^15.5.1",
"@withtyped/client": "^0.7.19",
"@withtyped/client": "^0.7.21",
"buffer": "^5.7.1",
"classnames": "^2.3.1",
"clean-deep": "^3.4.0",

View file

@ -1,11 +1,11 @@
import { Theme } from '@logto/schemas';
import type { TenantInfo } from '@logto/schemas/models';
import classNames from 'classnames';
import { useContext, useState } from 'react';
import Plus from '@/assets/icons/plus.svg';
import TenantLandingPageImageDark from '@/assets/images/tenant-landing-page-dark.svg';
import TenantLandingPageImage from '@/assets/images/tenant-landing-page.svg';
import { type TenantResponse } from '@/cloud/types/router';
import CreateTenantModal from '@/components/CreateTenantModal';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
@ -54,7 +54,7 @@ function TenantLandingPageContent({ className }: Props) {
<CreateTenantModal
skipPlanSelection
isOpen={isCreateModalOpen}
onClose={async (tenant?: TenantInfo) => {
onClose={async (tenant?: TenantResponse) => {
if (tenant) {
prependTenant(tenant);
navigateTenant(tenant.id);

View file

@ -2,6 +2,7 @@ import type router from '@logto/cloud/routes';
import { type GuardedResponse, type RouterRoutes } from '@withtyped/client';
type GetRoutes = RouterRoutes<typeof router>['get'];
type GetArrayElementType<T> = T extends Array<infer U> ? U : never;
export type SubscriptionPlanResponse = GuardedResponse<
GetRoutes['/api/subscription-plans']
@ -12,3 +13,6 @@ export type Subscription = GuardedResponse<GetRoutes['/api/tenants/:tenantId/sub
export type SubscriptionUsage = GuardedResponse<GetRoutes['/api/tenants/:tenantId/usage']>;
export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId/invoices']>;
// The response of GET /api/tenants is TenantResponse[].
export type TenantResponse = GetArrayElementType<GuardedResponse<GetRoutes['/api/tenants']>>;

View file

@ -1,4 +1,4 @@
import { maxFreeTenantLimit } from '@logto/schemas';
import { maxFreeTenantLimit, adminTenantId } from '@logto/schemas';
import classNames from 'classnames';
import { useContext, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@ -51,8 +51,13 @@ function PlanCardItem({ plan, onSelect }: Props) {
const isFreePlan = planId === ReservedPlanId.free;
// Todo: @xiaoyijun filter our all free tenants
const isFreeTenantExceeded = tenants.length >= maxFreeTenantLimit;
const isFreeTenantExceeded = useMemo(
() =>
/** Should not block admin tenant owners from creating more than three tenants */
!tenants.some(({ id }) => id === adminTenantId) &&
tenants.filter(({ planId }) => planId === ReservedPlanId.free).length >= maxFreeTenantLimit,
[tenants]
);
return (
<div className={styles.container}>

View file

@ -1,9 +1,9 @@
import { type TenantInfo } from '@logto/schemas/models';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantResponse } from '@/cloud/types/router';
import { ReservedPlanId } from '@/consts/subscriptions';
import DangerousRaw from '@/ds-components/DangerousRaw';
import ModalLayout from '@/ds-components/ModalLayout';
@ -20,7 +20,7 @@ import * as styles from './index.module.scss';
type Props = {
tenantData?: CreateTenantData;
onClose: (tenant?: TenantInfo) => void;
onClose: (tenant?: TenantResponse) => void;
};
function SelectTenantPlanModal({ tenantData, onClose }: Props) {

View file

@ -1,6 +1,6 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { Theme } from '@logto/schemas';
import { TenantTag, type TenantInfo } from '@logto/schemas/models';
import { TenantTag } from '@logto/schemas/models';
import { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
@ -10,6 +10,7 @@ import Modal from 'react-modal';
import CreateTenantHeaderIconDark from '@/assets/icons/create-tenant-header-dark.svg';
import CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantResponse } from '@/cloud/types/router';
import { isProduction } from '@/consts/env';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
@ -25,7 +26,7 @@ import { type CreateTenantData } from './type';
type Props = {
isOpen: boolean;
onClose: (tenant?: TenantInfo) => void;
onClose: (tenant?: TenantResponse) => void;
// eslint-disable-next-line react/boolean-prop-naming
skipPlanSelection?: boolean;
};

View file

@ -1,7 +1,7 @@
import { type TenantInfo } from '@logto/schemas/models';
import classNames from 'classnames';
import Tick from '@/assets/icons/tick.svg';
import { type TenantResponse } from '@/cloud/types/router';
import PlanName from '@/components/PlanName';
import { isProduction } from '@/consts/env';
import { DropdownItem } from '@/ds-components/Dropdown';
@ -14,7 +14,7 @@ import TenantStatusTag from './TenantStatusTag';
import * as styles from './index.module.scss';
type Props = {
tenantData: TenantInfo;
tenantData: TenantResponse;
isSelected: boolean;
onClick: () => void;
};

View file

@ -101,19 +101,4 @@ $dropdown-item-height: 40px;
height: 20px;
color: var(--color-neutral-50);
}
&.disabled {
&:hover {
background: transparent;
}
&:not(:disabled) {
cursor: not-allowed;
}
> div,
> svg {
color: var(--color-placeholder);
}
}
}

View file

@ -1,11 +1,9 @@
import { adminTenantId, maxFreeTenantLimit } from '@logto/schemas';
import { type TenantInfo } from '@logto/schemas/models';
import classNames from 'classnames';
import { useContext, useMemo, useRef, useState } from 'react';
import { useContext, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg';
import PlusSign from '@/assets/icons/plus.svg';
import { type TenantResponse } from '@/cloud/types/router';
import CreateTenantModal from '@/components/CreateTenantModal';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Divider from '@/ds-components/Divider';
@ -27,12 +25,6 @@ export default function TenantSelector() {
navigateTenant,
} = useContext(TenantsContext);
const isCreateButtonDisabled = useMemo(
() =>
/** Should not block admin tenant owners from creating more than three tenants */
!tenants.some(({ id }) => id === adminTenantId) && tenants.length >= maxFreeTenantLimit,
[tenants]
);
const anchorRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [showCreateTenantModal, setShowCreateTenantModal] = useState(false);
@ -85,11 +77,7 @@ export default function TenantSelector() {
<Divider />
<button
tabIndex={0}
className={classNames(
isCreateButtonDisabled && styles.disabled,
styles.createTenantButton
)}
disabled={isCreateButtonDisabled}
className={styles.createTenantButton}
onClick={() => {
setShowCreateTenantModal(true);
}}
@ -103,7 +91,7 @@ export default function TenantSelector() {
</Dropdown>
<CreateTenantModal
isOpen={showCreateTenantModal}
onClose={async (tenant?: TenantInfo) => {
onClose={async (tenant?: TenantResponse) => {
if (tenant) {
prependTenant(tenant);
navigateTenant(tenant.id);

View file

@ -1,10 +1,10 @@
import { useLogto } from '@logto/react';
import { type TenantInfo } from '@logto/schemas/lib/models/tenants.js';
import { trySafe } from '@silverhand/essentials';
import { useContext, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useSWRConfig } from 'swr';
import { type TenantResponse } from '@/cloud/types/router';
import AppLoading from '@/components/AppLoading';
// Used in the docs
// eslint-disable-next-line unused-imports/no-unused-imports
@ -63,7 +63,7 @@ export default function TenantAccess() {
}, [mutate, currentTenantId]);
useEffect(() => {
const validate = async ({ indicator }: TenantInfo) => {
const validate = async ({ indicator }: TenantResponse) => {
// Test fetching an access token for the current Tenant ID.
// If failed, it means the user finishes the first auth, ands still needs to auth again to
// fetch the full-scoped (with all available tenants) token.

View file

@ -1,11 +1,13 @@
import { defaultManagementApi, defaultTenantId } from '@logto/schemas';
import { type TenantInfo, TenantTag } from '@logto/schemas/models';
import { TenantTag } from '@logto/schemas/models';
import { conditionalArray, noop } from '@silverhand/essentials';
import type { ReactNode } from 'react';
import { useCallback, useMemo, createContext, useState } from 'react';
import { useMatch, useNavigate } from 'react-router-dom';
import { type TenantResponse } from '@/cloud/types/router';
import { isCloud } from '@/consts/env';
import { ReservedPlanId } from '@/consts/subscriptions';
/**
* The routes don't start with a tenant ID.
@ -32,20 +34,20 @@ type CurrentTenantStatus = 'pending' | 'validating' | 'validated';
/** @see {@link TenantsProvider} for why `useSWR()` is not applicable for this context. */
type Tenants = {
tenants: readonly TenantInfo[];
tenants: readonly TenantResponse[];
/** Indicates if the tenants data is ready for the first render. */
isInitComplete: boolean;
/** Reset tenants to the given value. It will overwrite the current tenants data and set `isInitComplete` to `true`. */
resetTenants: (tenants: TenantInfo[]) => void;
resetTenants: (tenants: TenantResponse[]) => void;
/** Prepend a new tenant to the current tenants data. */
prependTenant: (tenant: TenantInfo) => void;
prependTenant: (tenant: TenantResponse) => void;
/** Remove a tenant by ID from the current tenants data. */
removeTenant: (tenantId: string) => void;
/** Update a tenant by ID if it exists in the current tenants data. */
updateTenant: (tenantId: string, data: Partial<TenantInfo>) => void;
updateTenant: (tenantId: string, data: Partial<TenantResponse>) => void;
/** The current tenant ID parsed from the URL. */
currentTenantId: string;
currentTenant?: TenantInfo;
currentTenant?: TenantResponse;
/**
* Indicates if the Access Token has been validated for use. Will be reset to `pending` when the current tenant changes.
*
@ -65,7 +67,13 @@ const { tenantId, indicator } = defaultManagementApi.resource;
*/
const initialTenants = Object.freeze(
conditionalArray(
!isCloud && { id: tenantId, name: `tenant_${tenantId}`, tag: TenantTag.Development, indicator }
!isCloud && {
id: tenantId,
name: `tenant_${tenantId}`,
tag: TenantTag.Development,
indicator,
planId: `${ReservedPlanId.free}`, // `planId` is string type.
}
)
);
@ -132,18 +140,18 @@ function TenantsProvider({ children }: Props) {
const memorizedContext = useMemo(
() => ({
tenants,
resetTenants: (tenants: TenantInfo[]) => {
resetTenants: (tenants: TenantResponse[]) => {
setTenants(tenants);
setCurrentTenantStatus('pending');
setIsInitComplete(true);
},
prependTenant: (tenant: TenantInfo) => {
prependTenant: (tenant: TenantResponse) => {
setTenants((tenants) => [tenant, ...tenants]);
},
removeTenant: (tenantId: string) => {
setTenants((tenants) => tenants.filter((tenant) => tenant.id !== tenantId));
},
updateTenant: (tenantId: string, data: Partial<TenantInfo>) => {
updateTenant: (tenantId: string, data: Partial<TenantResponse>) => {
setTenants((tenants) =>
tenants.map((tenant) => (tenant.id === tenantId ? { ...tenant, ...data } : tenant))
);

View file

@ -1,7 +1,7 @@
import { type TenantInfo } from '@logto/schemas/models';
import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next';
import { type TenantResponse } from '@/cloud/types/router';
import { contactEmailLink } from '@/consts';
import { tenantTagMap } from '@/containers/AppContent/components/Topbar/TenantSelector/TenantEnvTag';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
@ -14,7 +14,7 @@ type Props = {
isLoading: boolean;
onClose: () => void;
onDelete: () => void;
tenant: Pick<TenantInfo, 'name' | 'tag'>;
tenant: Pick<TenantResponse, 'name' | 'tag'>;
};
function DeleteModal({ isOpen, isLoading, onClose, onDelete, tenant }: Props) {

View file

@ -1,4 +1,4 @@
import { type TenantInfo, TenantTag } from '@logto/schemas/models';
import { TenantTag } from '@logto/schemas/models';
import classNames from 'classnames';
import { useContext, useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
@ -6,6 +6,7 @@ import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantResponse } from '@/cloud/types/router';
import AppError from '@/components/AppError';
import PageMeta from '@/components/PageMeta';
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
@ -18,7 +19,7 @@ import ProfileForm from './ProfileForm';
import * as styles from './index.module.scss';
import { type TenantSettingsForm } from './types.js';
const tenantProfileToForm = (tenant?: TenantInfo): TenantSettingsForm => {
const tenantProfileToForm = (tenant?: TenantResponse): TenantSettingsForm => {
return {
profile: { name: tenant?.name ?? 'My project', tag: tenant?.tag ?? TenantTag.Development },
};

View file

@ -2755,8 +2755,8 @@ importers:
specifier: workspace:^1.3.1
version: link:../app-insights
'@logto/cloud':
specifier: 0.2.5-2087c06
version: 0.2.5-2087c06(zod@3.20.2)
specifier: 0.2.5-70aa370
version: 0.2.5-70aa370(zod@3.20.2)
'@logto/connector-kit':
specifier: workspace:^1.1.1
version: link:../toolkit/connector-kit
@ -2857,8 +2857,8 @@ importers:
specifier: ^15.5.1
version: 15.5.1
'@withtyped/client':
specifier: ^0.7.19
version: 0.7.19(zod@3.20.2)
specifier: ^0.7.21
version: 0.7.21(zod@3.20.2)
buffer:
specifier: ^5.7.1
version: 5.7.1
@ -7210,8 +7210,8 @@ packages:
jose: 4.14.4
dev: true
/@logto/cloud@0.2.5-2087c06(zod@3.20.2):
resolution: {integrity: sha512-v/zisil/t8XrlFxlVic+0O6T2J3FUzB5FDC1w3OYNzhXNbSpmbqlt5vyN9RIwXUGgAKjdhKQjoACpV7HpPFZcQ==}
/@logto/cloud@0.2.5-4d5e389(zod@3.20.2):
resolution: {integrity: sha512-vRJZGc0WvjE1rFJ0DNLaOHkhpe4TMdui/pvcTwGb/bDKzw/NM+4HtUoZj1a1DZ8Qqn24ex1WkTjxY8XGd3EruQ==}
engines: {node: ^18.12.0}
dependencies:
'@silverhand/essentials': 2.7.0
@ -7220,12 +7220,12 @@ packages:
- zod
dev: true
/@logto/cloud@0.2.5-4d5e389(zod@3.20.2):
resolution: {integrity: sha512-vRJZGc0WvjE1rFJ0DNLaOHkhpe4TMdui/pvcTwGb/bDKzw/NM+4HtUoZj1a1DZ8Qqn24ex1WkTjxY8XGd3EruQ==}
/@logto/cloud@0.2.5-70aa370(zod@3.20.2):
resolution: {integrity: sha512-V5fIsbotJ8+L6Q+R9PnLjspvuccDKpukpLz/uHRUv4SYDo8U5MAcC680T2TGiROtMjWqnb0vd6WHuqXZ9XWTcw==}
engines: {node: ^18.12.0}
dependencies:
'@silverhand/essentials': 2.7.0
'@withtyped/server': 0.12.7(zod@3.20.2)
'@withtyped/server': 0.12.8(zod@3.20.2)
transitivePeerDependencies:
- zod
dev: true
@ -9855,6 +9855,16 @@ packages:
'@withtyped/shared': 0.2.2
transitivePeerDependencies:
- zod
dev: false
/@withtyped/client@0.7.21(zod@3.20.2):
resolution: {integrity: sha512-N9dvH5nqIwaT7YxaIm83RRQf9AEjxwJ4ugJviZJSxtWy8zLul2/odEMc6epieylFVa6CcLg82yJmRSlqPtJiTw==}
dependencies:
'@withtyped/server': 0.12.8(zod@3.20.2)
'@withtyped/shared': 0.2.2
transitivePeerDependencies:
- zod
dev: true
/@withtyped/server@0.12.7(zod@3.20.2):
resolution: {integrity: sha512-NNT78ZZmSZiEosxI3iW/kVx1KEG5vetvpEXNl0Gy58OlOnI8l/7h8Q//JZJ268xWOKyaNI4KrngTRtL5uvZu9Q==}
@ -9873,7 +9883,6 @@ packages:
'@silverhand/essentials': 2.7.0
'@withtyped/shared': 0.2.2
zod: 3.20.2
dev: false
/@withtyped/shared@0.2.2:
resolution: {integrity: sha512-Vpcj12NqaoZ8M5Z/1kffheI9FBZEm9goed0THmgTcMKXLHjXSRbMZMp0olVxovEgaTIAydshqJOQUXKZMctIZw==}