From 9bcbc2c2ccbb88537e9b7554c92b631118d870f1 Mon Sep 17 00:00:00 2001
From: Ricardo Espinoza
Date: Mon, 3 Feb 2025 11:16:37 -0500
Subject: [PATCH] feat: Allow setting API keys expiration (#12954)
---
packages/@n8n/api-types/src/api-keys.ts | 5 +
.../create-api-key-request.dto.test.ts | 53 +++++
....ts => update-api-key-request.dto.test.ts} | 8 +-
.../api-keys/create-api-key-request.dto.ts | 15 ++
...t.dto.ts => update-api-key-request.dto.ts} | 2 +-
packages/@n8n/api-types/src/dto/index.ts | 3 +-
.../__tests__/api-keys.controller.test.ts | 6 +-
.../src/controllers/api-keys.controller.ts | 12 +-
.../src/services/public-api-key.service.ts | 39 +++-
.../cli/test/integration/api-keys.api.test.ts | 149 ++++++++++--
.../cli/test/integration/shared/db/users.ts | 1 +
packages/editor-ui/src/api/api-keys.ts | 11 +-
.../editor-ui/src/components/ApiKeyCard.vue | 22 +-
.../ApiKeyCreateOrEditModal.test.ts | 152 ++++++++++--
.../components/ApiKeyCreateOrEditModal.vue | 216 +++++++++++++++---
.../src/plugins/i18n/locales/en.json | 10 +-
.../editor-ui/src/stores/apiKeys.store.ts | 12 +-
.../src/views/SettingsApiView.test.ts | 37 ++-
18 files changed, 636 insertions(+), 117 deletions(-)
create mode 100644 packages/@n8n/api-types/src/dto/api-keys/__tests__/create-api-key-request.dto.test.ts
rename packages/@n8n/api-types/src/dto/api-keys/__tests__/{create-or-update.dto.test.ts => update-api-key-request.dto.test.ts} (75%)
create mode 100644 packages/@n8n/api-types/src/dto/api-keys/create-api-key-request.dto.ts
rename packages/@n8n/api-types/src/dto/api-keys/{create-or-update-api-key-request.dto.ts => update-api-key-request.dto.ts} (78%)
diff --git a/packages/@n8n/api-types/src/api-keys.ts b/packages/@n8n/api-types/src/api-keys.ts
index e812786e78..a805d898f2 100644
--- a/packages/@n8n/api-types/src/api-keys.ts
+++ b/packages/@n8n/api-types/src/api-keys.ts
@@ -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 };
diff --git a/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-api-key-request.dto.test.ts b/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-api-key-request.dto.test.ts
new file mode 100644
index 0000000000..923e0462bb
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-api-key-request.dto.test.ts
@@ -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);
+ }
+ });
+ });
+});
diff --git a/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts b/packages/@n8n/api-types/src/dto/api-keys/__tests__/update-api-key-request.dto.test.ts
similarity index 75%
rename from packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts
rename to packages/@n8n/api-types/src/dto/api-keys/__tests__/update-api-key-request.dto.test.ts
index beb7ebcf0d..10d6b0c31f 100644
--- a/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts
+++ b/packages/@n8n/api-types/src/dto/api-keys/__tests__/update-api-key-request.dto.test.ts
@@ -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);
diff --git a/packages/@n8n/api-types/src/dto/api-keys/create-api-key-request.dto.ts b/packages/@n8n/api-types/src/dto/api-keys/create-api-key-request.dto.ts
new file mode 100644
index 0000000000..f5e66b0d62
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/api-keys/create-api-key-request.dto.ts
@@ -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' }),
+}) {}
diff --git a/packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts b/packages/@n8n/api-types/src/dto/api-keys/update-api-key-request.dto.ts
similarity index 78%
rename from packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts
rename to packages/@n8n/api-types/src/dto/api-keys/update-api-key-request.dto.ts
index 168c28c2fa..9cb1b73fa5 100644
--- a/packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts
+++ b/packages/@n8n/api-types/src/dto/api-keys/update-api-key-request.dto.ts
@@ -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),
}) {}
diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts
index 3c6db394c2..ad695f0bb2 100644
--- a/packages/@n8n/api-types/src/dto/index.ts
+++ b/packages/@n8n/api-types/src/dto/index.ts
@@ -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';
diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts
index aaa530a39b..eb13081b48 100644
--- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts
+++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts
@@ -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 }),
);
diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts
index 17ed524b82..e2a824068a 100644
--- a/packages/cli/src/controllers/api-keys.controller.ts
+++ b/packages/cli/src/controllers/api-keys.controller.ts
@@ -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 };
diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts
index f2e43c3181..719f922fb2 100644
--- a/packages/cli/src/services/public-api-key.service.ts
+++ b/packages/cli/src/services/public-api-key.service.ts
@@ -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;
+ };
}
diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts
index e1649d4b0b..0f0dbf4e9e 100644
--- a/packages/cli/test/integration/api-keys.api.test.ts
+++ b/packages/cli/test/integration/api-keys.api.test.ts
@@ -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)
diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts
index 88751fd727..af0cf99820 100644
--- a/packages/cli/test/integration/shared/db/users.ts
+++ b/packages/cli/test/integration/shared/db/users.ts
@@ -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,
});
};
diff --git a/packages/editor-ui/src/api/api-keys.ts b/packages/editor-ui/src/api/api-keys.ts
index 5ea2f593b7..c9af96f136 100644
--- a/packages/editor-ui/src/api/api-keys.ts
+++ b/packages/editor-ui/src/api/api-keys.ts
@@ -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 {
return await makeRestApiRequest(context, 'GET', '/api-keys');
@@ -8,7 +13,7 @@ export async function getApiKeys(context: IRestApiContext): Promise {
export async function createApiKey(
context: IRestApiContext,
- payload: CreateOrUpdateApiKeyRequestDto,
+ payload: CreateApiKeyRequestDto,
): Promise {
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);
}
diff --git a/packages/editor-ui/src/components/ApiKeyCard.vue b/packages/editor-ui/src/components/ApiKeyCard.vue
index 2167bede96..0c3fe82975 100644
--- a/packages/editor-ui/src/components/ApiKeyCard.vue
+++ b/packages/editor-ui/src/components/ApiKeyCard.vue
@@ -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 } });
};
@@ -53,9 +63,9 @@ const getApiCreationTime = (apiKey: ApiKey): string => {
{{ apiKey.label }}
-
-
- {{ getApiCreationTime(apiKey) }}
+
+
+ {{ getExpirationTime(apiKey) }}
diff --git a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts
index 781a8341bd..3aac4e2434 100644
--- a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts
+++ b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts
@@ -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');
diff --git a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue
index 04139e84ad..2ce3eb050a 100644
--- a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue
+++ b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue
@@ -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(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(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;
+};
@@ -150,7 +242,7 @@ const modalTitle = computed(() => {
-
+
{
-
+
{{
i18n.baseText(
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
)
}}
-
+
{{ ' ' }}
{{
@@ -178,56 +270,94 @@ const modalTitle = computed(() => {
}}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ i18n.baseText('settings.api.view.modal.form.expirationText', {
+ interpolate: { expirationDate },
+ })
+ }}
+
+
+
-
+
+ {{
+ apiKeyCreationDate
+ }}
-
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index e1ab15dfa3..fe445c8f6e 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -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",
diff --git a/packages/editor-ui/src/stores/apiKeys.store.ts b/packages/editor-ui/src/stores/apiKeys.store.ts
index 826f7bb0d1..0cc897aa75 100644
--- a/packages/editor-ui/src/stores/apiKeys.store.ts
+++ b/packages/editor-ui/src/stores/apiKeys.store.ts
@@ -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
([]);
@@ -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 {
diff --git a/packages/editor-ui/src/views/SettingsApiView.test.ts b/packages/editor-ui/src/views/SettingsApiView.test.ts
index ed8342739d..b895af20e3 100644
--- a/packages/editor-ui/src/views/SettingsApiView.test.ts
+++ b/packages/editor-ui/src/views/SettingsApiView.test.ts
@@ -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();