diff --git a/packages/@n8n/api-types/src/api-keys.ts b/packages/@n8n/api-types/src/api-keys.ts new file mode 100644 index 0000000000..e812786e78 --- /dev/null +++ b/packages/@n8n/api-types/src/api-keys.ts @@ -0,0 +1,9 @@ +export type ApiKey = { + id: string; + label: string; + apiKey: string; + createdAt: string; + updatedAt: string; +}; + +export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string }; diff --git a/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts b/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts new file mode 100644 index 0000000000..beb7ebcf0d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts @@ -0,0 +1,40 @@ +import { CreateOrUpdateApiKeyRequestDto } from '../create-or-update-api-key-request.dto'; + +describe('CreateOrUpdateApiKeyRequestDto', () => { + describe('Valid requests', () => { + test('should allow valid label', () => { + const result = CreateOrUpdateApiKeyRequestDto.safeParse({ + label: 'valid label', + }); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'empty label', + label: '', + expectedErrorPath: ['label'], + }, + { + name: 'label exceeding 50 characters', + label: '2mWMfsrvAmneWluS8IbezaIHZOu2mWMfsrvAmneWluS8IbezaIa', + expectedErrorPath: ['label'], + }, + { + name: 'label with xss injection', + label: '', + expectedErrorPath: ['label'], + }, + ])('should fail validation for $name', ({ label, expectedErrorPath }) => { + const result = CreateOrUpdateApiKeyRequestDto.safeParse({ label }); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts b/packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts new file mode 100644 index 0000000000..168c28c2fa --- /dev/null +++ b/packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts @@ -0,0 +1,13 @@ +import xss from 'xss'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +const xssCheck = (value: string) => + value === + xss(value, { + whiteList: {}, + }); + +export class CreateOrUpdateApiKeyRequestDto extends Z.class({ + label: z.string().max(50).min(1).refine(xssCheck), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 9be09c02f3..f8bdb80268 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -47,3 +47,5 @@ export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.d export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto'; export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto'; + +export { CreateOrUpdateApiKeyRequestDto } from './api-keys/create-or-update-api-key-request.dto'; diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 3ce856d6ad..ceaac19a69 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -87,6 +87,7 @@ export interface FrontendSettings { }; }; publicApi: { + apiKeysPerUserLimit: number; enabled: boolean; latestVersion: number; path: string; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 5620689af0..a51850cc6c 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -4,6 +4,7 @@ export type * from './push'; export type * from './scaling'; export type * from './frontend-settings'; export type * from './user'; +export type * from './api-keys'; export type { Collaborator } from './push/collaboration'; export type { SendWorkerStatusMessage } from './push/worker'; diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index abcf298d3d..6411c91bac 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -104,6 +104,7 @@ export const LICENSE_QUOTAS = { WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune', TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects', AI_CREDITS: 'quota:aiCredits', + API_KEYS_PER_USER_LIMIT: 'quota:apiKeysPerUserLimit', } as const; export const UNLIMITED_LICENSE_QUOTA = -1; diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts index dc40d3357d..aaa530a39b 100644 --- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts @@ -3,8 +3,10 @@ import { mock } from 'jest-mock-extended'; import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { EventService } from '@/events/event.service'; -import type { ApiKeysRequest, AuthenticatedRequest } from '@/requests'; +import { License } from '@/license'; +import type { AuthenticatedRequest } from '@/requests'; import { PublicApiKeyService } from '@/services/public-api-key.service'; import { mockInstance } from '@test/mocking'; @@ -13,6 +15,8 @@ import { ApiKeysController } from '../api-keys.controller'; describe('ApiKeysController', () => { const publicApiKeyService = mockInstance(PublicApiKeyService); const eventService = mockInstance(EventService); + mockInstance(ApiKeyRepository); + mockInstance(License); const controller = Container.get(ApiKeysController); let req: AuthenticatedRequest; @@ -28,7 +32,7 @@ describe('ApiKeysController', () => { id: '123', userId: '123', label: 'My API Key', - apiKey: 'apiKey********', + apiKey: 'apiKey123', createdAt: new Date(), } as ApiKey; @@ -36,14 +40,25 @@ describe('ApiKeysController', () => { publicApiKeyService.createPublicApiKeyForUser.mockResolvedValue(apiKeyData); + publicApiKeyService.redactApiKey.mockImplementation(() => '***123'); + // Act - const newApiKey = await controller.createAPIKey(req); + const newApiKey = await controller.createAPIKey(req, mock(), mock()); // Assert expect(publicApiKeyService.createPublicApiKeyForUser).toHaveBeenCalled(); - expect(apiKeyData).toEqual(newApiKey); + expect(newApiKey).toEqual( + expect.objectContaining({ + id: '123', + userId: '123', + label: 'My API Key', + apiKey: '***123', + createdAt: expect.any(Date), + rawApiKey: 'apiKey123', + }), + ); expect(eventService.emit).toHaveBeenCalledWith( 'public-api-key-created', expect.objectContaining({ user: req.user, publicApi: false }), @@ -91,11 +106,11 @@ describe('ApiKeysController', () => { mfaEnabled: false, }); - const req = mock({ user, params: { id: user.id } }); + const req = mock({ user, params: { id: user.id } }); // Act - await controller.deleteAPIKey(req); + await controller.deleteAPIKey(req, mock(), user.id); publicApiKeyService.deleteApiKeyForUser.mockResolvedValue(); diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index db53a00449..17ed524b82 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -1,9 +1,13 @@ -import { type RequestHandler } from 'express'; +import { CreateOrUpdateApiKeyRequestDto } from '@n8n/api-types'; +import type { RequestHandler } from 'express'; -import { Delete, Get, Post, RestController } from '@/decorators'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; +import { Body, Delete, Get, Param, Patch, Post, RestController } from '@/decorators'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; +import { License } from '@/license'; import { isApiEnabled } from '@/public-api'; -import { ApiKeysRequest, AuthenticatedRequest } from '@/requests'; +import { AuthenticatedRequest } from '@/requests'; import { PublicApiKeyService } from '@/services/public-api-key.service'; export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { @@ -19,18 +23,36 @@ export class ApiKeysController { constructor( private readonly eventService: EventService, private readonly publicApiKeyService: PublicApiKeyService, + private readonly apiKeysRepository: ApiKeyRepository, + private readonly license: License, ) {} /** * Create an API Key */ @Post('/', { middlewares: [isApiEnabledMiddleware] }) - async createAPIKey(req: AuthenticatedRequest) { - const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user); + async createAPIKey( + req: AuthenticatedRequest, + _res: Response, + @Body payload: CreateOrUpdateApiKeyRequestDto, + ) { + const currentNumberOfApiKeys = await this.apiKeysRepository.countBy({ userId: req.user.id }); + + if (currentNumberOfApiKeys >= this.license.getApiKeysPerUserLimit()) { + throw new BadRequestError('You have reached the maximum number of API keys allowed.'); + } + + const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user, { + label: payload.label, + }); this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false }); - return newApiKey; + return { + ...newApiKey, + apiKey: this.publicApiKeyService.redactApiKey(newApiKey.apiKey), + rawApiKey: newApiKey.apiKey, + }; } /** @@ -46,11 +68,28 @@ export class ApiKeysController { * Delete an API Key */ @Delete('/:id', { middlewares: [isApiEnabledMiddleware] }) - async deleteAPIKey(req: ApiKeysRequest.DeleteAPIKey) { - await this.publicApiKeyService.deleteApiKeyForUser(req.user, req.params.id); + async deleteAPIKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) { + await this.publicApiKeyService.deleteApiKeyForUser(req.user, apiKeyId); this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false }); return { success: true }; } + + /** + * Patch an API Key + */ + @Patch('/:id', { middlewares: [isApiEnabledMiddleware] }) + async updateAPIKey( + req: AuthenticatedRequest, + _res: Response, + @Param('id') apiKeyId: string, + @Body payload: CreateOrUpdateApiKeyRequestDto, + ) { + await this.publicApiKeyService.updateApiKeyForUser(req.user, apiKeyId, { + label: payload.label, + }); + + return { success: true }; + } } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 025139aec2..90ba208ce1 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -110,6 +110,7 @@ export class E2EController { [LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1, [LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0, [LICENSE_QUOTAS.AI_CREDITS]: 0, + [LICENSE_QUOTAS.API_KEYS_PER_USER_LIMIT]: 1, }; private numericFeatures: Record = { @@ -123,6 +124,8 @@ export class E2EController { [LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT], [LICENSE_QUOTAS.AI_CREDITS]: E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.AI_CREDITS], + [LICENSE_QUOTAS.API_KEYS_PER_USER_LIMIT]: + E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.API_KEYS_PER_USER_LIMIT], }; constructor( diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index 7e1a443495..4a1b7d134e 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -358,6 +358,10 @@ export class License { return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; } + getApiKeysPerUserLimit() { + return this.getFeatureValue(LICENSE_QUOTAS.API_KEYS_PER_USER_LIMIT) ?? 1; + } + getTriggerLimit() { return this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index b9a5ae97a3..9c26f740bb 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -175,14 +175,6 @@ export declare namespace CredentialRequest { >; } -// ---------------------------------- -// /api-keys -// ---------------------------------- - -export declare namespace ApiKeysRequest { - export type DeleteAPIKey = AuthenticatedRequest<{ id: string }>; -} - // ---------------------------------- // /me // ---------------------------------- diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 27955a1d90..9db860b1e6 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -138,6 +138,7 @@ export class Server extends AbstractServer { if (!this.globalConfig.tags.disabled) { await import('@/controllers/tags.controller'); } + // ---------------------------------------- // SAML // ---------------------------------------- diff --git a/packages/cli/src/services/__tests__/public-api-key.service.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.test.ts index 7c60b62983..86db071f35 100644 --- a/packages/cli/src/services/__tests__/public-api-key.service.test.ts +++ b/packages/cli/src/services/__tests__/public-api-key.service.test.ts @@ -144,4 +144,28 @@ describe('PublicApiKeyService', () => { ); }); }); + + describe('redactApiKey', () => { + it('should redact api key', async () => { + //Arrange + + const jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0ODUxNDA5ODQsImlhdCI6MTQ4NTEzNzM4NCwiaXNzIjoiYWNtZS5jb20iLCJzdWIiOiIyOWFjMGMxOC0wYjRhLTQyY2YtODJmYy0wM2Q1NzAzMThhMWQiLCJhcHBsaWNhdGlvbklkIjoiNzkxMDM3MzQtOTdhYi00ZDFhLWFmMzctZTAwNmQwNWQyOTUyIiwicm9sZXMiOltdfQ.Mp0Pcwsz5VECK11Kf2ZZNF_SMKu5CgBeLN9ZOP04kZo'; + + const publicApiKeyService = new PublicApiKeyService( + apiKeyRepository, + userRepository, + jwtService, + eventService, + ); + + //Act + + const redactedApiKey = publicApiKeyService.redactApiKey(jwt); + + //Assert + + expect(redactedApiKey).toBe('******4kZo'); + }); + }); }); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 954c3e9fc3..ad94bb60ea 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -146,6 +146,7 @@ export class FrontendService { }, }, publicApi: { + apiKeysPerUserLimit: this.license.getApiKeysPerUserLimit(), enabled: isApiEnabled(), latestVersion: 1, path: this.globalConfig.publicApi.path, diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index a6b1133bc2..f2e43c3181 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -12,8 +12,8 @@ import { JwtService } from './jwt.service'; const API_KEY_AUDIENCE = 'public-api'; const API_KEY_ISSUER = 'n8n'; -const REDACT_API_KEY_REVEAL_COUNT = 15; -const REDACT_API_KEY_MAX_LENGTH = 80; +const REDACT_API_KEY_REVEAL_COUNT = 4; +const REDACT_API_KEY_MAX_LENGTH = 10; @Service() export class PublicApiKeyService { @@ -27,15 +27,14 @@ export class PublicApiKeyService { /** * Creates a new public API key for the specified user. * @param user - The user for whom the API key is being created. - * @returns A promise that resolves to the newly created API key. */ - async createPublicApiKeyForUser(user: User) { + async createPublicApiKeyForUser(user: User, { label }: { label: string }) { const apiKey = this.generateApiKey(user); await this.apiKeyRepository.upsert( this.apiKeyRepository.create({ userId: user.id, apiKey, - label: 'My API Key', + label, }), ['apiKey'], ); @@ -60,6 +59,10 @@ export class PublicApiKeyService { await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); } + async updateApiKeyForUser(user: User, apiKeyId: string, { label }: { label?: string } = {}) { + await this.apiKeyRepository.update({ id: apiKeyId, userId: user.id }, { label }); + } + private async getUserForApiKey(apiKey: string) { return await this.userRepository .createQueryBuilder('user') @@ -70,22 +73,24 @@ export class PublicApiKeyService { } /** - * Redacts an API key by keeping the first few characters and replacing the rest with asterisks. - * @param apiKey - The API key to be redacted. If null, the function returns undefined. - * @returns The redacted API key with a fixed prefix and asterisks replacing the rest of the characters. + * Redacts an API key by replacing a portion of it with asterisks. + * + * The function keeps the last `REDACT_API_KEY_REVEAL_COUNT` characters of the API key visible + * and replaces the rest with asterisks, up to a maximum length defined by `REDACT_API_KEY_MAX_LENGTH`. + * * @example * ```typescript * const redactedKey = PublicApiKeyService.redactApiKey('12345-abcdef-67890'); - * console.log(redactedKey); // Output: '12345-*****' + * console.log(redactedKey); // Output: '*****-67890' * ``` */ redactApiKey(apiKey: string) { - const visiblePart = apiKey.slice(0, REDACT_API_KEY_REVEAL_COUNT); - const redactedPart = '*'.repeat(apiKey.length - REDACT_API_KEY_REVEAL_COUNT); + const visiblePart = apiKey.slice(-REDACT_API_KEY_REVEAL_COUNT); + const redactedPart = '*'.repeat( + Math.max(0, REDACT_API_KEY_MAX_LENGTH - REDACT_API_KEY_REVEAL_COUNT), + ); - const completeRedactedApiKey = visiblePart + redactedPart; - - return completeRedactedApiKey.slice(0, REDACT_API_KEY_MAX_LENGTH); + return redactedPart + visiblePart; } getAuthMiddleware(version: string) { diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index 14050b543a..e1649d4b0b 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -1,7 +1,7 @@ +import type { ApiKeyWithRawValue } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; -import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { PublicApiKeyService } from '@/services/public-api-key.service'; @@ -57,9 +57,12 @@ describe('Owner shell', () => { }); test('POST /api-keys should create an api key', async () => { - const newApiKeyResponse = await testServer.authAgentFor(ownerShell).post('/api-keys'); + const newApiKeyResponse = await testServer + .authAgentFor(ownerShell) + .post('/api-keys') + .send({ label: 'My API Key' }); - const newApiKey = newApiKeyResponse.body.data as ApiKey; + const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKey).toBeDefined(); @@ -72,31 +75,50 @@ describe('Owner shell', () => { id: expect.any(String), label: 'My API Key', userId: ownerShell.id, - apiKey: newApiKey.apiKey, + apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), }); }); + test('POST /api-keys should fail if max number of API keys reached', async () => { + await testServer.authAgentFor(ownerShell).post('/api-keys').send({ label: 'My API Key' }); + + const secondApiKey = await testServer + .authAgentFor(ownerShell) + .post('/api-keys') + .send({ label: 'My API Key' }); + + expect(secondApiKey.statusCode).toBe(400); + }); + test('GET /api-keys should fetch the api key redacted', async () => { - const newApiKeyResponse = await testServer.authAgentFor(ownerShell).post('/api-keys'); + const newApiKeyResponse = await testServer + .authAgentFor(ownerShell) + .post('/api-keys') + .send({ label: 'My API Key' }); const retrieveAllApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys'); expect(retrieveAllApiKeysResponse.statusCode).toBe(200); + const redactedApiKey = publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.rawApiKey); + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ id: newApiKeyResponse.body.data.id, label: 'My API Key', userId: ownerShell.id, - apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey), + apiKey: redactedApiKey, createdAt: expect.any(String), updatedAt: expect.any(String), }); }); test('DELETE /api-keys/:id should delete the api key', async () => { - const newApiKeyResponse = await testServer.authAgentFor(ownerShell).post('/api-keys'); + const newApiKeyResponse = await testServer + .authAgentFor(ownerShell) + .post('/api-keys') + .send({ label: 'My API Key' }); const deleteApiKeyResponse = await testServer .authAgentFor(ownerShell) @@ -122,7 +144,10 @@ describe('Member', () => { }); test('POST /api-keys should create an api key', async () => { - const newApiKeyResponse = await testServer.authAgentFor(member).post('/api-keys'); + const newApiKeyResponse = await testServer + .authAgentFor(member) + .post('/api-keys') + .send({ label: 'My API Key' }); expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKeyResponse.body.data.apiKey).toBeDefined(); @@ -136,35 +161,54 @@ describe('Member', () => { id: expect.any(String), label: 'My API Key', userId: member.id, - apiKey: newApiKeyResponse.body.data.apiKey, + apiKey: newApiKeyResponse.body.data.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), }); }); + test('POST /api-keys should fail if max number of API keys reached', async () => { + await testServer.authAgentFor(member).post('/api-keys').send({ label: 'My API Key' }); + + const secondApiKey = await testServer + .authAgentFor(member) + .post('/api-keys') + .send({ label: 'My API Key' }); + + expect(secondApiKey.statusCode).toBe(400); + }); + test('GET /api-keys should fetch the api key redacted', async () => { - const newApiKeyResponse = await testServer.authAgentFor(member).post('/api-keys'); + const newApiKeyResponse = await testServer + .authAgentFor(member) + .post('/api-keys') + .send({ label: 'My API Key' }); const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/api-keys'); expect(retrieveAllApiKeysResponse.statusCode).toBe(200); + const redactedApiKey = publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.rawApiKey); + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ id: newApiKeyResponse.body.data.id, label: 'My API Key', userId: member.id, - apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey), + apiKey: redactedApiKey, createdAt: expect.any(String), updatedAt: expect.any(String), }); - expect(newApiKeyResponse.body.data.apiKey).not.toEqual( + expect(newApiKeyResponse.body.data.rawApiKey).not.toEqual( retrieveAllApiKeysResponse.body.data[0].apiKey, ); }); test('DELETE /api-keys/:id should delete the api key', async () => { - const newApiKeyResponse = await testServer.authAgentFor(member).post('/api-keys'); + const newApiKeyResponse = await testServer + .authAgentFor(member) + .post('/api-keys') + .send({ label: 'My API Key' }); const deleteApiKeyResponse = await testServer .authAgentFor(member) diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index bb4332f9de..88751fd727 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -81,7 +81,9 @@ export async function createUserWithMfaEnabled( } export const addApiKey = async (user: User) => { - return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user); + return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user, { + label: randomName(), + }); }; export async function createOwnerWithApiKey() { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 6cdd045703..bd207122b0 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1484,14 +1484,6 @@ export interface IN8nPromptResponse { updated: boolean; } -export type ApiKey = { - id: string; - label: string; - apiKey: string; - createdAt: string; - updatedAt: string; -}; - export type InputPanel = { nodeName?: string; run?: number; diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index 46e35a7d17..2272c2f40f 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -62,7 +62,13 @@ export const defaultSettings: FrontendSettings = { disableSessionRecording: false, enabled: false, }, - publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + publicApi: { + apiKeysPerUserLimit: 0, + enabled: false, + latestVersion: 0, + path: '', + swaggerUi: { enabled: false }, + }, pushBackend: 'websocket', saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', diff --git a/packages/editor-ui/src/api/api-keys.ts b/packages/editor-ui/src/api/api-keys.ts index b4b44c8e13..5ea2f593b7 100644 --- a/packages/editor-ui/src/api/api-keys.ts +++ b/packages/editor-ui/src/api/api-keys.ts @@ -1,12 +1,16 @@ -import type { ApiKey, IRestApiContext } from '@/Interface'; +import type { IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils/apiUtils'; +import type { CreateOrUpdateApiKeyRequestDto, ApiKey, ApiKeyWithRawValue } from '@n8n/api-types'; export async function getApiKeys(context: IRestApiContext): Promise { return await makeRestApiRequest(context, 'GET', '/api-keys'); } -export async function createApiKey(context: IRestApiContext): Promise { - return await makeRestApiRequest(context, 'POST', '/api-keys'); +export async function createApiKey( + context: IRestApiContext, + payload: CreateOrUpdateApiKeyRequestDto, +): Promise { + return await makeRestApiRequest(context, 'POST', '/api-keys', payload); } export async function deleteApiKey( @@ -15,3 +19,11 @@ export async function deleteApiKey( ): Promise<{ success: boolean }> { return await makeRestApiRequest(context, 'DELETE', `/api-keys/${id}`); } + +export async function updateApiKey( + context: IRestApiContext, + id: string, + payload: CreateOrUpdateApiKeyRequestDto, +): Promise<{ success: boolean }> { + return await makeRestApiRequest(context, 'PATCH', `/api-keys/${id}`, payload); +} diff --git a/packages/editor-ui/src/components/ApiKeyCard.vue b/packages/editor-ui/src/components/ApiKeyCard.vue new file mode 100644 index 0000000000..2167bede96 --- /dev/null +++ b/packages/editor-ui/src/components/ApiKeyCard.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts new file mode 100644 index 0000000000..781a8341bd --- /dev/null +++ b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts @@ -0,0 +1,119 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, STORES } from '@/constants'; +import { cleanupAppModals, createAppModals, mockedStore, retry } from '@/__tests__/utils'; +import ApiKeyEditModal from './ApiKeyCreateOrEditModal.vue'; +import { fireEvent } from '@testing-library/vue'; +import { useApiKeysStore } from '@/stores/apiKeys.store'; + +const renderComponent = createComponentRenderer(ApiKeyEditModal, { + pinia: createTestingPinia({ + initialState: { + [STORES.UI]: { + modalsById: { + [API_KEY_CREATE_OR_EDIT_MODAL_KEY]: { open: true }, + }, + }, + }, + }), +}); + +const apiKeysStore = mockedStore(useApiKeysStore); + +describe('ApiKeyCreateOrEditModal', () => { + beforeEach(() => { + createAppModals(); + }); + + afterEach(() => { + cleanupAppModals(); + vi.clearAllMocks(); + }); + + test('should allow creating API key from modal', async () => { + apiKeysStore.createApiKey.mockResolvedValue({ + id: '123', + label: 'new api key', + apiKey: '123456', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + rawApiKey: '***456', + }); + + const { getByText, getByPlaceholderText } = renderComponent({ + props: { + mode: 'new', + }, + }); + + await retry(() => expect(getByText('Create API Key')).toBeInTheDocument()); + expect(getByText('Label')).toBeInTheDocument(); + + const inputLabel = getByPlaceholderText('e.g Internal Project'); + const saveButton = getByText('Save'); + + expect(inputLabel).toBeInTheDocument(); + expect(saveButton).toBeInTheDocument(); + + await fireEvent.update(inputLabel, 'new label'); + + await fireEvent.click(saveButton); + + expect(getByText('***456')).toBeInTheDocument(); + + expect(getByText('API Key Created')).toBeInTheDocument(); + + expect(getByText('Done')).toBeInTheDocument(); + + expect( + getByText('Make sure to copy your API key now as you will not be able to see this again.'), + ).toBeInTheDocument(); + + expect(getByText('You can find more details in')).toBeInTheDocument(); + + expect(getByText('the API documentation')).toBeInTheDocument(); + + expect(getByText('Click to copy')).toBeInTheDocument(); + + expect(getByText('new api key')).toBeInTheDocument(); + }); + + test('should allow editing API key label', async () => { + apiKeysStore.apiKeys = [ + { + id: '123', + label: 'new api key', + apiKey: '123**', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + }, + ]; + + apiKeysStore.updateApiKey.mockResolvedValue(); + + const { getByText, getByTestId } = renderComponent({ + props: { + mode: 'edit', + activeId: '123', + }, + }); + + await retry(() => expect(getByText('Edit API Key')).toBeInTheDocument()); + + expect(getByText('Label')).toBeInTheDocument(); + + const labelInput = getByTestId('api-key-label'); + + expect((labelInput as unknown as HTMLInputElement).value).toBe('new api key'); + + await fireEvent.update(labelInput, 'updated api key'); + + const editButton = getByText('Edit'); + + expect(editButton).toBeInTheDocument(); + + await fireEvent.click(editButton); + + expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', { label: 'updated api key' }); + }); +}); diff --git a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue new file mode 100644 index 0000000000..04139e84ad --- /dev/null +++ b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/packages/editor-ui/src/components/Modal.vue b/packages/editor-ui/src/components/Modal.vue index 3f98e2a023..6fa9bf3994 100644 --- a/packages/editor-ui/src/components/Modal.vue +++ b/packages/editor-ui/src/components/Modal.vue @@ -31,6 +31,7 @@ const props = withDefaults( closeOnClickModal?: boolean; closeOnPressEscape?: boolean; appendToBody?: boolean; + lockScroll?: boolean; }>(), { title: '', @@ -46,6 +47,7 @@ const props = withDefaults( closeOnClickModal: true, closeOnPressEscape: true, appendToBody: false, + lockScroll: true, }, ); @@ -143,6 +145,7 @@ function getCustomClass() { :close-on-press-escape="closeOnPressEscape" :style="styles" :append-to="appendToBody ? undefined : appModalsId" + :lock-scroll="lockScroll" :append-to-body="appendToBody" :data-test-id="`${name}-modal`" :modal-class="center ? $style.center : ''" diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index fc7b2d67f1..10e54b141c 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -7,6 +7,7 @@ import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, + API_KEY_CREATE_OR_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DELETE_USER_MODAL_KEY, DUPLICATE_MODAL_KEY, @@ -54,6 +55,7 @@ import WorkflowSettings from '@/components/WorkflowSettings.vue'; import DeleteUserModal from '@/components/DeleteUserModal.vue'; import ActivationModal from '@/components/ActivationModal.vue'; import ImportCurlModal from '@/components/ImportCurlModal.vue'; +import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue'; import MfaSetupModal from '@/components/MfaSetupModal.vue'; import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue'; import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue'; @@ -83,6 +85,21 @@ import type { EventBus } from 'n8n-design-system'; + + + + + diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index cb741cbbf7..af6ab3d87a 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -42,6 +42,7 @@ export const ABOUT_MODAL_KEY = 'about'; export const CHAT_EMBED_MODAL_KEY = 'chatEmbed'; export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword'; export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential'; +export const API_KEY_CREATE_OR_EDIT_MODAL_KEY = 'createOrEditApiKey'; export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential'; export const DELETE_USER_MODAL_KEY = 'deleteUser'; export const INVITE_USER_MODAL_KEY = 'inviteUser'; @@ -660,6 +661,7 @@ export const enum STORES { ASSISTANT = 'assistant', BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator', PROJECTS = 'projects', + API_KEYS = 'apiKeys', TEST_DEFINITION = 'testDefinition', } diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 80dcf2905f..d3f88b0394 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1828,12 +1828,16 @@ "settings.api.create.description": "Control n8n programmatically using the n8n API", "settings.api.create.button": "Create an API Key", "settings.api.create.button.loading": "Creating API Key...", - "settings.api.create.error": "Creating the API Key failed.", + "settings.api.create.error": "API Key creation failed.", + "settings.api.edit.error": "API Key update failed.", "settings.api.delete.title": "Delete this API Key?", "settings.api.delete.description": "Any application using this API Key will no longer have access to n8n. This operation cannot be undone.", "settings.api.delete.button": "Delete Forever", "settings.api.delete.error": "Deleting the API Key failed.", "settings.api.delete.toast": "API Key deleted", + "settings.api.create.toast": "API Key created", + "settings.api.update.toast": "API Key updated", + "settings.api.creationTime": "Created {time}", "settings.api.view.copy.toast": "API Key copied to clipboard", "settings.api.view.apiPlayground": "API Playground", "settings.api.view.info": "Use your API Key to control n8n programmatically using the {apiAction}. But if you only want to trigger workflows, consider using the {webhookAction} instead.", @@ -1844,6 +1848,14 @@ "settings.api.view.more-details": "You can find more details in", "settings.api.view.external-docs": "the API documentation", "settings.api.view.error": "Could not check if an api key already exists.", + "settings.api.view.modal.form.label": "Label", + "settings.api.view.modal.form.label.placeholder": "e.g Internal Project", + "settings.api.view.modal.title.created": "API Key Created", + "settings.api.view.modal.title.create": "Create API Key", + "settings.api.view.modal.title.edit": "Edit API Key", + "settings.api.view.modal.done.button": "Done", + "settings.api.view.modal.edit.button": "Edit", + "settings.api.view.modal.save.button": "Save", "settings.version": "Version", "settings.usageAndPlan.title": "Usage and plan", "settings.usageAndPlan.description": "You’re on the {name} {type}", diff --git a/packages/editor-ui/src/stores/apiKeys.store.ts b/packages/editor-ui/src/stores/apiKeys.store.ts new file mode 100644 index 0000000000..826f7bb0d1 --- /dev/null +++ b/packages/editor-ui/src/stores/apiKeys.store.ts @@ -0,0 +1,67 @@ +import { STORES } from '@/constants'; +import { defineStore } from 'pinia'; +import { useRootStore } from '@/stores/root.store'; + +import * as publicApiApi from '@/api/api-keys'; +import { computed, ref } from 'vue'; +import { useSettingsStore } from './settings.store'; +import type { ApiKey } from '@n8n/api-types'; + +export const useApiKeysStore = defineStore(STORES.API_KEYS, () => { + const apiKeys = ref([]); + + const rootStore = useRootStore(); + const settingsStore = useSettingsStore(); + + const apiKeysSortByCreationDate = computed(() => + apiKeys.value.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), + ); + + const apiKeysById = computed(() => { + return apiKeys.value.reduce( + (acc, apiKey) => { + acc[apiKey.id] = apiKey; + return acc; + }, + {} as Record, + ); + }); + + const canAddMoreApiKeys = computed( + () => apiKeys.value.length < settingsStore.api.apiKeysPerUserLimit, + ); + + const getAndCacheApiKeys = async () => { + if (apiKeys.value.length) return apiKeys.value; + apiKeys.value = await publicApiApi.getApiKeys(rootStore.restApiContext); + return apiKeys.value; + }; + + const createApiKey = async (label: string) => { + const newApiKey = await publicApiApi.createApiKey(rootStore.restApiContext, { label }); + const { rawApiKey, ...rest } = newApiKey; + apiKeys.value.push(rest); + return newApiKey; + }; + + const deleteApiKey = async (id: string) => { + await publicApiApi.deleteApiKey(rootStore.restApiContext, id); + apiKeys.value = apiKeys.value.filter((apiKey) => apiKey.id !== id); + }; + + const updateApiKey = async (id: string, data: { label: string }) => { + await publicApiApi.updateApiKey(rootStore.restApiContext, id, data); + apiKeysById.value[id].label = data.label; + }; + + return { + getAndCacheApiKeys, + createApiKey, + deleteApiKey, + updateApiKey, + apiKeysSortByCreationDate, + apiKeysById, + apiKeys, + canAddMoreApiKeys, + }; +}); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 194e5b3cc4..1765925f1d 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -2,7 +2,6 @@ import { computed, ref } from 'vue'; import Bowser from 'bowser'; import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types'; -import * as publicApiApi from '@/api/api-keys'; import * as eventsApi from '@/api/events'; import * as ldapApi from '@/api/ldap'; import * as settingsApi from '@/api/settings'; @@ -32,6 +31,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { }); const templatesEndpointHealthy = ref(false); const api = ref({ + apiKeysPerUserLimit: 0, enabled: false, latestVersion: 0, path: '/', @@ -321,21 +321,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { templatesEndpointHealthy.value = true; }; - const getApiKeys = async () => { - const rootStore = useRootStore(); - return await publicApiApi.getApiKeys(rootStore.restApiContext); - }; - - const createApiKey = async () => { - const rootStore = useRootStore(); - return await publicApiApi.createApiKey(rootStore.restApiContext); - }; - - const deleteApiKey = async (id: string) => { - const rootStore = useRootStore(); - await publicApiApi.deleteApiKey(rootStore.restApiContext, id); - }; - const getLdapConfig = async () => { const rootStore = useRootStore(); return await ldapApi.getLdapConfig(rootStore.restApiContext); @@ -438,9 +423,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { updateLdapConfig, runLdapSync, getTimezones, - createApiKey, - getApiKeys, - deleteApiKey, testTemplatesEndpoint, submitContactInfo, disableTemplates, diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index c8d0939482..1784efc29e 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -36,6 +36,7 @@ import { NEW_ASSISTANT_SESSION_MODAL, PROMPT_MFA_CODE_MODAL_KEY, COMMUNITY_PLUS_ENROLLMENT_MODAL, + API_KEY_CREATE_OR_EDIT_MODAL_KEY, } from '@/constants'; import type { INodeUi, @@ -143,6 +144,13 @@ export const useUIStore = defineStore(STORES.UI, () => { open: false, data: undefined, }, + [API_KEY_CREATE_OR_EDIT_MODAL_KEY]: { + open: false, + data: { + activeId: null, + mode: '', + }, + }, [CREDENTIAL_EDIT_MODAL_KEY]: { open: false, mode: '', diff --git a/packages/editor-ui/src/views/SettingsApiView.test.ts b/packages/editor-ui/src/views/SettingsApiView.test.ts new file mode 100644 index 0000000000..ed8342739d --- /dev/null +++ b/packages/editor-ui/src/views/SettingsApiView.test.ts @@ -0,0 +1,105 @@ +import { fireEvent, screen } from '@testing-library/vue'; +import { useSettingsStore } from '@/stores/settings.store'; + +import { renderComponent } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; +import SettingsApiView from './SettingsApiView.vue'; +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; +import { setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { useApiKeysStore } from '@/stores/apiKeys.store'; + +setActivePinia(createTestingPinia()); + +const settingsStore = mockedStore(useSettingsStore); +const cloudStore = mockedStore(useCloudPlanStore); +const apiKeysStore = mockedStore(useApiKeysStore); + +describe('SettingsApiView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('if user public api is not enabled and user is trialing it should show upgrade call to action', () => { + settingsStore.isPublicApiEnabled = false; + cloudStore.userIsTrialing = true; + + renderComponent(SettingsApiView); + + expect(screen.getByText('Upgrade to use API')).toBeInTheDocument(); + expect( + screen.getByText( + 'To prevent abuse, we limit API access to your workspace during your trial. If this is hindering your evaluation of n8n, please contact', + ), + ).toBeInTheDocument(); + expect(screen.getByText('support@n8n.io')).toBeInTheDocument(); + + expect(screen.getByText('Upgrade plan')).toBeInTheDocument(); + }); + + it('if user public api enabled and no API keys in account, it should create API key CTA', () => { + settingsStore.isPublicApiEnabled = true; + cloudStore.userIsTrialing = false; + apiKeysStore.apiKeys = []; + + renderComponent(SettingsApiView); + + expect(screen.getByText('Create an API Key')).toBeInTheDocument(); + expect(screen.getByText('Control n8n programmatically using the')).toBeInTheDocument(); + expect(screen.getByText('n8n API')).toBeInTheDocument(); + }); + + it('if user public api enabled and there are API Keys in account, they should be rendered', async () => { + settingsStore.isPublicApiEnabled = true; + cloudStore.userIsTrialing = false; + apiKeysStore.apiKeys = [ + { + id: '1', + label: 'test-key-1', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + apiKey: '****Atcr', + }, + ]; + + renderComponent(SettingsApiView); + + expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument(); + expect(screen.getByText('****Atcr')).toBeInTheDocument(); + expect(screen.getByText('test-key-1')).toBeInTheDocument(); + + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('should show delete warning when trying to delete an API key', async () => { + settingsStore.isPublicApiEnabled = true; + cloudStore.userIsTrialing = false; + apiKeysStore.apiKeys = [ + { + id: '1', + label: 'test-key-1', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + apiKey: '****Atcr', + }, + ]; + + renderComponent(SettingsApiView); + + expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument(); + expect(screen.getByText('****Atcr')).toBeInTheDocument(); + expect(screen.getByText('test-key-1')).toBeInTheDocument(); + + await fireEvent.click(screen.getByTestId('action-toggle')); + await fireEvent.click(screen.getByTestId('action-delete')); + + expect(screen.getByText('Delete this API Key?')).toBeInTheDocument(); + expect( + screen.getByText( + 'Any application using this API Key will no longer have access to n8n. This operation cannot be undone.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/views/SettingsApiView.vue b/packages/editor-ui/src/views/SettingsApiView.vue index ba4030a8a7..5238d032b2 100644 --- a/packages/editor-ui/src/views/SettingsApiView.vue +++ b/packages/editor-ui/src/views/SettingsApiView.vue @@ -1,22 +1,22 @@ @@ -120,62 +107,29 @@ function onCopy() { + +