mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: Allow multi API creation via the UI (#12845)
This commit is contained in:
parent
c25c613a04
commit
ad3250ceb0
9
packages/@n8n/api-types/src/api-keys.ts
Normal file
9
packages/@n8n/api-types/src/api-keys.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export type ApiKey = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
apiKey: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string };
|
|
@ -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: '<script>alert("xss");new label</script>',
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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),
|
||||||
|
}) {}
|
|
@ -47,3 +47,5 @@ export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.d
|
||||||
|
|
||||||
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
|
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
|
||||||
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';
|
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';
|
||||||
|
|
||||||
|
export { CreateOrUpdateApiKeyRequestDto } from './api-keys/create-or-update-api-key-request.dto';
|
||||||
|
|
|
@ -87,6 +87,7 @@ export interface FrontendSettings {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
publicApi: {
|
publicApi: {
|
||||||
|
apiKeysPerUserLimit: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
latestVersion: number;
|
latestVersion: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
|
@ -4,6 +4,7 @@ export type * from './push';
|
||||||
export type * from './scaling';
|
export type * from './scaling';
|
||||||
export type * from './frontend-settings';
|
export type * from './frontend-settings';
|
||||||
export type * from './user';
|
export type * from './user';
|
||||||
|
export type * from './api-keys';
|
||||||
|
|
||||||
export type { Collaborator } from './push/collaboration';
|
export type { Collaborator } from './push/collaboration';
|
||||||
export type { SendWorkerStatusMessage } from './push/worker';
|
export type { SendWorkerStatusMessage } from './push/worker';
|
||||||
|
|
|
@ -104,6 +104,7 @@ export const LICENSE_QUOTAS = {
|
||||||
WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune',
|
WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune',
|
||||||
TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects',
|
TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects',
|
||||||
AI_CREDITS: 'quota:aiCredits',
|
AI_CREDITS: 'quota:aiCredits',
|
||||||
|
API_KEYS_PER_USER_LIMIT: 'quota:apiKeysPerUserLimit',
|
||||||
} as const;
|
} as const;
|
||||||
export const UNLIMITED_LICENSE_QUOTA = -1;
|
export const UNLIMITED_LICENSE_QUOTA = -1;
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,10 @@ import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
import type { ApiKey } from '@/databases/entities/api-key';
|
import type { ApiKey } from '@/databases/entities/api-key';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
||||||
import { EventService } from '@/events/event.service';
|
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 { PublicApiKeyService } from '@/services/public-api-key.service';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
|
@ -13,6 +15,8 @@ import { ApiKeysController } from '../api-keys.controller';
|
||||||
describe('ApiKeysController', () => {
|
describe('ApiKeysController', () => {
|
||||||
const publicApiKeyService = mockInstance(PublicApiKeyService);
|
const publicApiKeyService = mockInstance(PublicApiKeyService);
|
||||||
const eventService = mockInstance(EventService);
|
const eventService = mockInstance(EventService);
|
||||||
|
mockInstance(ApiKeyRepository);
|
||||||
|
mockInstance(License);
|
||||||
const controller = Container.get(ApiKeysController);
|
const controller = Container.get(ApiKeysController);
|
||||||
|
|
||||||
let req: AuthenticatedRequest;
|
let req: AuthenticatedRequest;
|
||||||
|
@ -28,7 +32,7 @@ describe('ApiKeysController', () => {
|
||||||
id: '123',
|
id: '123',
|
||||||
userId: '123',
|
userId: '123',
|
||||||
label: 'My API Key',
|
label: 'My API Key',
|
||||||
apiKey: 'apiKey********',
|
apiKey: 'apiKey123',
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
} as ApiKey;
|
} as ApiKey;
|
||||||
|
|
||||||
|
@ -36,14 +40,25 @@ describe('ApiKeysController', () => {
|
||||||
|
|
||||||
publicApiKeyService.createPublicApiKeyForUser.mockResolvedValue(apiKeyData);
|
publicApiKeyService.createPublicApiKeyForUser.mockResolvedValue(apiKeyData);
|
||||||
|
|
||||||
|
publicApiKeyService.redactApiKey.mockImplementation(() => '***123');
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
||||||
const newApiKey = await controller.createAPIKey(req);
|
const newApiKey = await controller.createAPIKey(req, mock(), mock());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
||||||
expect(publicApiKeyService.createPublicApiKeyForUser).toHaveBeenCalled();
|
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(
|
expect(eventService.emit).toHaveBeenCalledWith(
|
||||||
'public-api-key-created',
|
'public-api-key-created',
|
||||||
expect.objectContaining({ user: req.user, publicApi: false }),
|
expect.objectContaining({ user: req.user, publicApi: false }),
|
||||||
|
@ -91,11 +106,11 @@ describe('ApiKeysController', () => {
|
||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = mock<ApiKeysRequest.DeleteAPIKey>({ user, params: { id: user.id } });
|
const req = mock<AuthenticatedRequest>({ user, params: { id: user.id } });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
||||||
await controller.deleteAPIKey(req);
|
await controller.deleteAPIKey(req, mock(), user.id);
|
||||||
|
|
||||||
publicApiKeyService.deleteApiKeyForUser.mockResolvedValue();
|
publicApiKeyService.deleteApiKeyForUser.mockResolvedValue();
|
||||||
|
|
||||||
|
|
|
@ -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 { EventService } from '@/events/event.service';
|
||||||
|
import { License } from '@/license';
|
||||||
import { isApiEnabled } from '@/public-api';
|
import { isApiEnabled } from '@/public-api';
|
||||||
import { ApiKeysRequest, AuthenticatedRequest } from '@/requests';
|
import { AuthenticatedRequest } from '@/requests';
|
||||||
import { PublicApiKeyService } from '@/services/public-api-key.service';
|
import { PublicApiKeyService } from '@/services/public-api-key.service';
|
||||||
|
|
||||||
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
|
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
|
||||||
|
@ -19,18 +23,36 @@ export class ApiKeysController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventService: EventService,
|
private readonly eventService: EventService,
|
||||||
private readonly publicApiKeyService: PublicApiKeyService,
|
private readonly publicApiKeyService: PublicApiKeyService,
|
||||||
|
private readonly apiKeysRepository: ApiKeyRepository,
|
||||||
|
private readonly license: License,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an API Key
|
* Create an API Key
|
||||||
*/
|
*/
|
||||||
@Post('/', { middlewares: [isApiEnabledMiddleware] })
|
@Post('/', { middlewares: [isApiEnabledMiddleware] })
|
||||||
async createAPIKey(req: AuthenticatedRequest) {
|
async createAPIKey(
|
||||||
const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user);
|
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 });
|
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 an API Key
|
||||||
*/
|
*/
|
||||||
@Delete('/:id', { middlewares: [isApiEnabledMiddleware] })
|
@Delete('/:id', { middlewares: [isApiEnabledMiddleware] })
|
||||||
async deleteAPIKey(req: ApiKeysRequest.DeleteAPIKey) {
|
async deleteAPIKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) {
|
||||||
await this.publicApiKeyService.deleteApiKeyForUser(req.user, req.params.id);
|
await this.publicApiKeyService.deleteApiKeyForUser(req.user, apiKeyId);
|
||||||
|
|
||||||
this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false });
|
this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false });
|
||||||
|
|
||||||
return { success: true };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,6 +110,7 @@ export class E2EController {
|
||||||
[LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1,
|
[LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1,
|
||||||
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0,
|
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0,
|
||||||
[LICENSE_QUOTAS.AI_CREDITS]: 0,
|
[LICENSE_QUOTAS.AI_CREDITS]: 0,
|
||||||
|
[LICENSE_QUOTAS.API_KEYS_PER_USER_LIMIT]: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
private numericFeatures: Record<NumericLicenseFeature, number> = {
|
private numericFeatures: Record<NumericLicenseFeature, number> = {
|
||||||
|
@ -123,6 +124,8 @@ export class E2EController {
|
||||||
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]:
|
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]:
|
||||||
E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT],
|
E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT],
|
||||||
[LICENSE_QUOTAS.AI_CREDITS]: E2EController.numericFeaturesDefaults[LICENSE_QUOTAS.AI_CREDITS],
|
[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(
|
constructor(
|
||||||
|
|
|
@ -358,6 +358,10 @@ export class License {
|
||||||
return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
|
return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApiKeysPerUserLimit() {
|
||||||
|
return this.getFeatureValue(LICENSE_QUOTAS.API_KEYS_PER_USER_LIMIT) ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
getTriggerLimit() {
|
getTriggerLimit() {
|
||||||
return this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
|
return this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
|
||||||
}
|
}
|
||||||
|
|
|
@ -175,14 +175,6 @@ export declare namespace CredentialRequest {
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
|
||||||
// /api-keys
|
|
||||||
// ----------------------------------
|
|
||||||
|
|
||||||
export declare namespace ApiKeysRequest {
|
|
||||||
export type DeleteAPIKey = AuthenticatedRequest<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// /me
|
// /me
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -138,6 +138,7 @@ export class Server extends AbstractServer {
|
||||||
if (!this.globalConfig.tags.disabled) {
|
if (!this.globalConfig.tags.disabled) {
|
||||||
await import('@/controllers/tags.controller');
|
await import('@/controllers/tags.controller');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// SAML
|
// SAML
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -146,6 +146,7 @@ export class FrontendService {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
publicApi: {
|
publicApi: {
|
||||||
|
apiKeysPerUserLimit: this.license.getApiKeysPerUserLimit(),
|
||||||
enabled: isApiEnabled(),
|
enabled: isApiEnabled(),
|
||||||
latestVersion: 1,
|
latestVersion: 1,
|
||||||
path: this.globalConfig.publicApi.path,
|
path: this.globalConfig.publicApi.path,
|
||||||
|
|
|
@ -12,8 +12,8 @@ import { JwtService } from './jwt.service';
|
||||||
|
|
||||||
const API_KEY_AUDIENCE = 'public-api';
|
const API_KEY_AUDIENCE = 'public-api';
|
||||||
const API_KEY_ISSUER = 'n8n';
|
const API_KEY_ISSUER = 'n8n';
|
||||||
const REDACT_API_KEY_REVEAL_COUNT = 15;
|
const REDACT_API_KEY_REVEAL_COUNT = 4;
|
||||||
const REDACT_API_KEY_MAX_LENGTH = 80;
|
const REDACT_API_KEY_MAX_LENGTH = 10;
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class PublicApiKeyService {
|
export class PublicApiKeyService {
|
||||||
|
@ -27,15 +27,14 @@ export class PublicApiKeyService {
|
||||||
/**
|
/**
|
||||||
* Creates a new public API key for the specified user.
|
* Creates a new public API key for the specified user.
|
||||||
* @param user - The user for whom the API key is being created.
|
* @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);
|
const apiKey = this.generateApiKey(user);
|
||||||
await this.apiKeyRepository.upsert(
|
await this.apiKeyRepository.upsert(
|
||||||
this.apiKeyRepository.create({
|
this.apiKeyRepository.create({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
apiKey,
|
apiKey,
|
||||||
label: 'My API Key',
|
label,
|
||||||
}),
|
}),
|
||||||
['apiKey'],
|
['apiKey'],
|
||||||
);
|
);
|
||||||
|
@ -60,6 +59,10 @@ export class PublicApiKeyService {
|
||||||
await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId });
|
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) {
|
private async getUserForApiKey(apiKey: string) {
|
||||||
return await this.userRepository
|
return await this.userRepository
|
||||||
.createQueryBuilder('user')
|
.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.
|
* Redacts an API key by replacing a portion of it 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.
|
* 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
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* const redactedKey = PublicApiKeyService.redactApiKey('12345-abcdef-67890');
|
* const redactedKey = PublicApiKeyService.redactApiKey('12345-abcdef-67890');
|
||||||
* console.log(redactedKey); // Output: '12345-*****'
|
* console.log(redactedKey); // Output: '*****-67890'
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
redactApiKey(apiKey: string) {
|
redactApiKey(apiKey: string) {
|
||||||
const visiblePart = apiKey.slice(0, REDACT_API_KEY_REVEAL_COUNT);
|
const visiblePart = apiKey.slice(-REDACT_API_KEY_REVEAL_COUNT);
|
||||||
const redactedPart = '*'.repeat(apiKey.length - 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 redactedPart + visiblePart;
|
||||||
|
|
||||||
return completeRedactedApiKey.slice(0, REDACT_API_KEY_MAX_LENGTH);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthMiddleware(version: string) {
|
getAuthMiddleware(version: string) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
import type { ApiKeyWithRawValue } from '@n8n/api-types';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
|
||||||
import type { ApiKey } from '@/databases/entities/api-key';
|
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
||||||
import { PublicApiKeyService } from '@/services/public-api-key.service';
|
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 () => {
|
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(newApiKeyResponse.statusCode).toBe(200);
|
||||||
expect(newApiKey).toBeDefined();
|
expect(newApiKey).toBeDefined();
|
||||||
|
@ -72,31 +75,50 @@ describe('Owner shell', () => {
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
label: 'My API Key',
|
label: 'My API Key',
|
||||||
userId: ownerShell.id,
|
userId: ownerShell.id,
|
||||||
apiKey: newApiKey.apiKey,
|
apiKey: newApiKey.rawApiKey,
|
||||||
createdAt: expect.any(Date),
|
createdAt: expect.any(Date),
|
||||||
updatedAt: 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 () => {
|
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');
|
const retrieveAllApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys');
|
||||||
|
|
||||||
expect(retrieveAllApiKeysResponse.statusCode).toBe(200);
|
expect(retrieveAllApiKeysResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const redactedApiKey = publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.rawApiKey);
|
||||||
|
|
||||||
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
||||||
id: newApiKeyResponse.body.data.id,
|
id: newApiKeyResponse.body.data.id,
|
||||||
label: 'My API Key',
|
label: 'My API Key',
|
||||||
userId: ownerShell.id,
|
userId: ownerShell.id,
|
||||||
apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey),
|
apiKey: redactedApiKey,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DELETE /api-keys/:id should delete the api key', async () => {
|
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
|
const deleteApiKeyResponse = await testServer
|
||||||
.authAgentFor(ownerShell)
|
.authAgentFor(ownerShell)
|
||||||
|
@ -122,7 +144,10 @@ describe('Member', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /api-keys should create an api key', async () => {
|
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.statusCode).toBe(200);
|
||||||
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
|
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
|
||||||
|
@ -136,35 +161,54 @@ describe('Member', () => {
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
label: 'My API Key',
|
label: 'My API Key',
|
||||||
userId: member.id,
|
userId: member.id,
|
||||||
apiKey: newApiKeyResponse.body.data.apiKey,
|
apiKey: newApiKeyResponse.body.data.rawApiKey,
|
||||||
createdAt: expect.any(Date),
|
createdAt: expect.any(Date),
|
||||||
updatedAt: 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 () => {
|
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');
|
const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/api-keys');
|
||||||
|
|
||||||
expect(retrieveAllApiKeysResponse.statusCode).toBe(200);
|
expect(retrieveAllApiKeysResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const redactedApiKey = publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.rawApiKey);
|
||||||
|
|
||||||
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
||||||
id: newApiKeyResponse.body.data.id,
|
id: newApiKeyResponse.body.data.id,
|
||||||
label: 'My API Key',
|
label: 'My API Key',
|
||||||
userId: member.id,
|
userId: member.id,
|
||||||
apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey),
|
apiKey: redactedApiKey,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: 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,
|
retrieveAllApiKeysResponse.body.data[0].apiKey,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DELETE /api-keys/:id should delete the api key', async () => {
|
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
|
const deleteApiKeyResponse = await testServer
|
||||||
.authAgentFor(member)
|
.authAgentFor(member)
|
||||||
|
|
|
@ -81,7 +81,9 @@ export async function createUserWithMfaEnabled(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addApiKey = async (user: User) => {
|
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() {
|
export async function createOwnerWithApiKey() {
|
||||||
|
|
|
@ -1484,14 +1484,6 @@ export interface IN8nPromptResponse {
|
||||||
updated: boolean;
|
updated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiKey = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
apiKey: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InputPanel = {
|
export type InputPanel = {
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
run?: number;
|
run?: number;
|
||||||
|
|
|
@ -62,7 +62,13 @@ export const defaultSettings: FrontendSettings = {
|
||||||
disableSessionRecording: false,
|
disableSessionRecording: false,
|
||||||
enabled: 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',
|
pushBackend: 'websocket',
|
||||||
saveDataErrorExecution: 'all',
|
saveDataErrorExecution: 'all',
|
||||||
saveDataSuccessExecution: 'all',
|
saveDataSuccessExecution: 'all',
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
import type { ApiKey, IRestApiContext } from '@/Interface';
|
import type { IRestApiContext } from '@/Interface';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
|
import type { CreateOrUpdateApiKeyRequestDto, ApiKey, ApiKeyWithRawValue } from '@n8n/api-types';
|
||||||
|
|
||||||
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
|
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
|
||||||
return await makeRestApiRequest(context, 'GET', '/api-keys');
|
return await makeRestApiRequest(context, 'GET', '/api-keys');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApiKey(context: IRestApiContext): Promise<ApiKey> {
|
export async function createApiKey(
|
||||||
return await makeRestApiRequest(context, 'POST', '/api-keys');
|
context: IRestApiContext,
|
||||||
|
payload: CreateOrUpdateApiKeyRequestDto,
|
||||||
|
): Promise<ApiKeyWithRawValue> {
|
||||||
|
return await makeRestApiRequest(context, 'POST', '/api-keys', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(
|
export async function deleteApiKey(
|
||||||
|
@ -15,3 +19,11 @@ export async function deleteApiKey(
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
return await makeRestApiRequest(context, 'DELETE', `/api-keys/${id}`);
|
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);
|
||||||
|
}
|
||||||
|
|
115
packages/editor-ui/src/components/ApiKeyCard.vue
Normal file
115
packages/editor-ui/src/components/ApiKeyCard.vue
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { ApiKey } from '@n8n/api-types';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
const API_KEY_ITEM_ACTIONS = {
|
||||||
|
EDIT: 'edit',
|
||||||
|
DELETE: 'delete',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_LIST = [
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
value: API_KEY_ITEM_ACTIONS.EDIT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
value: API_KEY_ITEM_ACTIONS.DELETE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const cardActions = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
apiKey: ApiKey;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [id: string];
|
||||||
|
delete: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
async function onAction(action: string) {
|
||||||
|
if (action === API_KEY_ITEM_ACTIONS.EDIT) {
|
||||||
|
emit('edit', props.apiKey.id);
|
||||||
|
} else if (action === API_KEY_ITEM_ACTIONS.DELETE) {
|
||||||
|
emit('delete', props.apiKey.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApiCreationTime = (apiKey: ApiKey): string => {
|
||||||
|
const timeAgo = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toRelative() ?? '';
|
||||||
|
return i18n.baseText('settings.api.creationTime', { interpolate: { time: timeAgo } });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-card :class="$style.cardLink" data-test-id="api-key-card" @click="onAction('edit')">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<n8n-heading tag="h2" bold :class="$style.cardHeading">
|
||||||
|
{{ apiKey.label }}
|
||||||
|
</n8n-heading>
|
||||||
|
<div :class="$style.cardDescription">
|
||||||
|
<n8n-text color="text-light" size="small">
|
||||||
|
<span>{{ getApiCreationTime(apiKey) }}</span>
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="apiKey.apiKey.includes('*')" :class="$style.cardApiKey">
|
||||||
|
<n8n-text color="text-light" size="small"> {{ apiKey.apiKey }}</n8n-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div ref="cardActions" :class="$style.cardActions">
|
||||||
|
<n8n-action-toggle :actions="ACTION_LIST" theme="dark" @action="onAction" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n8n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.cardLink {
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0 0 var(--spacing-s);
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeading {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
word-break: word-break;
|
||||||
|
padding: var(--spacing-s) 0 0 var(--spacing-s);
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDescription {
|
||||||
|
min-height: 19px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 0 var(--spacing-s) var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--spacing-s) 0 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardApiKey {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
239
packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue
Normal file
239
packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Modal from '@/components/Modal.vue';
|
||||||
|
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, DOCS_DOMAIN } from '@/constants';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
|
import { useApiKeysStore } from '@/stores/apiKeys.store';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
import type { ApiKeyWithRawValue } from '@n8n/api-types';
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { showError, showMessage } = useToast();
|
||||||
|
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore;
|
||||||
|
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
|
||||||
|
const { baseUrl } = useRootStore();
|
||||||
|
const documentTitle = useDocumentTitle();
|
||||||
|
|
||||||
|
const label = ref('');
|
||||||
|
const modalBus = createEventBus();
|
||||||
|
const newApiKey = ref<ApiKeyWithRawValue | null>(null);
|
||||||
|
const apiDocsURL = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const rawApiKey = ref('');
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mode?: 'new' | 'edit';
|
||||||
|
activeId?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mode: 'new',
|
||||||
|
activeId: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
documentTitle.set(i18n.baseText('settings.api'));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.value?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (props.mode === 'edit') {
|
||||||
|
label.value = apiKeysById[props.activeId]?.label ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDocsURL.value = isSwaggerUIEnabled
|
||||||
|
? `${baseUrl}${publicApiPath}/v${publicApiLatestVersion}/docs`
|
||||||
|
: `https://${DOCS_DOMAIN}/api/api-reference/`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onInput(value: string): void {
|
||||||
|
label.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEdit() {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
await updateApiKey(props.activeId, { label: label.value });
|
||||||
|
showMessage({
|
||||||
|
type: 'success',
|
||||||
|
title: i18n.baseText('settings.api.update.toast'),
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, i18n.baseText('settings.api.edit.error'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
uiStore.closeModal(API_KEY_CREATE_OR_EDIT_MODAL_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!label.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
newApiKey.value = await createApiKey(label.value);
|
||||||
|
rawApiKey.value = newApiKey.value.rawApiKey;
|
||||||
|
|
||||||
|
showMessage({
|
||||||
|
type: 'success',
|
||||||
|
title: i18n.baseText('settings.api.create.toast'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, i18n.baseText('settings.api.create.error'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalTitle = computed(() => {
|
||||||
|
let path = 'edit';
|
||||||
|
if (props.mode === 'new') {
|
||||||
|
if (newApiKey.value) {
|
||||||
|
path = 'created';
|
||||||
|
} else {
|
||||||
|
path = 'create';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i18n.baseText(`settings.api.view.modal.title.${path}` as BaseTextKey);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="modalTitle"
|
||||||
|
:event-bus="modalBus"
|
||||||
|
:name="API_KEY_CREATE_OR_EDIT_MODAL_KEY"
|
||||||
|
width="600px"
|
||||||
|
:lock-scroll="false"
|
||||||
|
:close-on-esc="true"
|
||||||
|
:close-on-click-outside="true"
|
||||||
|
:show-close="true"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div>
|
||||||
|
<p v-if="newApiKey" class="mb-s">
|
||||||
|
<n8n-info-tip :bold="false">
|
||||||
|
<i18n-t keypath="settings.api.view.info" tag="span">
|
||||||
|
<template #apiAction>
|
||||||
|
<a
|
||||||
|
href="https://docs.n8n.io/api"
|
||||||
|
target="_blank"
|
||||||
|
v-text="i18n.baseText('settings.api.view.info.api')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #webhookAction>
|
||||||
|
<a
|
||||||
|
href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.webhook/"
|
||||||
|
target="_blank"
|
||||||
|
v-text="i18n.baseText('settings.api.view.info.webhook')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</n8n-info-tip>
|
||||||
|
</p>
|
||||||
|
<n8n-card v-if="newApiKey" class="mb-4xs" :class="$style.card">
|
||||||
|
<CopyInput
|
||||||
|
:label="newApiKey.label"
|
||||||
|
:value="newApiKey.rawApiKey"
|
||||||
|
:redact-value="true"
|
||||||
|
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
||||||
|
:toast-title="i18n.baseText('settings.api.view.copy.toast')"
|
||||||
|
:hint="i18n.baseText('settings.api.view.copy')"
|
||||||
|
/>
|
||||||
|
</n8n-card>
|
||||||
|
|
||||||
|
<div v-if="newApiKey" :class="$style.hint">
|
||||||
|
<n8n-text size="small">
|
||||||
|
{{
|
||||||
|
i18n.baseText(
|
||||||
|
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</n8n-text>
|
||||||
|
{{ ' ' }}
|
||||||
|
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
|
||||||
|
{{
|
||||||
|
i18n.baseText(
|
||||||
|
`settings.api.view.${isSwaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</n8n-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<N8nInputLabel
|
||||||
|
v-else
|
||||||
|
:label="i18n.baseText('settings.api.view.modal.form.label')"
|
||||||
|
color="text-dark"
|
||||||
|
>
|
||||||
|
<N8nInput
|
||||||
|
ref="inputRef"
|
||||||
|
required
|
||||||
|
:model-value="label"
|
||||||
|
type="text"
|
||||||
|
:placeholder="i18n.baseText('settings.api.view.modal.form.label.placeholder')"
|
||||||
|
:maxlength="50"
|
||||||
|
data-test-id="api-key-label"
|
||||||
|
@update:model-value="onInput"
|
||||||
|
/>
|
||||||
|
</N8nInputLabel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div>
|
||||||
|
<N8nButton
|
||||||
|
v-if="mode === 'new' && !newApiKey"
|
||||||
|
float="right"
|
||||||
|
:loading="loading"
|
||||||
|
:label="i18n.baseText('settings.api.view.modal.save.button')"
|
||||||
|
@click="onSave"
|
||||||
|
/>
|
||||||
|
<N8nButton
|
||||||
|
v-else-if="mode === 'new'"
|
||||||
|
float="right"
|
||||||
|
:label="i18n.baseText('settings.api.view.modal.done.button')"
|
||||||
|
@click="closeModal"
|
||||||
|
/>
|
||||||
|
<N8nButton
|
||||||
|
v-else-if="mode === 'edit'"
|
||||||
|
float="right"
|
||||||
|
:label="i18n.baseText('settings.api.view.modal.edit.button')"
|
||||||
|
@click="onEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.card {
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -31,6 +31,7 @@ const props = withDefaults(
|
||||||
closeOnClickModal?: boolean;
|
closeOnClickModal?: boolean;
|
||||||
closeOnPressEscape?: boolean;
|
closeOnPressEscape?: boolean;
|
||||||
appendToBody?: boolean;
|
appendToBody?: boolean;
|
||||||
|
lockScroll?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
|
@ -46,6 +47,7 @@ const props = withDefaults(
|
||||||
closeOnClickModal: true,
|
closeOnClickModal: true,
|
||||||
closeOnPressEscape: true,
|
closeOnPressEscape: true,
|
||||||
appendToBody: false,
|
appendToBody: false,
|
||||||
|
lockScroll: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -143,6 +145,7 @@ function getCustomClass() {
|
||||||
:close-on-press-escape="closeOnPressEscape"
|
:close-on-press-escape="closeOnPressEscape"
|
||||||
:style="styles"
|
:style="styles"
|
||||||
:append-to="appendToBody ? undefined : appModalsId"
|
:append-to="appendToBody ? undefined : appModalsId"
|
||||||
|
:lock-scroll="lockScroll"
|
||||||
:append-to-body="appendToBody"
|
:append-to-body="appendToBody"
|
||||||
:data-test-id="`${name}-modal`"
|
:data-test-id="`${name}-modal`"
|
||||||
:modal-class="center ? $style.center : ''"
|
:modal-class="center ? $style.center : ''"
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
|
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
|
||||||
CONTACT_PROMPT_MODAL_KEY,
|
CONTACT_PROMPT_MODAL_KEY,
|
||||||
CREDENTIAL_EDIT_MODAL_KEY,
|
CREDENTIAL_EDIT_MODAL_KEY,
|
||||||
|
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
||||||
CREDENTIAL_SELECT_MODAL_KEY,
|
CREDENTIAL_SELECT_MODAL_KEY,
|
||||||
DELETE_USER_MODAL_KEY,
|
DELETE_USER_MODAL_KEY,
|
||||||
DUPLICATE_MODAL_KEY,
|
DUPLICATE_MODAL_KEY,
|
||||||
|
@ -54,6 +55,7 @@ import WorkflowSettings from '@/components/WorkflowSettings.vue';
|
||||||
import DeleteUserModal from '@/components/DeleteUserModal.vue';
|
import DeleteUserModal from '@/components/DeleteUserModal.vue';
|
||||||
import ActivationModal from '@/components/ActivationModal.vue';
|
import ActivationModal from '@/components/ActivationModal.vue';
|
||||||
import ImportCurlModal from '@/components/ImportCurlModal.vue';
|
import ImportCurlModal from '@/components/ImportCurlModal.vue';
|
||||||
|
import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue';
|
||||||
import MfaSetupModal from '@/components/MfaSetupModal.vue';
|
import MfaSetupModal from '@/components/MfaSetupModal.vue';
|
||||||
import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue';
|
import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue';
|
||||||
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
||||||
|
@ -83,6 +85,21 @@ import type { EventBus } from 'n8n-design-system';
|
||||||
<CredentialEdit :modal-name="modalName" :mode="mode" :active-id="activeId" />
|
<CredentialEdit :modal-name="modalName" :mode="mode" :active-id="activeId" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="API_KEY_CREATE_OR_EDIT_MODAL_KEY">
|
||||||
|
<template
|
||||||
|
#default="{
|
||||||
|
modalName,
|
||||||
|
data: { mode, activeId },
|
||||||
|
}: {
|
||||||
|
modalName: string;
|
||||||
|
data: { mode: 'new' | 'edit'; activeId: string };
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ApiKeyCreateOrEditModal :modal-name="modalName" :mode="mode" :active-id="activeId" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="ABOUT_MODAL_KEY">
|
<ModalRoot :name="ABOUT_MODAL_KEY">
|
||||||
<AboutModal />
|
<AboutModal />
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
|
@ -42,6 +42,7 @@ export const ABOUT_MODAL_KEY = 'about';
|
||||||
export const CHAT_EMBED_MODAL_KEY = 'chatEmbed';
|
export const CHAT_EMBED_MODAL_KEY = 'chatEmbed';
|
||||||
export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword';
|
export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword';
|
||||||
export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
|
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 CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
|
||||||
export const DELETE_USER_MODAL_KEY = 'deleteUser';
|
export const DELETE_USER_MODAL_KEY = 'deleteUser';
|
||||||
export const INVITE_USER_MODAL_KEY = 'inviteUser';
|
export const INVITE_USER_MODAL_KEY = 'inviteUser';
|
||||||
|
@ -660,6 +661,7 @@ export const enum STORES {
|
||||||
ASSISTANT = 'assistant',
|
ASSISTANT = 'assistant',
|
||||||
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
|
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
|
||||||
PROJECTS = 'projects',
|
PROJECTS = 'projects',
|
||||||
|
API_KEYS = 'apiKeys',
|
||||||
TEST_DEFINITION = 'testDefinition',
|
TEST_DEFINITION = 'testDefinition',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1828,12 +1828,16 @@
|
||||||
"settings.api.create.description": "Control n8n programmatically using the <a href=\"https://docs.n8n.io/api\" target=\"_blank\">n8n API</a>",
|
"settings.api.create.description": "Control n8n programmatically using the <a href=\"https://docs.n8n.io/api\" target=\"_blank\">n8n API</a>",
|
||||||
"settings.api.create.button": "Create an API Key",
|
"settings.api.create.button": "Create an API Key",
|
||||||
"settings.api.create.button.loading": "Creating 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.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.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.button": "Delete Forever",
|
||||||
"settings.api.delete.error": "Deleting the API Key failed.",
|
"settings.api.delete.error": "Deleting the API Key failed.",
|
||||||
"settings.api.delete.toast": "API Key deleted",
|
"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.copy.toast": "API Key copied to clipboard",
|
||||||
"settings.api.view.apiPlayground": "API Playground",
|
"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.",
|
"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.more-details": "You can find more details in",
|
||||||
"settings.api.view.external-docs": "the API documentation",
|
"settings.api.view.external-docs": "the API documentation",
|
||||||
"settings.api.view.error": "Could not check if an api key already exists.",
|
"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.version": "Version",
|
||||||
"settings.usageAndPlan.title": "Usage and plan",
|
"settings.usageAndPlan.title": "Usage and plan",
|
||||||
"settings.usageAndPlan.description": "You’re on the {name} {type}",
|
"settings.usageAndPlan.description": "You’re on the {name} {type}",
|
||||||
|
|
67
packages/editor-ui/src/stores/apiKeys.store.ts
Normal file
67
packages/editor-ui/src/stores/apiKeys.store.ts
Normal file
|
@ -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<ApiKey[]>([]);
|
||||||
|
|
||||||
|
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<string, ApiKey>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
|
@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
|
||||||
import Bowser from 'bowser';
|
import Bowser from 'bowser';
|
||||||
import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types';
|
import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types';
|
||||||
|
|
||||||
import * as publicApiApi from '@/api/api-keys';
|
|
||||||
import * as eventsApi from '@/api/events';
|
import * as eventsApi from '@/api/events';
|
||||||
import * as ldapApi from '@/api/ldap';
|
import * as ldapApi from '@/api/ldap';
|
||||||
import * as settingsApi from '@/api/settings';
|
import * as settingsApi from '@/api/settings';
|
||||||
|
@ -32,6 +31,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||||
});
|
});
|
||||||
const templatesEndpointHealthy = ref(false);
|
const templatesEndpointHealthy = ref(false);
|
||||||
const api = ref({
|
const api = ref({
|
||||||
|
apiKeysPerUserLimit: 0,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
latestVersion: 0,
|
latestVersion: 0,
|
||||||
path: '/',
|
path: '/',
|
||||||
|
@ -321,21 +321,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||||
templatesEndpointHealthy.value = true;
|
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 getLdapConfig = async () => {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
return await ldapApi.getLdapConfig(rootStore.restApiContext);
|
return await ldapApi.getLdapConfig(rootStore.restApiContext);
|
||||||
|
@ -438,9 +423,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||||
updateLdapConfig,
|
updateLdapConfig,
|
||||||
runLdapSync,
|
runLdapSync,
|
||||||
getTimezones,
|
getTimezones,
|
||||||
createApiKey,
|
|
||||||
getApiKeys,
|
|
||||||
deleteApiKey,
|
|
||||||
testTemplatesEndpoint,
|
testTemplatesEndpoint,
|
||||||
submitContactInfo,
|
submitContactInfo,
|
||||||
disableTemplates,
|
disableTemplates,
|
||||||
|
|
|
@ -36,6 +36,7 @@ import {
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
PROMPT_MFA_CODE_MODAL_KEY,
|
PROMPT_MFA_CODE_MODAL_KEY,
|
||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
INodeUi,
|
INodeUi,
|
||||||
|
@ -143,6 +144,13 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||||
open: false,
|
open: false,
|
||||||
data: undefined,
|
data: undefined,
|
||||||
},
|
},
|
||||||
|
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
data: {
|
||||||
|
activeId: null,
|
||||||
|
mode: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
[CREDENTIAL_EDIT_MODAL_KEY]: {
|
[CREDENTIAL_EDIT_MODAL_KEY]: {
|
||||||
open: false,
|
open: false,
|
||||||
mode: '',
|
mode: '',
|
||||||
|
|
105
packages/editor-ui/src/views/SettingsApiView.test.ts
Normal file
105
packages/editor-ui/src/views/SettingsApiView.test.ts
Normal file
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,22 +1,22 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import type { ApiKey } from '@/Interface';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
|
|
||||||
import CopyInput from '@/components/CopyInput.vue';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
|
||||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
import { DOCS_DOMAIN, MODAL_CONFIRM } from '@/constants';
|
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, MODAL_CONFIRM } from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useApiKeysStore } from '@/stores/apiKeys.store';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
const cloudPlanStore = useCloudPlanStore();
|
const cloudPlanStore = useCloudPlanStore();
|
||||||
const { baseUrl } = useRootStore();
|
|
||||||
|
|
||||||
const { showError, showMessage } = useToast();
|
const { showError, showMessage } = useToast();
|
||||||
const { confirm } = useMessage();
|
const { confirm } = useMessage();
|
||||||
|
@ -26,33 +26,45 @@ const { goToUpgrade } = usePageRedirectionHelper();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const mounted = ref(false);
|
const apiKeysStore = useApiKeysStore();
|
||||||
const apiKeys = ref<ApiKey[]>([]);
|
const { getAndCacheApiKeys, deleteApiKey } = apiKeysStore;
|
||||||
const apiDocsURL = ref('');
|
const { apiKeysSortByCreationDate } = storeToRefs(apiKeysStore);
|
||||||
|
|
||||||
const { isPublicApiEnabled, isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } =
|
const { isPublicApiEnabled } = settingsStore;
|
||||||
settingsStore;
|
|
||||||
|
|
||||||
const isRedactedApiKey = computed((): boolean => {
|
const onCreateApiKey = async () => {
|
||||||
if (!apiKeys.value) return false;
|
telemetry.track('User clicked create API key button');
|
||||||
return apiKeys.value[0].apiKey.includes('*');
|
|
||||||
|
uiStore.openModalWithData({
|
||||||
|
name: API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
||||||
|
data: { mode: 'new' },
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
documentTitle.set(i18n.baseText('settings.api'));
|
documentTitle.set(i18n.baseText('settings.api'));
|
||||||
|
|
||||||
if (!isPublicApiEnabled) return;
|
if (!isPublicApiEnabled) return;
|
||||||
|
|
||||||
void getApiKeys();
|
await getApiKeys();
|
||||||
apiDocsURL.value = isSwaggerUIEnabled
|
|
||||||
? `${baseUrl}${publicApiPath}/v${publicApiLatestVersion}/docs`
|
|
||||||
: `https://${DOCS_DOMAIN}/api/api-reference/`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function onUpgrade() {
|
function onUpgrade() {
|
||||||
void goToUpgrade('settings-n8n-api', 'upgrade-api', 'redirect');
|
void goToUpgrade('settings-n8n-api', 'upgrade-api', 'redirect');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showDeleteModal() {
|
async function getApiKeys() {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
await getAndCacheApiKeys();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, i18n.baseText('settings.api.view.error'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
const confirmed = await confirm(
|
const confirmed = await confirm(
|
||||||
i18n.baseText('settings.api.delete.description'),
|
i18n.baseText('settings.api.delete.description'),
|
||||||
i18n.baseText('settings.api.delete.title'),
|
i18n.baseText('settings.api.delete.title'),
|
||||||
|
@ -61,52 +73,27 @@ async function showDeleteModal() {
|
||||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed === MODAL_CONFIRM) {
|
if (confirmed === MODAL_CONFIRM) {
|
||||||
await deleteApiKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getApiKeys() {
|
|
||||||
try {
|
try {
|
||||||
apiKeys.value = await settingsStore.getApiKeys();
|
await deleteApiKey(id);
|
||||||
} catch (error) {
|
|
||||||
showError(error, i18n.baseText('settings.api.view.error'));
|
|
||||||
} finally {
|
|
||||||
mounted.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createApiKey() {
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newApiKey = await settingsStore.createApiKey();
|
|
||||||
apiKeys.value.push(newApiKey);
|
|
||||||
} catch (error) {
|
|
||||||
showError(error, i18n.baseText('settings.api.create.error'));
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
telemetry.track('User clicked create API key button');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteApiKey() {
|
|
||||||
try {
|
|
||||||
await settingsStore.deleteApiKey(apiKeys.value[0].id);
|
|
||||||
showMessage({
|
showMessage({
|
||||||
title: i18n.baseText('settings.api.delete.toast'),
|
title: i18n.baseText('settings.api.delete.toast'),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
apiKeys.value = [];
|
} catch (e) {
|
||||||
} catch (error) {
|
showError(e, i18n.baseText('settings.api.delete.error'));
|
||||||
showError(error, i18n.baseText('settings.api.delete.error'));
|
|
||||||
} finally {
|
} finally {
|
||||||
telemetry.track('User clicked delete API key button');
|
telemetry.track('User clicked delete API key button');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onCopy() {
|
function onEdit(id: string) {
|
||||||
telemetry.track('User clicked copy API key button');
|
uiStore.openModalWithData({
|
||||||
|
name: API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
||||||
|
data: { mode: 'edit', activeId: id },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -120,62 +107,29 @@ function onCopy() {
|
||||||
</span>
|
</span>
|
||||||
</n8n-heading>
|
</n8n-heading>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="apiKeysSortByCreationDate.length">
|
||||||
|
<el-row
|
||||||
|
v-for="apiKey in apiKeysSortByCreationDate"
|
||||||
|
:key="apiKey.id"
|
||||||
|
:gutter="10"
|
||||||
|
:class="$style.destinationItem"
|
||||||
|
>
|
||||||
|
<el-col>
|
||||||
|
<ApiKeyCard :api-key="apiKey" @delete="onDelete" @edit="onEdit" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<div v-if="apiKeys.length">
|
<div class="mt-m text-right">
|
||||||
<p class="mb-s">
|
<n8n-button
|
||||||
<n8n-info-tip :bold="false">
|
size="large"
|
||||||
<i18n-t keypath="settings.api.view.info" tag="span">
|
:disabled="!apiKeysStore.canAddMoreApiKeys"
|
||||||
<template #apiAction>
|
@click="onCreateApiKey"
|
||||||
<a
|
>
|
||||||
href="https://docs.n8n.io/api"
|
{{ i18n.baseText('settings.api.create.button') }}
|
||||||
target="_blank"
|
</n8n-button>
|
||||||
v-text="i18n.baseText('settings.api.view.info.api')"
|
</div>
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<template #webhookAction>
|
|
||||||
<a
|
|
||||||
href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.webhook/"
|
|
||||||
target="_blank"
|
|
||||||
v-text="i18n.baseText('settings.api.view.info.webhook')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</n8n-info-tip>
|
|
||||||
</p>
|
|
||||||
<n8n-card class="mb-4xs" :class="$style.card">
|
|
||||||
<span :class="$style.delete">
|
|
||||||
<n8n-link :bold="true" @click="showDeleteModal">
|
|
||||||
{{ i18n.baseText('generic.delete') }}
|
|
||||||
</n8n-link>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<CopyInput
|
|
||||||
:label="apiKeys[0].label"
|
|
||||||
:value="apiKeys[0].apiKey"
|
|
||||||
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
|
||||||
:toast-title="i18n.baseText('settings.api.view.copy.toast')"
|
|
||||||
:redact-value="true"
|
|
||||||
:disable-copy="isRedactedApiKey"
|
|
||||||
:hint="!isRedactedApiKey ? i18n.baseText('settings.api.view.copy') : ''"
|
|
||||||
@copy="onCopy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</n8n-card>
|
|
||||||
<div :class="$style.hint">
|
|
||||||
<n8n-text size="small">
|
|
||||||
{{ i18n.baseText(`settings.api.view.${isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`) }}
|
|
||||||
</n8n-text>
|
|
||||||
{{ ' ' }}
|
|
||||||
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
|
|
||||||
{{
|
|
||||||
i18n.baseText(
|
|
||||||
`settings.api.view.${isSwaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</n8n-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
v-else-if="!isPublicApiEnabled && cloudPlanStore.userIsTrialing"
|
v-else-if="!isPublicApiEnabled && cloudPlanStore.userIsTrialing"
|
||||||
data-test-id="public-api-upgrade-cta"
|
data-test-id="public-api-upgrade-cta"
|
||||||
|
@ -185,27 +139,22 @@ function onCopy() {
|
||||||
@click:button="onUpgrade"
|
@click:button="onUpgrade"
|
||||||
/>
|
/>
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
v-else-if="mounted && !cloudPlanStore.state.loadingPlan"
|
v-if="isPublicApiEnabled && !apiKeysSortByCreationDate.length"
|
||||||
:button-text="
|
:button-text="
|
||||||
i18n.baseText(loading ? 'settings.api.create.button.loading' : 'settings.api.create.button')
|
i18n.baseText(loading ? 'settings.api.create.button.loading' : 'settings.api.create.button')
|
||||||
"
|
"
|
||||||
:description="i18n.baseText('settings.api.create.description')"
|
:description="i18n.baseText('settings.api.create.description')"
|
||||||
@click:button="createApiKey"
|
@click:button="onCreateApiKey"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.container {
|
|
||||||
> * {
|
|
||||||
margin-bottom: var(--spacing-2xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
|
||||||
*:first-child {
|
*:first-child {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -216,6 +165,10 @@ function onCopy() {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.destinationItem {
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
.delete {
|
.delete {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
Loading…
Reference in a new issue