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:
parent
3ef8c06d4a
commit
350d070ef7
14 changed files with 71 additions and 70 deletions
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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']>>;
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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))
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
@ -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==}
|
||||
|
|
Loading…
Reference in a new issue