From 07e145b903f929370fc76d58db0477d004cad2b2 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Fri, 23 Feb 2024 12:37:20 +0800 Subject: [PATCH] fix(core,console): block deletion on management API (#5424) * fix(core,console): block deletion on management API * refactor(core): separate resource api file to two files due to file size --- .../DetailsPage/DetailsPageHeader/index.tsx | 22 +- .../src/pages/ApiResourceDetails/index.tsx | 49 +++-- packages/core/src/routes/init.ts | 2 + .../core/src/routes/resource.scope.test.ts | 116 +++++++++++ packages/core/src/routes/resource.scope.ts | 188 ++++++++++++++++++ packages/core/src/routes/resource.test.ts | 76 ++----- packages/core/src/routes/resource.ts | 176 +--------------- .../src/tests/api/resource.test.ts | 7 + .../phrases/src/locales/de/errors/resource.ts | 2 + .../phrases/src/locales/en/errors/resource.ts | 1 + .../phrases/src/locales/es/errors/resource.ts | 2 + .../phrases/src/locales/fr/errors/resource.ts | 2 + .../phrases/src/locales/it/errors/resource.ts | 2 + .../phrases/src/locales/ja/errors/resource.ts | 2 + .../phrases/src/locales/ko/errors/resource.ts | 2 + .../src/locales/pl-pl/errors/resource.ts | 2 + .../src/locales/pt-br/errors/resource.ts | 2 + .../src/locales/pt-pt/errors/resource.ts | 2 + .../phrases/src/locales/ru/errors/resource.ts | 2 + .../src/locales/tr-tr/errors/resource.ts | 2 + .../src/locales/zh-cn/errors/resource.ts | 2 + .../src/locales/zh-hk/errors/resource.ts | 2 + .../src/locales/zh-tw/errors/resource.ts | 2 + 23 files changed, 405 insertions(+), 260 deletions(-) create mode 100644 packages/core/src/routes/resource.scope.test.ts create mode 100644 packages/core/src/routes/resource.scope.ts diff --git a/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx b/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx index 865d97e4d..d3e01bd87 100644 --- a/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx +++ b/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx @@ -220,16 +220,18 @@ function DetailsPageHeader({ onClick={additionalActionButton.onClick} /> )} - , size: 'large' }} - title={t('general.more_options')} - > - {actionMenuItems?.map(({ title, icon, type, onClick }) => ( - - - - ))} - + {actionMenuItems && actionMenuItems.length > 0 && ( + , size: 'large' }} + title={t('general.more_options')} + > + {actionMenuItems.map(({ title, icon, type, onClick }) => ( + + + + ))} + + )} ); diff --git a/packages/console/src/pages/ApiResourceDetails/index.tsx b/packages/console/src/pages/ApiResourceDetails/index.tsx index bc9ca4efb..595ed827a 100644 --- a/packages/console/src/pages/ApiResourceDetails/index.tsx +++ b/packages/console/src/pages/ApiResourceDetails/index.tsx @@ -1,6 +1,7 @@ import { withAppInsights } from '@logto/app-insights/react'; import type { Resource } from '@logto/schemas'; import { isManagementApi, Theme } from '@logto/schemas'; +import { conditionalArray } from '@silverhand/essentials'; import classNames from 'classnames'; import { useEffect, useState } from 'react'; import { toast } from 'react-hot-toast'; @@ -15,7 +16,7 @@ import File from '@/assets/icons/file.svg'; import ManagementApiResourceDark from '@/assets/icons/management-api-resource-dark.svg'; import ManagementApiResource from '@/assets/icons/management-api-resource.svg'; import DetailsPage from '@/components/DetailsPage'; -import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader'; +import DetailsPageHeader, { type MenuItem } from '@/components/DetailsPage/DetailsPageHeader'; import Drawer from '@/components/Drawer'; import PageMeta from '@/components/PageMeta'; import { ApiResourceDetailsTabs } from '@/consts/page-tabs'; @@ -133,37 +134,41 @@ function ApiResourceDetails() { } }, }} - actionMenuItems={[ - { + actionMenuItems={conditionalArray( + // Should not show delete button for management api resource. + !isLogtoManagementApiResource && { icon: , title: 'general.delete', type: 'danger', onClick: () => { setIsDeleteFormOpen(true); }, - }, - ]} + } + )} /> - { - setIsDeleteFormOpen(false); - }} - onConfirm={onDelete} - > -
- }}> - {t('api_resource_details.delete_description', { name: data.name })} - -
-
+ {/* Can not delete management api resource. */} + {!isLogtoManagementApiResource && ( + { + setIsDeleteFormOpen(false); + }} + onConfirm={onDelete} + > +
+ }}> + {t('api_resource_details.delete_description', { name: data.name })} + +
+
+ )} {t('api_resource_details.settings_tab')} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index f532127e8..48decc417 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -28,6 +28,7 @@ import logRoutes from './log.js'; import logtoConfigRoutes from './logto-config.js'; import organizationRoutes from './organization/index.js'; import resourceRoutes from './resource.js'; +import resourceScopeRoutes from './resource.scope.js'; import roleRoutes from './role.js'; import roleScopeRoutes from './role.scope.js'; import signInExperiencesRoutes from './sign-in-experience/index.js'; @@ -61,6 +62,7 @@ const createRouters = (tenant: TenantContext) => { logtoConfigRoutes(managementRouter, tenant); connectorRoutes(managementRouter, tenant); resourceRoutes(managementRouter, tenant); + resourceScopeRoutes(managementRouter, tenant); signInExperiencesRoutes(managementRouter, tenant); adminUserRoutes(managementRouter, tenant); logRoutes(managementRouter, tenant); diff --git a/packages/core/src/routes/resource.scope.test.ts b/packages/core/src/routes/resource.scope.test.ts new file mode 100644 index 000000000..e34e04142 --- /dev/null +++ b/packages/core/src/routes/resource.scope.test.ts @@ -0,0 +1,116 @@ +import { type Resource, type CreateResource } from '@logto/schemas'; +import { pickDefault } from '@logto/shared/esm'; +import { type Nullable } from '@silverhand/essentials'; + +import { mockResource, mockScope } from '#src/__mocks__/index.js'; +import { mockId, mockIdGenerators } from '#src/test-utils/nanoid.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +const resources = { + findTotalNumberOfResources: async () => ({ count: 10 }), + findAllResources: async (): Promise => [mockResource], + findResourceByIndicator: async (indicator: string): Promise> => { + if (indicator === mockResource.indicator) { + return mockResource; + } + return null; + }, + findResourceById: jest.fn(async (): Promise => mockResource), + insertResource: async (body: CreateResource): Promise => ({ + ...mockResource, + ...body, + }), + updateResourceById: async (_: unknown, data: Partial): Promise => ({ + ...mockResource, + ...data, + }), + deleteResourceById: jest.fn(), + findScopesByResourceId: async () => [mockScope], +}; +const { findResourceById } = resources; + +const scopes = { + findScopesByResourceId: async () => [mockScope], + searchScopesByResourceId: async () => [mockScope], + countScopesByResourceId: async () => ({ count: 1 }), + findScopesByResourceIds: async () => [], + insertScope: jest.fn(async () => mockScope), + updateScopeById: jest.fn(async () => mockScope), + deleteScopeById: jest.fn(), + findScopeByNameAndResourceId: jest.fn(), +}; +const { insertScope, updateScopeById } = scopes; + +await mockIdGenerators(); + +const tenantContext = new MockTenant(undefined, { scopes, resources }, undefined); + +const resourceScopeRoutes = await pickDefault(import('./resource.scope.js')); + +describe('resource scope routes', () => { + const resourceScopeRequest = createRequester({ + authedRoutes: resourceScopeRoutes, + tenantContext, + }); + + it('GET /resources/:id/scopes', async () => { + const response = await resourceScopeRequest.get('/resources/foo/scopes'); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockScope]); + }); + + it('POST /resources/:id/scopes', async () => { + const name = 'write:users'; + const description = 'description'; + + const response = await resourceScopeRequest + .post('/resources/foo/scopes') + .send({ name, description }); + + expect(response.status).toEqual(201); + expect(findResourceById).toHaveBeenCalledWith('foo'); + expect(insertScope).toHaveBeenCalledWith({ + id: mockId, + name, + description, + resourceId: 'foo', + }); + }); + + it('POST /resources/:id/scopes should throw with spaces in name', async () => { + const name = 'write users'; + const description = 'description'; + + const response = await resourceScopeRequest + .post('/resources/foo/scopes') + .send({ name, description }); + + expect(response.status).toEqual(400); + }); + + it('PATCH /resources/:id/scopes/:scopeId', async () => { + const name = 'write:users'; + const description = 'description'; + + const response = await resourceScopeRequest + .patch('/resources/foo/scopes/foz') + .send({ name, description }); + + expect(response.status).toEqual(200); + expect(findResourceById).toHaveBeenCalledWith('foo'); + expect(updateScopeById).toHaveBeenCalledWith('foz', { + name, + description, + }); + }); + + it('DELETE /resources/:id/scopes/:scopeId', async () => { + await expect(resourceScopeRequest.delete('/resources/foo/scopes/foz')).resolves.toHaveProperty( + 'status', + 204 + ); + }); +}); diff --git a/packages/core/src/routes/resource.scope.ts b/packages/core/src/routes/resource.scope.ts new file mode 100644 index 000000000..e5e001324 --- /dev/null +++ b/packages/core/src/routes/resource.scope.ts @@ -0,0 +1,188 @@ +import { Scopes } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { tryThat } from '@silverhand/essentials'; +import { object, string } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import assertThat from '#src/utils/assert-that.js'; +import { parseSearchParamsForSearch } from '#src/utils/search.js'; + +import type { AuthedRouter, RouterInitArgs } from './types.js'; + +export default function resourceScopeRoutes( + ...[ + router, + { + queries, + libraries: { quota }, + }, + ]: RouterInitArgs +) { + const { + resources: { findResourceById }, + scopes: { + countScopesByResourceId, + deleteScopeById, + searchScopesByResourceId, + findScopeByNameAndResourceId, + insertScope, + updateScopeById, + }, + } = queries; + + router.get( + '/resources/:resourceId/scopes', + koaPagination(), + koaGuard({ + params: object({ resourceId: string().min(1) }), + status: [200, 400], + response: Scopes.guard.array(), + }), + async (ctx, next) => { + const { + params: { resourceId }, + } = ctx.guard; + const { limit, offset } = ctx.pagination; + const { searchParams } = ctx.request.URL; + + return tryThat( + async () => { + const search = parseSearchParamsForSearch(searchParams); + + const [{ count }, scopes] = await Promise.all([ + countScopesByResourceId(resourceId, search), + searchScopesByResourceId(resourceId, search, limit, offset), + ]); + + // Return totalCount to pagination middleware + ctx.pagination.totalCount = count; + ctx.body = scopes; + + return next(); + }, + (error) => { + if (error instanceof TypeError) { + throw new RequestError( + { code: 'request.invalid_input', details: error.message }, + error + ); + } + throw error; + } + ); + } + ); + + router.post( + '/resources/:resourceId/scopes', + koaGuard({ + params: object({ resourceId: string().min(1) }), + body: Scopes.createGuard.pick({ name: true, description: true }), + response: Scopes.guard, + status: [201, 422, 400, 404], + }), + async (ctx, next) => { + const { + params: { resourceId }, + body, + } = ctx.guard; + + await quota.guardKey('scopesPerResourceLimit', resourceId); + + assertThat(!/\s/.test(body.name), 'scope.name_with_space'); + + assertThat( + await findResourceById(resourceId), + new RequestError({ + code: 'entity.not_exists_with_id', + name: 'resource', + id: resourceId, + resourceId, + status: 404, + }) + ); + + assertThat( + !(await findScopeByNameAndResourceId(body.name, resourceId)), + new RequestError({ + code: 'scope.name_exists', + name: body.name, + status: 422, + }) + ); + + ctx.status = 201; + ctx.body = await insertScope({ + ...body, + id: generateStandardId(), + resourceId, + }); + + return next(); + } + ); + + router.patch( + '/resources/:resourceId/scopes/:scopeId', + koaGuard({ + params: object({ resourceId: string().min(1), scopeId: string().min(1) }), + body: Scopes.createGuard.pick({ name: true, description: true }).partial(), + response: Scopes.guard, + status: [200, 400, 404, 422], + }), + async (ctx, next) => { + const { + params: { scopeId, resourceId }, + body, + } = ctx.guard; + + assertThat( + await findResourceById(resourceId), + new RequestError({ + code: 'entity.not_exists_with_id', + name: 'resource', + id: resourceId, + resourceId, + status: 404, + }) + ); + + if (body.name) { + assertThat(!/\s/.test(body.name), 'scope.name_with_space'); + assertThat( + !(await findScopeByNameAndResourceId(body.name, resourceId, scopeId)), + new RequestError({ + code: 'scope.name_exists', + name: body.name, + status: 422, + }) + ); + } + + ctx.body = await updateScopeById(scopeId, body); + + return next(); + } + ); + + router.delete( + '/resources/:resourceId/scopes/:scopeId', + koaGuard({ + params: object({ resourceId: string().min(1), scopeId: string().min(1) }), + status: [204, 404], + }), + async (ctx, next) => { + const { + params: { scopeId }, + } = ctx.guard; + + await deleteScopeById(scopeId); + + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/resource.test.ts b/packages/core/src/routes/resource.test.ts index 90bf5bf81..88f766174 100644 --- a/packages/core/src/routes/resource.test.ts +++ b/packages/core/src/routes/resource.test.ts @@ -1,4 +1,8 @@ -import type { Resource, CreateResource } from '@logto/schemas'; +import { + type Resource, + type CreateResource, + getManagementApiResourceIndicator, +} from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; import { type Nullable } from '@silverhand/essentials'; @@ -30,7 +34,6 @@ const resources = { deleteResourceById: jest.fn(), findScopesByResourceId: async () => [mockScope], }; -const { findResourceById } = resources; const scopes = { findScopesByResourceId: async () => [mockScope], @@ -42,7 +45,6 @@ const scopes = { deleteScopeById: jest.fn(), findScopeByNameAndResourceId: jest.fn(), }; -const { insertScope, updateScopeById } = scopes; await mockIdGenerators(); @@ -154,69 +156,19 @@ describe('resource routes', () => { await expect(resourceRequest.delete('/resources/foo')).resolves.toHaveProperty('status', 204); }); + it('DELETE /resources/:id should throw when trying to delete management API', async () => { + const { findResourceById } = resources; + findResourceById.mockResolvedValueOnce({ + ...mockResource, + indicator: getManagementApiResourceIndicator('mock'), + }); + await expect(resourceRequest.delete('/resources/foo')).resolves.toHaveProperty('status', 400); + }); + it('DELETE /resources/:id should throw with invalid id', async () => { const { deleteResourceById } = resources; deleteResourceById.mockRejectedValueOnce(new Error('not found')); await expect(resourceRequest.delete('/resources/foo')).resolves.toHaveProperty('status', 500); }); - - it('GET /resources/:id/scopes', async () => { - const response = await resourceRequest.get('/resources/foo/scopes'); - expect(response.status).toEqual(200); - expect(response.body).toEqual([mockScope]); - expect(findResourceById).toHaveBeenCalledWith('foo'); - }); - - it('POST /resources/:id/scopes', async () => { - const name = 'write:users'; - const description = 'description'; - - const response = await resourceRequest - .post('/resources/foo/scopes') - .send({ name, description }); - - expect(response.status).toEqual(201); - expect(findResourceById).toHaveBeenCalledWith('foo'); - expect(insertScope).toHaveBeenCalledWith({ - id: mockId, - name, - description, - resourceId: 'foo', - }); - }); - - it('POST /resources/:id/scopes should throw with spaces in name', async () => { - const name = 'write users'; - const description = 'description'; - - const response = await resourceRequest - .post('/resources/foo/scopes') - .send({ name, description }); - - expect(response.status).toEqual(400); - }); - - it('PATCH /resources/:id/scopes/:scopeId', async () => { - const name = 'write:users'; - const description = 'description'; - - const response = await resourceRequest - .patch('/resources/foo/scopes/foz') - .send({ name, description }); - - expect(response.status).toEqual(200); - expect(findResourceById).toHaveBeenCalledWith('foo'); - expect(updateScopeById).toHaveBeenCalledWith('foz', { - name, - description, - }); - }); - - it('DELETE /resources/:id/scopes/:scopeId', async () => { - await expect(resourceRequest.delete('/resources/foo/scopes/foz')).resolves.toHaveProperty( - 'status', - 204 - ); - }); }); diff --git a/packages/core/src/routes/resource.ts b/packages/core/src/routes/resource.ts index b4c2baefa..b528321d2 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -1,6 +1,6 @@ -import { Resources, Scopes } from '@logto/schemas'; +import { isManagementApi, Resources, Scopes } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; -import { tryThat, yes } from '@silverhand/essentials'; +import { yes } from '@silverhand/essentials'; import { boolean, object, string } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; @@ -9,7 +9,6 @@ import koaPagination from '#src/middleware/koa-pagination.js'; import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import assertThat from '#src/utils/assert-that.js'; import { attachScopesToResources } from '#src/utils/resource.js'; -import { parseSearchParamsForSearch } from '#src/utils/search.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; @@ -34,14 +33,6 @@ export default function resourceRoutes( deleteResourceById, }, scopes: scopeQueries, - scopes: { - countScopesByResourceId, - deleteScopeById, - searchScopesByResourceId, - findScopeByNameAndResourceId, - insertScope, - updateScopeById, - }, } = queries; router.get( @@ -183,167 +174,20 @@ export default function resourceRoutes( router.delete( '/resources/:id', - koaGuard({ params: object({ id: string().min(1) }), status: [204, 404] }), + koaGuard({ params: object({ id: string().min(1) }), status: [204, 400, 404] }), async (ctx, next) => { const { id } = ctx.guard.params; + + const { indicator } = await findResourceById(id); + assertThat( + !isManagementApi(indicator), + new RequestError({ code: 'resource.cannot_delete_management_api' }) + ); + await deleteResourceById(id); ctx.status = 204; return next(); } ); - - router.get( - '/resources/:resourceId/scopes', - koaPagination(), - koaGuard({ - params: object({ resourceId: string().min(1) }), - status: [200, 400], - response: Scopes.guard.array(), - }), - async (ctx, next) => { - const { - params: { resourceId }, - } = ctx.guard; - const { limit, offset } = ctx.pagination; - const { searchParams } = ctx.request.URL; - - return tryThat( - async () => { - const search = parseSearchParamsForSearch(searchParams); - - const [{ count }, scopes] = await Promise.all([ - countScopesByResourceId(resourceId, search), - searchScopesByResourceId(resourceId, search, limit, offset), - ]); - - // Return totalCount to pagination middleware - ctx.pagination.totalCount = count; - ctx.body = scopes; - - return next(); - }, - (error) => { - if (error instanceof TypeError) { - throw new RequestError( - { code: 'request.invalid_input', details: error.message }, - error - ); - } - throw error; - } - ); - } - ); - - router.post( - '/resources/:resourceId/scopes', - koaGuard({ - params: object({ resourceId: string().min(1) }), - body: Scopes.createGuard.pick({ name: true, description: true }), - response: Scopes.guard, - status: [201, 422, 400, 404], - }), - async (ctx, next) => { - const { - params: { resourceId }, - body, - } = ctx.guard; - - await quota.guardKey('scopesPerResourceLimit', resourceId); - - assertThat(!/\s/.test(body.name), 'scope.name_with_space'); - - assertThat( - await findResourceById(resourceId), - new RequestError({ - code: 'entity.not_exists_with_id', - name: 'resource', - id: resourceId, - resourceId, - status: 404, - }) - ); - - assertThat( - !(await findScopeByNameAndResourceId(body.name, resourceId)), - new RequestError({ - code: 'scope.name_exists', - name: body.name, - status: 422, - }) - ); - - ctx.status = 201; - ctx.body = await insertScope({ - ...body, - id: generateStandardId(), - resourceId, - }); - - return next(); - } - ); - - router.patch( - '/resources/:resourceId/scopes/:scopeId', - koaGuard({ - params: object({ resourceId: string().min(1), scopeId: string().min(1) }), - body: Scopes.createGuard.pick({ name: true, description: true }).partial(), - response: Scopes.guard, - status: [200, 400, 404, 422], - }), - async (ctx, next) => { - const { - params: { scopeId, resourceId }, - body, - } = ctx.guard; - - assertThat( - await findResourceById(resourceId), - new RequestError({ - code: 'entity.not_exists_with_id', - name: 'resource', - id: resourceId, - resourceId, - status: 404, - }) - ); - - if (body.name) { - assertThat(!/\s/.test(body.name), 'scope.name_with_space'); - assertThat( - !(await findScopeByNameAndResourceId(body.name, resourceId, scopeId)), - new RequestError({ - code: 'scope.name_exists', - name: body.name, - status: 422, - }) - ); - } - - ctx.body = await updateScopeById(scopeId, body); - - return next(); - } - ); - - router.delete( - '/resources/:resourceId/scopes/:scopeId', - koaGuard({ - params: object({ resourceId: string().min(1), scopeId: string().min(1) }), - status: [204, 404], - }), - async (ctx, next) => { - const { - params: { scopeId }, - } = ctx.guard; - - await deleteScopeById(scopeId); - - ctx.status = 204; - - return next(); - } - ); } diff --git a/packages/integration-tests/src/tests/api/resource.test.ts b/packages/integration-tests/src/tests/api/resource.test.ts index 9ddd87906..b83469a07 100644 --- a/packages/integration-tests/src/tests/api/resource.test.ts +++ b/packages/integration-tests/src/tests/api/resource.test.ts @@ -122,6 +122,13 @@ describe('admin console api resources', () => { expect(response instanceof HTTPError && response.response.statusCode === 404).toBe(true); }); + it('should throw when deleting management api resource', async () => { + const response = await deleteResource(defaultManagementApi.resource.id).catch( + (error: unknown) => error + ); + expect(response instanceof HTTPError && response.response.statusCode === 400).toBe(true); + }); + it('should throw 404 when delete api resource not found', async () => { const response = await deleteResource('dummy_id').catch((error: unknown) => error); expect(response instanceof HTTPError && response.response.statusCode === 404).toBe(true); diff --git a/packages/phrases/src/locales/de/errors/resource.ts b/packages/phrases/src/locales/de/errors/resource.ts index 47a884e65..4172a193b 100644 --- a/packages/phrases/src/locales/de/errors/resource.ts +++ b/packages/phrases/src/locales/de/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'Die API-Kennung {{indicator}} wird bereits verwendet', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/en/errors/resource.ts b/packages/phrases/src/locales/en/errors/resource.ts index 6a4251c91..4a47fbf34 100644 --- a/packages/phrases/src/locales/en/errors/resource.ts +++ b/packages/phrases/src/locales/en/errors/resource.ts @@ -1,5 +1,6 @@ const resource = { resource_identifier_in_use: 'The API identifier {{indicator}} is already in use', + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/es/errors/resource.ts b/packages/phrases/src/locales/es/errors/resource.ts index 2a6a6dd96..780df333b 100644 --- a/packages/phrases/src/locales/es/errors/resource.ts +++ b/packages/phrases/src/locales/es/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: '"El identificador de API {{indicator}} ya está en uso', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/fr/errors/resource.ts b/packages/phrases/src/locales/fr/errors/resource.ts index 56225deb4..fe4c8aeda 100644 --- a/packages/phrases/src/locales/fr/errors/resource.ts +++ b/packages/phrases/src/locales/fr/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: "L'identifiant d'API {{indicator}} est déjà utilisé", + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/it/errors/resource.ts b/packages/phrases/src/locales/it/errors/resource.ts index dda1ae73e..997fdba34 100644 --- a/packages/phrases/src/locales/it/errors/resource.ts +++ b/packages/phrases/src/locales/it/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: "L'identificatore API {{indicator}} è già in uso", + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/ja/errors/resource.ts b/packages/phrases/src/locales/ja/errors/resource.ts index 08a2609c2..17aad104a 100644 --- a/packages/phrases/src/locales/ja/errors/resource.ts +++ b/packages/phrases/src/locales/ja/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'API識別子 {{indicator}} はすでに使用されています', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/ko/errors/resource.ts b/packages/phrases/src/locales/ko/errors/resource.ts index 0313ac802..92ad2a501 100644 --- a/packages/phrases/src/locales/ko/errors/resource.ts +++ b/packages/phrases/src/locales/ko/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'API 식별자 {{indicator}}가 이미 사용 중입니다', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/pl-pl/errors/resource.ts b/packages/phrases/src/locales/pl-pl/errors/resource.ts index 0dd7e461c..1a26d6f79 100644 --- a/packages/phrases/src/locales/pl-pl/errors/resource.ts +++ b/packages/phrases/src/locales/pl-pl/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'Identyfikator API {{indicator}} jest już używany', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/pt-br/errors/resource.ts b/packages/phrases/src/locales/pt-br/errors/resource.ts index 50c0c861a..930c2f326 100644 --- a/packages/phrases/src/locales/pt-br/errors/resource.ts +++ b/packages/phrases/src/locales/pt-br/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'O identificador de API {{indicator}} já está em uso', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/pt-pt/errors/resource.ts b/packages/phrases/src/locales/pt-pt/errors/resource.ts index 395f114f6..69884fc53 100644 --- a/packages/phrases/src/locales/pt-pt/errors/resource.ts +++ b/packages/phrases/src/locales/pt-pt/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'O identificador da API {{indicator}} já está em uso', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/ru/errors/resource.ts b/packages/phrases/src/locales/ru/errors/resource.ts index 96d28810f..8dd8c80c1 100644 --- a/packages/phrases/src/locales/ru/errors/resource.ts +++ b/packages/phrases/src/locales/ru/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'Идентификатор API {{indicator}} уже используется', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/tr-tr/errors/resource.ts b/packages/phrases/src/locales/tr-tr/errors/resource.ts index 42b37bd63..f0bb68939 100644 --- a/packages/phrases/src/locales/tr-tr/errors/resource.ts +++ b/packages/phrases/src/locales/tr-tr/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'API tanımlayıcısı {{indicator}} zaten kullanımda', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/zh-cn/errors/resource.ts b/packages/phrases/src/locales/zh-cn/errors/resource.ts index e5b954a01..6ab300c0f 100644 --- a/packages/phrases/src/locales/zh-cn/errors/resource.ts +++ b/packages/phrases/src/locales/zh-cn/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'API 标识符 {{indicator}} 已被使用', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/zh-hk/errors/resource.ts b/packages/phrases/src/locales/zh-hk/errors/resource.ts index b2eafef18..c74d93bfc 100644 --- a/packages/phrases/src/locales/zh-hk/errors/resource.ts +++ b/packages/phrases/src/locales/zh-hk/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'API 識別碼 {{indicator}} 已經被使用', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource); diff --git a/packages/phrases/src/locales/zh-tw/errors/resource.ts b/packages/phrases/src/locales/zh-tw/errors/resource.ts index b2eafef18..c74d93bfc 100644 --- a/packages/phrases/src/locales/zh-tw/errors/resource.ts +++ b/packages/phrases/src/locales/zh-tw/errors/resource.ts @@ -1,5 +1,7 @@ const resource = { resource_identifier_in_use: 'API 識別碼 {{indicator}} 已經被使用', + /** UNTRANSLATED */ + cannot_delete_management_api: 'Cannot delete Logto management API.', }; export default Object.freeze(resource);