mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: Allow setting API keys expiration (#12954)
This commit is contained in:
parent
e39928dde8
commit
9bcbc2c2cc
|
@ -1,9 +1,14 @@
|
|||
/** Unix timestamp. Seconds since epoch */
|
||||
export type UnixTimestamp = number | null;
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
label: string;
|
||||
apiKey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Null if API key never expires */
|
||||
expiresAt: UnixTimestamp | null;
|
||||
};
|
||||
|
||||
export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string };
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import { CreateApiKeyRequestDto } from '../create-api-key-request.dto';
|
||||
|
||||
describe('CreateApiKeyRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'expiresAt in the future',
|
||||
expiresAt: Date.now() / 1000 + 1000,
|
||||
},
|
||||
{
|
||||
name: 'expiresAt null',
|
||||
expiresAt: null,
|
||||
},
|
||||
])('should succeed validation for $name', ({ expiresAt }) => {
|
||||
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'expiresAt in the past',
|
||||
expiresAt: Date.now() / 1000 - 1000,
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt with string',
|
||||
expiresAt: 'invalid',
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt with []',
|
||||
expiresAt: [],
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt with {}',
|
||||
expiresAt: {},
|
||||
expectedErrorPath: ['expiresAt'],
|
||||
},
|
||||
])('should fail validation for $name', ({ expiresAt, expectedErrorPath }) => {
|
||||
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,9 +1,9 @@
|
|||
import { CreateOrUpdateApiKeyRequestDto } from '../create-or-update-api-key-request.dto';
|
||||
import { UpdateApiKeyRequestDto } from '../update-api-key-request.dto';
|
||||
|
||||
describe('CreateOrUpdateApiKeyRequestDto', () => {
|
||||
describe('UpdateApiKeyRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test('should allow valid label', () => {
|
||||
const result = CreateOrUpdateApiKeyRequestDto.safeParse({
|
||||
const result = UpdateApiKeyRequestDto.safeParse({
|
||||
label: 'valid label',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
@ -28,7 +28,7 @@ describe('CreateOrUpdateApiKeyRequestDto', () => {
|
|||
expectedErrorPath: ['label'],
|
||||
},
|
||||
])('should fail validation for $name', ({ label, expectedErrorPath }) => {
|
||||
const result = CreateOrUpdateApiKeyRequestDto.safeParse({ label });
|
||||
const result = UpdateApiKeyRequestDto.safeParse({ label });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { UpdateApiKeyRequestDto } from './update-api-key-request.dto';
|
||||
|
||||
const isTimeNullOrInFuture = (value: number | null) => {
|
||||
if (!value) return true;
|
||||
return value > Date.now() / 1000;
|
||||
};
|
||||
|
||||
export class CreateApiKeyRequestDto extends UpdateApiKeyRequestDto.extend({
|
||||
expiresAt: z
|
||||
.number()
|
||||
.nullable()
|
||||
.refine(isTimeNullOrInFuture, { message: 'Expiration date must be in the future or null' }),
|
||||
}) {}
|
|
@ -8,6 +8,6 @@ const xssCheck = (value: string) =>
|
|||
whiteList: {},
|
||||
});
|
||||
|
||||
export class CreateOrUpdateApiKeyRequestDto extends Z.class({
|
||||
export class UpdateApiKeyRequestDto extends Z.class({
|
||||
label: z.string().max(50).min(1).refine(xssCheck),
|
||||
}) {}
|
|
@ -49,4 +49,5 @@ export { ManualRunQueryDto } from './workflows/manual-run-query.dto';
|
|||
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';
|
||||
export { UpdateApiKeyRequestDto } from './api-keys/update-api-key-request.dto';
|
||||
export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto';
|
||||
|
|
|
@ -79,7 +79,9 @@ describe('ApiKeysController', () => {
|
|||
updatedAt: new Date(),
|
||||
} as ApiKey;
|
||||
|
||||
publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue([apiKeyData]);
|
||||
publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue([
|
||||
{ ...apiKeyData, expiresAt: null },
|
||||
]);
|
||||
|
||||
// Act
|
||||
|
||||
|
@ -87,7 +89,7 @@ describe('ApiKeysController', () => {
|
|||
|
||||
// Assert
|
||||
|
||||
expect(apiKeys).toEqual([apiKeyData]);
|
||||
expect(apiKeys).toEqual([{ ...apiKeyData, expiresAt: null }]);
|
||||
expect(publicApiKeyService.getRedactedApiKeysForUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: req.user.id }),
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CreateOrUpdateApiKeyRequestDto } from '@n8n/api-types';
|
||||
import { CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
||||
|
@ -34,7 +34,7 @@ export class ApiKeysController {
|
|||
async createAPIKey(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Body payload: CreateOrUpdateApiKeyRequestDto,
|
||||
@Body { label, expiresAt }: CreateApiKeyRequestDto,
|
||||
) {
|
||||
const currentNumberOfApiKeys = await this.apiKeysRepository.countBy({ userId: req.user.id });
|
||||
|
||||
|
@ -43,7 +43,8 @@ export class ApiKeysController {
|
|||
}
|
||||
|
||||
const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user, {
|
||||
label: payload.label,
|
||||
label,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false });
|
||||
|
@ -52,6 +53,7 @@ export class ApiKeysController {
|
|||
...newApiKey,
|
||||
apiKey: this.publicApiKeyService.redactApiKey(newApiKey.apiKey),
|
||||
rawApiKey: newApiKey.apiKey,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -84,10 +86,10 @@ export class ApiKeysController {
|
|||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') apiKeyId: string,
|
||||
@Body payload: CreateOrUpdateApiKeyRequestDto,
|
||||
@Body { label }: UpdateApiKeyRequestDto,
|
||||
) {
|
||||
await this.publicApiKeyService.updateApiKeyForUser(req.user, apiKeyId, {
|
||||
label: payload.label,
|
||||
label,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import type { UnixTimestamp, UpdateApiKeyRequestDto } from '@n8n/api-types';
|
||||
import type { CreateApiKeyRequestDto } from '@n8n/api-types/src/dto/api-keys/create-api-key-request.dto';
|
||||
import { Service } from '@n8n/di';
|
||||
import { TokenExpiredError } from 'jsonwebtoken';
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
import { ApiKey } from '@/databases/entities/api-key';
|
||||
|
@ -28,15 +31,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.
|
||||
*/
|
||||
async createPublicApiKeyForUser(user: User, { label }: { label: string }) {
|
||||
const apiKey = this.generateApiKey(user);
|
||||
await this.apiKeyRepository.upsert(
|
||||
async createPublicApiKeyForUser(user: User, { label, expiresAt }: CreateApiKeyRequestDto) {
|
||||
const apiKey = this.generateApiKey(user, expiresAt);
|
||||
await this.apiKeyRepository.insert(
|
||||
this.apiKeyRepository.create({
|
||||
userId: user.id,
|
||||
apiKey,
|
||||
label,
|
||||
}),
|
||||
['apiKey'],
|
||||
);
|
||||
|
||||
return await this.apiKeyRepository.findOneByOrFail({ apiKey });
|
||||
|
@ -45,13 +47,13 @@ export class PublicApiKeyService {
|
|||
/**
|
||||
* Retrieves and redacts API keys for a given user.
|
||||
* @param user - The user for whom to retrieve and redact API keys.
|
||||
* @returns A promise that resolves to an array of objects containing redacted API keys.
|
||||
*/
|
||||
async getRedactedApiKeysForUser(user: User) {
|
||||
const apiKeys = await this.apiKeyRepository.findBy({ userId: user.id });
|
||||
return apiKeys.map((apiKeyRecord) => ({
|
||||
...apiKeyRecord,
|
||||
apiKey: this.redactApiKey(apiKeyRecord.apiKey),
|
||||
expiresAt: this.getApiKeyExpiration(apiKeyRecord.apiKey),
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -59,7 +61,7 @@ export class PublicApiKeyService {
|
|||
await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId });
|
||||
}
|
||||
|
||||
async updateApiKeyForUser(user: User, apiKeyId: string, { label }: { label?: string } = {}) {
|
||||
async updateApiKeyForUser(user: User, apiKeyId: string, { label }: UpdateApiKeyRequestDto) {
|
||||
await this.apiKeyRepository.update({ id: apiKeyId, userId: user.id }, { label });
|
||||
}
|
||||
|
||||
|
@ -105,6 +107,16 @@ export class PublicApiKeyService {
|
|||
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
this.jwtService.verify(providedApiKey, {
|
||||
issuer: API_KEY_ISSUER,
|
||||
audience: API_KEY_AUDIENCE,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof TokenExpiredError) return false;
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.eventService.emit('public-api-invoked', {
|
||||
userId: user.id,
|
||||
path: req.path,
|
||||
|
@ -118,6 +130,17 @@ export class PublicApiKeyService {
|
|||
};
|
||||
}
|
||||
|
||||
private generateApiKey = (user: User) =>
|
||||
this.jwtService.sign({ sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE });
|
||||
private generateApiKey = (user: User, expiresAt: UnixTimestamp) => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
return this.jwtService.sign(
|
||||
{ sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE },
|
||||
{ ...(expiresAt && { expiresIn: expiresAt - nowInSeconds }) },
|
||||
);
|
||||
};
|
||||
|
||||
private getApiKeyExpiration = (apiKey: string) => {
|
||||
const decoded = this.jwtService.decode(apiKey);
|
||||
return decoded?.exp ?? null;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Container } from '@n8n/di';
|
|||
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
||||
import { License } from '@/license';
|
||||
import { PublicApiKeyService } from '@/services/public-api-key.service';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
|
||||
|
@ -13,6 +14,10 @@ import * as testDb from './shared/test-db';
|
|||
import type { SuperAgentTest } from './shared/types';
|
||||
import * as utils from './shared/utils/';
|
||||
|
||||
const license = mockInstance(License);
|
||||
|
||||
license.getApiKeysPerUserLimit.mockImplementation(() => 2);
|
||||
|
||||
const testServer = utils.setupTestServer({ endpointGroups: ['apiKeys'] });
|
||||
let publicApiKeyService: PublicApiKeyService;
|
||||
|
||||
|
@ -56,11 +61,11 @@ describe('Owner shell', () => {
|
|||
ownerShell = await createUserShell('global:owner');
|
||||
});
|
||||
|
||||
test('POST /api-keys should create an api key', async () => {
|
||||
test('POST /api-keys should create an api key with no expiration', async () => {
|
||||
const newApiKeyResponse = await testServer
|
||||
.authAgentFor(ownerShell)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key' });
|
||||
.send({ label: 'My API Key', expiresAt: null });
|
||||
|
||||
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
|
||||
|
||||
|
@ -79,6 +84,39 @@ describe('Owner shell', () => {
|
|||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(newApiKey.expiresAt).toBeNull();
|
||||
expect(newApiKey.rawApiKey).toBeDefined();
|
||||
});
|
||||
|
||||
test('POST /api-keys should create an api key with expiration', async () => {
|
||||
const expiresAt = Date.now() + 1000;
|
||||
|
||||
const newApiKeyResponse = await testServer
|
||||
.authAgentFor(ownerShell)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key', expiresAt });
|
||||
|
||||
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
|
||||
|
||||
expect(newApiKeyResponse.statusCode).toBe(200);
|
||||
expect(newApiKey).toBeDefined();
|
||||
|
||||
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
|
||||
userId: ownerShell.id,
|
||||
});
|
||||
|
||||
expect(newStoredApiKey).toEqual({
|
||||
id: expect.any(String),
|
||||
label: 'My API Key',
|
||||
userId: ownerShell.id,
|
||||
apiKey: newApiKey.rawApiKey,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(newApiKey.expiresAt).toBe(expiresAt);
|
||||
expect(newApiKey.rawApiKey).toBeDefined();
|
||||
});
|
||||
|
||||
test('POST /api-keys should fail if max number of API keys reached', async () => {
|
||||
|
@ -93,24 +131,40 @@ describe('Owner shell', () => {
|
|||
});
|
||||
|
||||
test('GET /api-keys should fetch the api key redacted', async () => {
|
||||
const newApiKeyResponse = await testServer
|
||||
const expirationDateInTheFuture = Date.now() + 1000;
|
||||
|
||||
const apiKeyWithNoExpiration = await testServer
|
||||
.authAgentFor(ownerShell)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key' });
|
||||
.send({ label: 'My API Key', expiresAt: null });
|
||||
|
||||
const apiKeyWithExpiration = await testServer
|
||||
.authAgentFor(ownerShell)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture });
|
||||
|
||||
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',
|
||||
expect(retrieveAllApiKeysResponse.body.data[1]).toEqual({
|
||||
id: apiKeyWithExpiration.body.data.id,
|
||||
label: 'My API Key 2',
|
||||
userId: ownerShell.id,
|
||||
apiKey: redactedApiKey,
|
||||
apiKey: publicApiKeyService.redactApiKey(apiKeyWithExpiration.body.data.rawApiKey),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: expirationDateInTheFuture,
|
||||
});
|
||||
|
||||
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
||||
id: apiKeyWithNoExpiration.body.data.id,
|
||||
label: 'My API Key',
|
||||
userId: ownerShell.id,
|
||||
apiKey: publicApiKeyService.redactApiKey(apiKeyWithNoExpiration.body.data.rawApiKey),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -118,7 +172,7 @@ describe('Owner shell', () => {
|
|||
const newApiKeyResponse = await testServer
|
||||
.authAgentFor(ownerShell)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key' });
|
||||
.send({ label: 'My API Key', expiresAt: null });
|
||||
|
||||
const deleteApiKeyResponse = await testServer
|
||||
.authAgentFor(ownerShell)
|
||||
|
@ -143,11 +197,11 @@ describe('Member', () => {
|
|||
await utils.setInstanceOwnerSetUp(true);
|
||||
});
|
||||
|
||||
test('POST /api-keys should create an api key', async () => {
|
||||
test('POST /api-keys should create an api key with no expiration', async () => {
|
||||
const newApiKeyResponse = await testServer
|
||||
.authAgentFor(member)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key' });
|
||||
.send({ label: 'My API Key', expiresAt: null });
|
||||
|
||||
expect(newApiKeyResponse.statusCode).toBe(200);
|
||||
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
|
||||
|
@ -165,6 +219,39 @@ describe('Member', () => {
|
|||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(newApiKeyResponse.body.data.expiresAt).toBeNull();
|
||||
expect(newApiKeyResponse.body.data.rawApiKey).toBeDefined();
|
||||
});
|
||||
|
||||
test('POST /api-keys should create an api key with expiration', async () => {
|
||||
const expiresAt = Date.now() + 1000;
|
||||
|
||||
const newApiKeyResponse = await testServer
|
||||
.authAgentFor(member)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key', expiresAt });
|
||||
|
||||
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
|
||||
|
||||
expect(newApiKeyResponse.statusCode).toBe(200);
|
||||
expect(newApiKey).toBeDefined();
|
||||
|
||||
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
|
||||
userId: member.id,
|
||||
});
|
||||
|
||||
expect(newStoredApiKey).toEqual({
|
||||
id: expect.any(String),
|
||||
label: 'My API Key',
|
||||
userId: member.id,
|
||||
apiKey: newApiKey.rawApiKey,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(newApiKey.expiresAt).toBe(expiresAt);
|
||||
expect(newApiKey.rawApiKey).toBeDefined();
|
||||
});
|
||||
|
||||
test('POST /api-keys should fail if max number of API keys reached', async () => {
|
||||
|
@ -179,36 +266,48 @@ describe('Member', () => {
|
|||
});
|
||||
|
||||
test('GET /api-keys should fetch the api key redacted', async () => {
|
||||
const newApiKeyResponse = await testServer
|
||||
const expirationDateInTheFuture = Date.now() + 1000;
|
||||
|
||||
const apiKeyWithNoExpiration = await testServer
|
||||
.authAgentFor(member)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key' });
|
||||
.send({ label: 'My API Key', expiresAt: null });
|
||||
|
||||
const apiKeyWithExpiration = await testServer
|
||||
.authAgentFor(member)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture });
|
||||
|
||||
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',
|
||||
expect(retrieveAllApiKeysResponse.body.data[1]).toEqual({
|
||||
id: apiKeyWithExpiration.body.data.id,
|
||||
label: 'My API Key 2',
|
||||
userId: member.id,
|
||||
apiKey: redactedApiKey,
|
||||
apiKey: publicApiKeyService.redactApiKey(apiKeyWithExpiration.body.data.rawApiKey),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: expirationDateInTheFuture,
|
||||
});
|
||||
|
||||
expect(newApiKeyResponse.body.data.rawApiKey).not.toEqual(
|
||||
retrieveAllApiKeysResponse.body.data[0].apiKey,
|
||||
);
|
||||
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
||||
id: apiKeyWithNoExpiration.body.data.id,
|
||||
label: 'My API Key',
|
||||
userId: member.id,
|
||||
apiKey: publicApiKeyService.redactApiKey(apiKeyWithNoExpiration.body.data.rawApiKey),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('DELETE /api-keys/:id should delete the api key', async () => {
|
||||
const newApiKeyResponse = await testServer
|
||||
.authAgentFor(member)
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key' });
|
||||
.send({ label: 'My API Key', expiresAt: null });
|
||||
|
||||
const deleteApiKeyResponse = await testServer
|
||||
.authAgentFor(member)
|
||||
|
|
|
@ -83,6 +83,7 @@ export async function createUserWithMfaEnabled(
|
|||
export const addApiKey = async (user: User) => {
|
||||
return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user, {
|
||||
label: randomName(),
|
||||
expiresAt: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import type { IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import type { CreateOrUpdateApiKeyRequestDto, ApiKey, ApiKeyWithRawValue } from '@n8n/api-types';
|
||||
import type {
|
||||
CreateApiKeyRequestDto,
|
||||
UpdateApiKeyRequestDto,
|
||||
ApiKey,
|
||||
ApiKeyWithRawValue,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/api-keys');
|
||||
|
@ -8,7 +13,7 @@ export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
|
|||
|
||||
export async function createApiKey(
|
||||
context: IRestApiContext,
|
||||
payload: CreateOrUpdateApiKeyRequestDto,
|
||||
payload: CreateApiKeyRequestDto,
|
||||
): Promise<ApiKeyWithRawValue> {
|
||||
return await makeRestApiRequest(context, 'POST', '/api-keys', payload);
|
||||
}
|
||||
|
@ -23,7 +28,7 @@ export async function deleteApiKey(
|
|||
export async function updateApiKey(
|
||||
context: IRestApiContext,
|
||||
id: string,
|
||||
payload: CreateOrUpdateApiKeyRequestDto,
|
||||
payload: UpdateApiKeyRequestDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
return await makeRestApiRequest(context, 'PATCH', `/api-keys/${id}`, payload);
|
||||
}
|
||||
|
|
|
@ -40,9 +40,19 @@ async function onAction(action: string) {
|
|||
}
|
||||
}
|
||||
|
||||
const getApiCreationTime = (apiKey: ApiKey): string => {
|
||||
const timeAgo = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toRelative() ?? '';
|
||||
return i18n.baseText('settings.api.creationTime', { interpolate: { time: timeAgo } });
|
||||
const hasApiKeyExpired = (apiKey: ApiKey) => {
|
||||
if (!apiKey.expiresAt) return false;
|
||||
return apiKey.expiresAt <= Date.now() / 1000;
|
||||
};
|
||||
|
||||
const getExpirationTime = (apiKey: ApiKey): string => {
|
||||
if (!apiKey.expiresAt) return i18n.baseText('settings.api.neverExpires');
|
||||
|
||||
if (hasApiKeyExpired(apiKey)) return i18n.baseText('settings.api.expired');
|
||||
|
||||
const time = DateTime.fromSeconds(apiKey.expiresAt).toFormat('ccc, MMM d yyyy');
|
||||
|
||||
return i18n.baseText('settings.api.expirationTime', { interpolate: { time } });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -53,9 +63,9 @@ const getApiCreationTime = (apiKey: ApiKey): string => {
|
|||
<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>
|
||||
<div :class="[$style.cardDescription]">
|
||||
<n8n-text :color="!hasApiKeyExpired(apiKey) ? 'text-light' : 'warning'" size="small">
|
||||
<span>{{ getExpirationTime(apiKey) }}</span>
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,10 @@ 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';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { ApiKeyWithRawValue } from '@n8n/api-types';
|
||||
|
||||
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
|
||||
pinia: createTestingPinia({
|
||||
|
@ -18,6 +21,16 @@ const renderComponent = createComponentRenderer(ApiKeyEditModal, {
|
|||
}),
|
||||
});
|
||||
|
||||
const testApiKey: ApiKeyWithRawValue = {
|
||||
id: '123',
|
||||
label: 'new api key',
|
||||
apiKey: '123456***',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
rawApiKey: '123456',
|
||||
expiresAt: 0,
|
||||
};
|
||||
|
||||
const apiKeysStore = mockedStore(useApiKeysStore);
|
||||
|
||||
describe('ApiKeyCreateOrEditModal', () => {
|
||||
|
@ -30,15 +43,8 @@ describe('ApiKeyCreateOrEditModal', () => {
|
|||
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',
|
||||
});
|
||||
test('should allow creating API key with default expiration (30 days)', async () => {
|
||||
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
|
||||
|
||||
const { getByText, getByPlaceholderText } = renderComponent({
|
||||
props: {
|
||||
|
@ -59,6 +65,69 @@ describe('ApiKeyCreateOrEditModal', () => {
|
|||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
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 creating API key with custom expiration', async () => {
|
||||
apiKeysStore.createApiKey.mockResolvedValue({
|
||||
id: '123',
|
||||
label: 'new api key',
|
||||
apiKey: '123456',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
rawApiKey: '***456',
|
||||
expiresAt: 0,
|
||||
});
|
||||
|
||||
const { getByText, getByPlaceholderText, getByTestId } = 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');
|
||||
const expirationSelect = getByTestId('expiration-select');
|
||||
|
||||
expect(inputLabel).toBeInTheDocument();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
expect(expirationSelect).toBeInTheDocument();
|
||||
|
||||
await fireEvent.update(inputLabel, 'new label');
|
||||
|
||||
await fireEvent.click(expirationSelect);
|
||||
|
||||
const customOption = getByText('Custom');
|
||||
|
||||
expect(customOption).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(customOption);
|
||||
|
||||
const customExpirationInput = getByPlaceholderText('yyyy-mm-dd');
|
||||
|
||||
expect(customExpirationInput).toBeInTheDocument();
|
||||
|
||||
await fireEvent.input(customExpirationInput, '2029-12-31');
|
||||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
expect(getByText('***456')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('API Key Created')).toBeInTheDocument();
|
||||
|
@ -78,16 +147,57 @@ describe('ApiKeyCreateOrEditModal', () => {
|
|||
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(),
|
||||
test('should allow creating API key with no expiration', async () => {
|
||||
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
|
||||
|
||||
const { getByText, getByPlaceholderText, getByTestId } = 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');
|
||||
const expirationSelect = getByTestId('expiration-select');
|
||||
|
||||
expect(inputLabel).toBeInTheDocument();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
expect(expirationSelect).toBeInTheDocument();
|
||||
|
||||
await fireEvent.update(inputLabel, 'new label');
|
||||
|
||||
await fireEvent.click(expirationSelect);
|
||||
|
||||
const noExpirationOption = getByText('No Expiration');
|
||||
|
||||
expect(noExpirationOption).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(noExpirationOption);
|
||||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
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 = [testApiKey];
|
||||
|
||||
apiKeysStore.updateApiKey.mockResolvedValue();
|
||||
|
||||
|
@ -102,6 +212,12 @@ describe('ApiKeyCreateOrEditModal', () => {
|
|||
|
||||
expect(getByText('Label')).toBeInTheDocument();
|
||||
|
||||
const formattedDate = DateTime.fromMillis(Date.parse(testApiKey.createdAt)).toFormat(
|
||||
'ccc, MMM d yyyy',
|
||||
);
|
||||
|
||||
expect(getByText(`API key was created on ${formattedDate}`)).toBeInTheDocument();
|
||||
|
||||
const labelInput = getByTestId('api-key-label');
|
||||
|
||||
expect((labelInput as unknown as HTMLInputElement).value).toBe('new api key');
|
||||
|
|
|
@ -11,12 +11,24 @@ 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';
|
||||
import { N8nText } from 'n8n-design-system';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { ApiKey, ApiKeyWithRawValue, CreateApiKeyRequestDto } from '@n8n/api-types';
|
||||
|
||||
const EXPIRATION_OPTIONS = {
|
||||
'7_DAYS': 7,
|
||||
'30_DAYS': 30,
|
||||
'60_DAYS': 60,
|
||||
'90_DAYS': 90,
|
||||
CUSTOM: 1,
|
||||
NO_EXPIRATION: 0,
|
||||
};
|
||||
|
||||
const i18n = useI18n();
|
||||
const { showError, showMessage } = useToast();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore;
|
||||
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
|
||||
|
@ -24,11 +36,43 @@ const { baseUrl } = useRootStore();
|
|||
const documentTitle = useDocumentTitle();
|
||||
|
||||
const label = ref('');
|
||||
const expirationDaysFromNow = ref(EXPIRATION_OPTIONS['30_DAYS']);
|
||||
const modalBus = createEventBus();
|
||||
const newApiKey = ref<ApiKeyWithRawValue | null>(null);
|
||||
const apiDocsURL = ref('');
|
||||
const loading = ref(false);
|
||||
const rawApiKey = ref('');
|
||||
const customExpirationDate = ref('');
|
||||
const showExpirationDateSelector = ref(false);
|
||||
const apiKeyCreationDate = ref('');
|
||||
|
||||
const calculateExpirationDate = (daysFromNow: number) => {
|
||||
const date = DateTime.now()
|
||||
.setZone(rootStore.timezone)
|
||||
.startOf('day')
|
||||
.plus({ days: daysFromNow });
|
||||
return date;
|
||||
};
|
||||
|
||||
const getExpirationOptionLabel = (value: number) => {
|
||||
if (EXPIRATION_OPTIONS.CUSTOM === value) {
|
||||
return i18n.baseText('settings.api.view.modal.form.expiration.custom');
|
||||
}
|
||||
|
||||
if (EXPIRATION_OPTIONS.NO_EXPIRATION === value) {
|
||||
return i18n.baseText('settings.api.view.modal.form.expiration.none');
|
||||
}
|
||||
|
||||
return i18n.baseText('settings.api.view.modal.form.expiration.days', {
|
||||
interpolate: {
|
||||
numberOfDays: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const expirationDate = ref(
|
||||
calculateExpirationDate(expirationDaysFromNow.value).toFormat('ccc, MMM d yyyy'),
|
||||
);
|
||||
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
|
@ -43,6 +87,17 @@ const props = withDefaults(
|
|||
},
|
||||
);
|
||||
|
||||
const allFormFieldsAreSet = computed(() => {
|
||||
const isExpirationDateSet =
|
||||
expirationDaysFromNow.value === EXPIRATION_OPTIONS.NO_EXPIRATION ||
|
||||
(expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM && customExpirationDate.value) ||
|
||||
expirationDate.value;
|
||||
|
||||
return label.value && (props.mode === 'edit' ? true : isExpirationDateSet);
|
||||
});
|
||||
|
||||
const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime();
|
||||
|
||||
onMounted(() => {
|
||||
documentTitle.set(i18n.baseText('settings.api'));
|
||||
|
||||
|
@ -51,7 +106,9 @@ onMounted(() => {
|
|||
});
|
||||
|
||||
if (props.mode === 'edit') {
|
||||
label.value = apiKeysById[props.activeId]?.label ?? '';
|
||||
const apiKey = apiKeysById[props.activeId];
|
||||
label.value = apiKey.label ?? '';
|
||||
apiKeyCreationDate.value = getApiKeyCreationTime(apiKey);
|
||||
}
|
||||
|
||||
apiDocsURL.value = isSwaggerUIEnabled
|
||||
|
@ -63,6 +120,11 @@ function onInput(value: string): void {
|
|||
label.value = value;
|
||||
}
|
||||
|
||||
const getApiKeyCreationTime = (apiKey: ApiKey): string => {
|
||||
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy');
|
||||
return i18n.baseText('settings.api.creationTime', { interpolate: { time } });
|
||||
};
|
||||
|
||||
async function onEdit() {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
@ -88,9 +150,22 @@ const onSave = async () => {
|
|||
return;
|
||||
}
|
||||
|
||||
let expirationUnixTimestamp = null;
|
||||
|
||||
if (expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM) {
|
||||
expirationUnixTimestamp = parseInt(customExpirationDate.value, 10);
|
||||
} else if (expirationDaysFromNow.value !== EXPIRATION_OPTIONS.NO_EXPIRATION) {
|
||||
expirationUnixTimestamp = calculateExpirationDate(expirationDaysFromNow.value).toUnixInteger();
|
||||
}
|
||||
|
||||
const payload: CreateApiKeyRequestDto = {
|
||||
label: label.value,
|
||||
expiresAt: expirationUnixTimestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
newApiKey.value = await createApiKey(label.value);
|
||||
newApiKey.value = await createApiKey(payload);
|
||||
rawApiKey.value = newApiKey.value.rawApiKey;
|
||||
|
||||
showMessage({
|
||||
|
@ -115,6 +190,23 @@ const modalTitle = computed(() => {
|
|||
}
|
||||
return i18n.baseText(`settings.api.view.modal.title.${path}` as BaseTextKey);
|
||||
});
|
||||
|
||||
const onSelect = (value: number) => {
|
||||
if (value === EXPIRATION_OPTIONS.CUSTOM) {
|
||||
showExpirationDateSelector.value = true;
|
||||
expirationDate.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== EXPIRATION_OPTIONS.NO_EXPIRATION) {
|
||||
expirationDate.value = calculateExpirationDate(value).toFormat('ccc, MMM d yyyy');
|
||||
showExpirationDateSelector.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
expirationDate.value = '';
|
||||
showExpirationDateSelector.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -150,7 +242,7 @@ const modalTitle = computed(() => {
|
|||
</i18n-t>
|
||||
</n8n-info-tip>
|
||||
</p>
|
||||
<n8n-card v-if="newApiKey" class="mb-4xs" :class="$style.card">
|
||||
<n8n-card v-if="newApiKey" class="mb-4xs">
|
||||
<CopyInput
|
||||
:label="newApiKey.label"
|
||||
:value="newApiKey.rawApiKey"
|
||||
|
@ -162,13 +254,13 @@ const modalTitle = computed(() => {
|
|||
</n8n-card>
|
||||
|
||||
<div v-if="newApiKey" :class="$style.hint">
|
||||
<n8n-text size="small">
|
||||
<N8nText size="small">
|
||||
{{
|
||||
i18n.baseText(
|
||||
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
|
||||
)
|
||||
}}
|
||||
</n8n-text>
|
||||
</N8nText>
|
||||
{{ ' ' }}
|
||||
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
|
||||
{{
|
||||
|
@ -178,56 +270,94 @@ const modalTitle = computed(() => {
|
|||
}}
|
||||
</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 v-else :class="$style.form">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('settings.api.view.modal.form.label')"
|
||||
color="text-dark"
|
||||
>
|
||||
<N8nInput
|
||||
ref="inputRef"
|
||||
required
|
||||
:model-value="label"
|
||||
size="large"
|
||||
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 v-if="mode === 'new'" :class="$style.expirationSection">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('settings.api.view.modal.form.expiration')"
|
||||
color="text-dark"
|
||||
>
|
||||
<N8nSelect
|
||||
v-model="expirationDaysFromNow"
|
||||
size="large"
|
||||
filterable
|
||||
data-test-id="expiration-select"
|
||||
@update:model-value="onSelect"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="key in Object.keys(EXPIRATION_OPTIONS)"
|
||||
:key="key"
|
||||
:value="EXPIRATION_OPTIONS[key as keyof typeof EXPIRATION_OPTIONS]"
|
||||
:label="
|
||||
getExpirationOptionLabel(
|
||||
EXPIRATION_OPTIONS[key as keyof typeof EXPIRATION_OPTIONS],
|
||||
)
|
||||
"
|
||||
>
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
<N8nText v-if="expirationDate" class="mb-xs">{{
|
||||
i18n.baseText('settings.api.view.modal.form.expirationText', {
|
||||
interpolate: { expirationDate },
|
||||
})
|
||||
}}</N8nText>
|
||||
<el-date-picker
|
||||
v-if="showExpirationDateSelector"
|
||||
v-model="customExpirationDate"
|
||||
type="date"
|
||||
:teleported="false"
|
||||
placeholder="yyyy-mm-dd"
|
||||
value-format="X"
|
||||
:disabled-date="isCustomDateInThePast"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
v-if="mode === 'new' && !newApiKey"
|
||||
float="right"
|
||||
:loading="loading"
|
||||
:disabled="!allFormFieldsAreSet"
|
||||
: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"
|
||||
v-if="mode === 'edit'"
|
||||
:disabled="!allFormFieldsAreSet"
|
||||
:label="i18n.baseText('settings.api.view.modal.edit.button')"
|
||||
@click="onEdit"
|
||||
/>
|
||||
<N8nText v-if="mode === 'edit'" size="small" color="text-light">{{
|
||||
apiKeyCreationDate
|
||||
}}</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.card {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
}
|
||||
|
@ -236,4 +366,24 @@ const modalTitle = computed(() => {
|
|||
color: var(--color-text-light);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.expirationSection {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1844,7 +1844,10 @@
|
|||
"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.creationTime": "API key was created on {time}",
|
||||
"settings.api.expirationTime": "Expires on {time}",
|
||||
"settings.api.expired": "This API key has expired",
|
||||
"settings.api.neverExpires": "Never expires",
|
||||
"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.",
|
||||
|
@ -1856,7 +1859,12 @@
|
|||
"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.expiration": "Expiration",
|
||||
"settings.api.view.modal.form.expirationText": "The API key will expire on {expirationDate}",
|
||||
"settings.api.view.modal.form.label.placeholder": "e.g Internal Project",
|
||||
"settings.api.view.modal.form.expiration.custom": "Custom",
|
||||
"settings.api.view.modal.form.expiration.days": "{numberOfDays} days",
|
||||
"settings.api.view.modal.form.expiration.none": "No Expiration",
|
||||
"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",
|
||||
|
|
|
@ -5,7 +5,7 @@ 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';
|
||||
import type { ApiKey, CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types';
|
||||
|
||||
export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
|
@ -37,8 +37,8 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
|
|||
return apiKeys.value;
|
||||
};
|
||||
|
||||
const createApiKey = async (label: string) => {
|
||||
const newApiKey = await publicApiApi.createApiKey(rootStore.restApiContext, { label });
|
||||
const createApiKey = async (payload: CreateApiKeyRequestDto) => {
|
||||
const newApiKey = await publicApiApi.createApiKey(rootStore.restApiContext, payload);
|
||||
const { rawApiKey, ...rest } = newApiKey;
|
||||
apiKeys.value.push(rest);
|
||||
return newApiKey;
|
||||
|
@ -49,9 +49,9 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
|
|||
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;
|
||||
const updateApiKey = async (id: string, payload: UpdateApiKeyRequestDto) => {
|
||||
await publicApiApi.updateApiKey(rootStore.restApiContext, id, payload);
|
||||
apiKeysById.value[id].label = payload.label;
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
@ -8,6 +8,7 @@ import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
|||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { useApiKeysStore } from '@/stores/apiKeys.store';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
setActivePinia(createTestingPinia());
|
||||
|
||||
|
@ -50,6 +51,9 @@ describe('SettingsApiView', () => {
|
|||
});
|
||||
|
||||
it('if user public api enabled and there are API Keys in account, they should be rendered', async () => {
|
||||
const dateInTheFuture = DateTime.now().plus({ days: 1 });
|
||||
const dateInThePast = DateTime.now().minus({ days: 1 });
|
||||
|
||||
settingsStore.isPublicApiEnabled = true;
|
||||
cloudStore.userIsTrialing = false;
|
||||
apiKeysStore.apiKeys = [
|
||||
|
@ -59,17 +63,41 @@ describe('SettingsApiView', () => {
|
|||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Atcr',
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'test-key-2',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Bdcr',
|
||||
expiresAt: dateInTheFuture.toSeconds(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: 'test-key-3',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Wtcr',
|
||||
expiresAt: dateInThePast.toSeconds(),
|
||||
},
|
||||
];
|
||||
|
||||
renderComponent(SettingsApiView);
|
||||
|
||||
expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Never expires')).toBeInTheDocument();
|
||||
expect(screen.getByText('****Atcr')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-key-1')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`Expires on ${dateInTheFuture.toFormat('ccc, MMM d yyyy')}`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('****Bdcr')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-key-2')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('This API key has expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('****Wtcr')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-key-3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show delete warning when trying to delete an API key', async () => {
|
||||
|
@ -82,12 +110,13 @@ describe('SettingsApiView', () => {
|
|||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Atcr',
|
||||
expiresAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
renderComponent(SettingsApiView);
|
||||
|
||||
expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Never expires')).toBeInTheDocument();
|
||||
expect(screen.getByText('****Atcr')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-key-1')).toBeInTheDocument();
|
||||
|
||||
|
|
Loading…
Reference in a new issue