diff --git a/packages/cloud/src/routes/tenants.test.ts b/packages/cloud/src/routes/tenants.test.ts index 9ad2aca0a..7fcaf1b86 100644 --- a/packages/cloud/src/routes/tenants.test.ts +++ b/packages/cloud/src/routes/tenants.test.ts @@ -49,6 +49,26 @@ describe('POST /api/tenants', () => { ).rejects.toMatchObject({ status: 403 }); }); + it('should throw 403 when trying to create more than 3 tenants with `CreateTenant` scope', async () => { + const tenant: TenantInfo = { + id: 'tenant', + name: 'tenant', + tag: TenantTag.Development, + indicator: 'https://tenant.foo.bar', + }; + library.getAvailableTenants.mockResolvedValueOnce([tenant, tenant, tenant]); + + await expect( + router.routes()( + buildRequestAuthContext('POST /tenants', { + body: { name: 'tenant', tag: TenantTag.Development }, + })([CloudScope.CreateTenant]), + noop, + createHttpContext() + ) + ).rejects.toMatchObject({ status: 403 }); + }); + it('should be able to create a new tenant', async () => { const tenant: TenantInfo = { id: 'tenant_a', @@ -56,6 +76,7 @@ describe('POST /api/tenants', () => { tag: TenantTag.Development, indicator: 'https://foo.bar', }; + library.getAvailableTenants.mockResolvedValueOnce([]); library.createNewTenant.mockImplementationOnce(async (_, payload) => { return { ...tenant, ...payload }; }); diff --git a/packages/cloud/src/routes/tenants.ts b/packages/cloud/src/routes/tenants.ts index 023d63d8d..d6f7dc684 100644 --- a/packages/cloud/src/routes/tenants.ts +++ b/packages/cloud/src/routes/tenants.ts @@ -68,6 +68,18 @@ export const tenantsRoutes = (library: TenantsLibrary) => throw new RequestError('Forbidden due to lack of permission.', 403); } + /** + * Should throw 403 when users with `CreateTenant` scope are attempting to create more than 3 tenants. + * This does not apply to users with `ManageTenant` scope. + */ + if (context.auth.scopes.includes(CloudScope.CreateTenant)) { + const availableTenants = await library.getAvailableTenants(context.auth.id); + assert( + availableTenants.length < 3, + new RequestError(`Can not have more than 3 tenants.`, 403) + ); + } + return next({ ...context, json: await library.createNewTenant(context.auth.id, context.guarded.body), diff --git a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.module.scss b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.module.scss index 5f67b00fe..b3905640b 100644 --- a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.module.scss +++ b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.module.scss @@ -119,9 +119,24 @@ cursor: pointer; } - .icon { + > svg { width: 20px; height: 20px; color: var(--color-neutral-50); } + + &.disabled { + &:hover { + background: transparent; + } + + &:not(:disabled) { + cursor: not-allowed; + } + + > div, + > svg { + color: var(--color-placeholder); + } + } } diff --git a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.tsx b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.tsx index c0d263fc3..002dd76ef 100644 --- a/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.tsx +++ b/packages/console/src/containers/AppContent/components/Topbar/TenantSelector/index.tsx @@ -1,4 +1,4 @@ -import { type TenantInfo, TenantTag } from '@logto/schemas/models'; +import { type TenantInfo } from '@logto/schemas/models'; import classNames from 'classnames'; import { useRef, useState } from 'react'; import { toast } from 'react-hot-toast'; @@ -35,10 +35,12 @@ function TenantSelector() { return <AppError errorMessage={error.message} callStack={error.stack} />; } - if (!tenants?.length) { + if (!tenants?.length || !currentTenantInfo) { return null; } + const isCreateButtonDisabled = tenants.length >= 3; + return ( <> <div @@ -53,11 +55,8 @@ function TenantSelector() { setShowDropdown(true); }} > - <div className={styles.name}>{currentTenantInfo?.name ?? 'My project'}</div> - <TenantEnvTag - className={styles.tag} - tag={currentTenantInfo?.tag ?? TenantTag.Development} - /> + <div className={styles.name}>{currentTenantInfo.name}</div> + <TenantEnvTag className={styles.tag} tag={currentTenantInfo.tag} /> <KeyboardArrowDown className={styles.arrowIcon} /> </div> <Dropdown @@ -89,16 +88,25 @@ function TenantSelector() { <div role="button" tabIndex={0} - className={styles.createTenantButton} + className={classNames( + isCreateButtonDisabled && styles.disabled, + styles.createTenantButton + )} onClick={() => { + if (isCreateButtonDisabled) { + return; + } setShowCreateTenantModal(true); }} onKeyDown={onKeyDownHandler(() => { + if (isCreateButtonDisabled) { + return; + } setShowCreateTenantModal(true); })} > <div>{t('cloud.tenant.create_tenant')}</div> - <PlusSign className={styles.icon} /> + <PlusSign /> </div> </Dropdown> <CreateTenantModal diff --git a/packages/integration-tests/src/tests/api-cloud/tenant.test.ts b/packages/integration-tests/src/tests/api-cloud/tenant.test.ts index 85c3de0cc..2b6a11fb6 100644 --- a/packages/integration-tests/src/tests/api-cloud/tenant.test.ts +++ b/packages/integration-tests/src/tests/api-cloud/tenant.test.ts @@ -85,6 +85,9 @@ describe('Tenant APIs', () => { expect(tenant).toHaveProperty('tag', payload.tag); expect(tenant).toHaveProperty('name', payload.name); } + await expect( + createTenant(accessToken, { name: 'tenant4', tag: TenantTag.Staging }) + ).rejects.toThrow(); await deleteTenant(accessToken, tenant3.id); const resources = await authedAdminTenantApi.get('resources').json<Resource[]>(); expect(