mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-21 06:37:36 -04:00
External vault - AWS secret manager support [INS-4623] (#3)
* add basic framework for external vault * add aws secret manager integration * fix issues from comment
This commit is contained in:
169
package-lock.json
generated
169
package-lock.json
generated
@@ -3771,6 +3771,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.0.tgz",
|
||||
"integrity": "sha512-Ebw0+MCqoYflop7wVKj711ccbNlrwTBCtjY5rlbiY9kHL2bCYxq+qltK6uPsVBGGAOb033H2VO0YobcQVxoW7Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.43.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/instrumentation": {
|
||||
"version": "5.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.17.0.tgz",
|
||||
@@ -13669,9 +13685,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz",
|
||||
"integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz",
|
||||
"integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/express": {
|
||||
@@ -18447,6 +18463,15 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/abbrev": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz",
|
||||
"integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
@@ -18523,12 +18548,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/nopt": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.0.0.tgz",
|
||||
"integrity": "sha512-1L/fTJ4UmV/lUxT2Uf006pfZKTvAgCF+chz+0OgBHO8u2Z67pE7AaAUUj7CJy0lXqHmymUvGFt6NE9R3HER0yw==",
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||
"integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "^2.0.0"
|
||||
"abbrev": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
@@ -19656,6 +19681,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
|
||||
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
|
||||
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||
@@ -21611,9 +21683,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
|
||||
"integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
|
||||
"integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^9.0.5",
|
||||
@@ -24810,6 +24882,7 @@
|
||||
"objectpath": "^2.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"postcss": "^8.4.38",
|
||||
"quick-lru": "^7.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-aria": "3.32.1",
|
||||
"react-aria-components": "^1.1.1",
|
||||
@@ -24941,72 +25014,22 @@
|
||||
"xvfb-maybe": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"packages/insomnia-smoke-test/node_modules/@playwright/test": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.0.tgz",
|
||||
"integrity": "sha512-Ebw0+MCqoYflop7wVKj711ccbNlrwTBCtjY5rlbiY9kHL2bCYxq+qltK6uPsVBGGAOb033H2VO0YobcQVxoW7Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.43.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"packages/insomnia-smoke-test/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"packages/insomnia-smoke-test/node_modules/playwright": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
|
||||
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"packages/insomnia-smoke-test/node_modules/playwright-core": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
|
||||
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"packages/insomnia-testing": {
|
||||
"version": "11.0.0-beta.0",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"packages/insomnia/node_modules/quick-lru": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.0.0.tgz",
|
||||
"integrity": "sha512-MX8gB7cVYTrYcFfAnfLlhRd0+Toyl8yX8uBx1MrX7K0jegiz9TumwOK27ldXrgDlHRdVi+MqU9Ssw6dr4BNreg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"objectpath": "^2.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"postcss": "^8.4.38",
|
||||
"quick-lru": "^7.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-aria": "3.32.1",
|
||||
"react-aria-components": "^1.1.1",
|
||||
|
||||
@@ -152,4 +152,5 @@ export interface Settings {
|
||||
saveVaultKeyLocally: boolean;
|
||||
enableVaultInScripts: boolean;
|
||||
saveVaultKeyToOSSecretManager: boolean;
|
||||
vaultSecretCacheDuration: number;
|
||||
}
|
||||
|
||||
22
packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts
Normal file
22
packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { VaultCache } from '../cloud-service-integration/vault-cache';
|
||||
|
||||
describe('test cache', () => {
|
||||
it('should get item after set', () => {
|
||||
const cacheInstance = new VaultCache();
|
||||
cacheInstance.setItem('foo', 'bar');
|
||||
cacheInstance.setItem('number_key', Math.random() * 1000);
|
||||
cacheInstance.setItem('boolean_key', true);
|
||||
|
||||
expect(cacheInstance.has('foo')).toBe(true);
|
||||
expect(cacheInstance.has('boolean_key')).toBe(true);
|
||||
expect(cacheInstance.has('foo1')).toBe(false);
|
||||
expect(cacheInstance.getItem('foo')).toBe('bar');
|
||||
|
||||
cacheInstance.clear();
|
||||
expect(Array.from(cacheInstance.entriesAscending()).length).toBe(0);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { DecryptionFailure, GetSecretValueCommand, type GetSecretValueCommandOutput, InternalServiceError, InvalidParameterException, InvalidRequestException, ResourceNotFoundException, SecretsManagerClient, SecretsManagerServiceException } from '@aws-sdk/client-secrets-manager';
|
||||
import { GetCallerIdentityCommand, type GetCallerIdentityCommandOutput, STSClient, STSServiceException } from '@aws-sdk/client-sts';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import type { AWSTemporaryCredential, CloudProviderName } from '../../../models/cloud-credential';
|
||||
import type { AWSSecretConfig, CloudServiceResult, ICloudService } from './types';
|
||||
@@ -6,7 +8,7 @@ import type { AWSSecretConfig, CloudServiceResult, ICloudService } from './types
|
||||
export type AWSGetSecretConfig = Omit<AWSSecretConfig, 'SecretId' | 'SecretType' | 'SecretKey'>;
|
||||
export const providerName: CloudProviderName = 'aws';
|
||||
export class AWSService implements ICloudService {
|
||||
_credential: AWSTemporaryCredential;
|
||||
private _credential: AWSTemporaryCredential;
|
||||
|
||||
constructor(credential: AWSTemporaryCredential) {
|
||||
this._credential = credential;
|
||||
@@ -43,4 +45,62 @@ export class AWSService implements ICloudService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getUniqueCacheKey(secretName: string, config?: AWSGetSecretConfig) {
|
||||
const {
|
||||
VersionId = '',
|
||||
VersionStage = '',
|
||||
} = config || {};
|
||||
const uniqueKey = `${providerName}:${secretName}:${VersionId}:${VersionStage}`;
|
||||
const uniqueKeyHash = crypto.createHash('md5').update(uniqueKey).digest('hex');
|
||||
return uniqueKeyHash;
|
||||
}
|
||||
|
||||
async getSecret(secretNameOrARN: string, config: AWSGetSecretConfig = {}): Promise<CloudServiceResult<GetSecretValueCommandOutput>> {
|
||||
const { region, accessKeyId, secretAccessKey, sessionToken } = this._credential;
|
||||
const { VersionId, VersionStage } = config;
|
||||
const secretClient = new SecretsManagerClient({
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId, secretAccessKey, sessionToken,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const input = {
|
||||
SecretId: secretNameOrARN,
|
||||
...(VersionId && { VersionId }),
|
||||
...(VersionStage && { VersionStage }),
|
||||
};
|
||||
const response = await secretClient.send(
|
||||
new GetSecretValueCommand(input)
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
result: response,
|
||||
};
|
||||
} catch (error) {
|
||||
let errorCode = error.code || 'UnknownError';
|
||||
let errorMessage = error.message || 'Failed to get Secret. An unknown error occurred';
|
||||
if (error instanceof SecretsManagerServiceException) {
|
||||
errorMessage = errorMessage || error.message;
|
||||
errorCode = errorCode || error.name;
|
||||
if (error instanceof DecryptionFailure) {
|
||||
errorMessage = "Secrets Manager can't decrypt the protected secret text using the provided KMS key.";
|
||||
} else if (error instanceof InternalServiceError) {
|
||||
errorMessage = 'An error occurred on the server side.';
|
||||
} else if (error instanceof InvalidParameterException) {
|
||||
errorMessage = 'The parameter name or value is invalid.';
|
||||
} else if (error instanceof InvalidRequestException) {
|
||||
errorMessage = 'The request is invalid for the current state of the resource.';
|
||||
} else if (error instanceof ResourceNotFoundException) {
|
||||
errorMessage = "Secrets Manager can't find the specified resource.";
|
||||
};
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
result: null,
|
||||
error: { errorCode, errorMessage },
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
|
||||
import * as models from '../../../models';
|
||||
import type { AWSTemporaryCredential, BaseCloudCredential, CloudProviderName } from '../../../models/cloud-credential';
|
||||
import { ipcMainHandle } from '../electron';
|
||||
import { ipcMainHandle, ipcMainOn } from '../electron';
|
||||
import { type AWSGetSecretConfig, AWSService } from './aws-service';
|
||||
import { type MaxAgeUnit, VaultCache } from './vault-cache';
|
||||
|
||||
// in-memory cache for fetched vault secrets
|
||||
const vaultCache = new VaultCache();
|
||||
|
||||
export interface cloudServiceBridgeAPI {
|
||||
authenticate: typeof cloudServiceProviderAuthentication;
|
||||
getSecret: typeof getSecret;
|
||||
clearCache: typeof clearVaultCache;
|
||||
setCacheMaxAge: typeof setCacheMaxAge;
|
||||
}
|
||||
export interface CloudServiceAuthOption {
|
||||
provider: CloudProviderName;
|
||||
@@ -17,6 +26,9 @@ export type CloudServiceGetSecretConfig = AWSGetSecretConfig;
|
||||
|
||||
export function registerCloudServiceHandlers() {
|
||||
ipcMainHandle('cloudService.authenticate', (_event, options) => cloudServiceProviderAuthentication(options));
|
||||
ipcMainHandle('cloudService.getSecret', (_event, options) => getSecret(options));
|
||||
ipcMainOn('cloudService.clearCache', () => clearVaultCache());
|
||||
ipcMainOn('cloudService.setCacheMaxAge', (_event, { maxAge, unit }) => setCacheMaxAge(maxAge, unit));
|
||||
}
|
||||
|
||||
// factory pattern to create cloud service class based on its provider name
|
||||
@@ -31,9 +43,35 @@ class ServiceFactory {
|
||||
}
|
||||
};
|
||||
|
||||
const clearVaultCache = () => {
|
||||
return vaultCache.clear();
|
||||
};
|
||||
|
||||
const setCacheMaxAge = (newAge: number, unit: MaxAgeUnit = 'min') => {
|
||||
return vaultCache.setMaxAge(newAge, unit);
|
||||
};
|
||||
|
||||
// authenticate with cloud service provider
|
||||
const cloudServiceProviderAuthentication = (options: CloudServiceAuthOption) => {
|
||||
const { provider, credentials } = options;
|
||||
const cloudService = ServiceFactory.createCloudService(provider, credentials);
|
||||
return cloudService.authenticate();
|
||||
};
|
||||
|
||||
const getSecret = async (options: CloudServiceSecretOption<CloudServiceGetSecretConfig>) => {
|
||||
const { provider, credentials, secretId, config } = options;
|
||||
const cloudService = ServiceFactory.createCloudService(provider, credentials);
|
||||
const uniqueSecretKey = cloudService.getUniqueCacheKey(secretId, config);
|
||||
if (vaultCache.has(uniqueSecretKey)) {
|
||||
// return cache value if exists
|
||||
return vaultCache.getItem(uniqueSecretKey);
|
||||
}
|
||||
const secretResult = await cloudService.getSecret(secretId, config);
|
||||
if (secretResult.success) {
|
||||
const settings = await models.settings.get();
|
||||
const maxAge = Number(settings.vaultSecretCacheDuration) * 1000 * 60;
|
||||
// set cached value after success
|
||||
vaultCache.setItem(uniqueSecretKey, secretResult, { maxAge });
|
||||
}
|
||||
return secretResult;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface CloudServiceResult<T extends Record<string, any>> {
|
||||
}
|
||||
export interface ICloudService {
|
||||
authenticate(...args: any[]): Promise<any>;
|
||||
getSecret(secretName: string, config: any): Promise<any>;
|
||||
getUniqueCacheKey<T extends {} = {}>(secretName: string, config?: T): string;
|
||||
}
|
||||
|
||||
export type AWSSecretType = 'kv' | 'plaintext';
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import QuickLRU from 'quick-lru';
|
||||
|
||||
export interface VaultCacheOptions {
|
||||
maxSize?: number;
|
||||
maxAge?: number;
|
||||
}
|
||||
export type MaxAgeUnit = 'ms' | 's' | 'min' | 'h';
|
||||
|
||||
// convert time unit to milliseconds
|
||||
export const timeToMs = (time: number, unit: MaxAgeUnit = 'ms') => {
|
||||
if (typeof time === 'number' && time > 0) {
|
||||
switch (unit) {
|
||||
case 'ms':
|
||||
return time;
|
||||
case 's':
|
||||
return time * 1000;
|
||||
case 'min':
|
||||
return time * 1000 * 60;
|
||||
case 'h':
|
||||
return time * 1000 * 60 * 60;
|
||||
default:
|
||||
return time;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export class VaultCache<K = string, T = any> {
|
||||
_cache: QuickLRU<K, T>;
|
||||
// The maximum number of milliseconds an item should remain in cache, default 30 mins
|
||||
_maxAge: number = 30 * 60 * 1000;
|
||||
|
||||
constructor(options?: VaultCacheOptions) {
|
||||
const { maxSize = 1000, maxAge } = options || {};
|
||||
this._maxAge = maxAge || this._maxAge;
|
||||
this._cache = new QuickLRU({ maxSize, maxAge: this._maxAge });
|
||||
}
|
||||
|
||||
has(key: K) {
|
||||
return this._cache.has(key);
|
||||
}
|
||||
|
||||
setItem(key: K, value: T, options?: Pick<Required<VaultCacheOptions>, 'maxAge'>) {
|
||||
const { maxAge = this._maxAge } = options || {};
|
||||
this._cache.set(key, value, { maxAge });
|
||||
}
|
||||
|
||||
getItem(key: K) {
|
||||
if (this._cache.has(key)) {
|
||||
return this._cache.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getKeys() {
|
||||
return Array.from(this._cache.keys());
|
||||
}
|
||||
|
||||
getValues() {
|
||||
return Array.from(this._cache.values());
|
||||
}
|
||||
|
||||
entriesAscending() {
|
||||
return this._cache.entriesAscending();
|
||||
}
|
||||
|
||||
entriesDescending() {
|
||||
return this._cache.entriesDescending();
|
||||
}
|
||||
|
||||
deleteItem(key: K) {
|
||||
if (this._cache.has(key)) {
|
||||
this._cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
resize(newSize: number) {
|
||||
if (newSize > 0) {
|
||||
this._cache.resize(newSize);
|
||||
} else {
|
||||
throw Error('cache size must be positive number');
|
||||
}
|
||||
}
|
||||
|
||||
setMaxAge(maxAge: number, unit: MaxAgeUnit = 'ms') {
|
||||
this._maxAge = timeToMs(maxAge, unit);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
getSize() {
|
||||
return this._cache.size;
|
||||
}
|
||||
|
||||
};
|
||||
@@ -35,6 +35,8 @@ export type HandleChannels =
|
||||
| 'secretStorage.encryptString'
|
||||
| 'secretStorage.decryptString'
|
||||
| 'cloudService.authenticate'
|
||||
| 'cloudService.getSecret'
|
||||
| 'cloudService.exchangeCode'
|
||||
| 'git.loadGitRepository'
|
||||
| 'git.getGitBranches'
|
||||
| 'git.gitFetchAction'
|
||||
@@ -105,7 +107,10 @@ export type MainOnChannels =
|
||||
| 'addExecutionStep'
|
||||
| 'completeExecutionStep'
|
||||
| 'updateLatestStepName'
|
||||
| 'startExecution';
|
||||
| 'startExecution'
|
||||
| 'cloudService.setCacheMaxAge'
|
||||
| 'cloudService.clearCache'
|
||||
| 'cloudService.openAuthUrl';
|
||||
|
||||
export type RendererOnChannels =
|
||||
'clear-all-models'
|
||||
|
||||
@@ -75,6 +75,7 @@ export function init(): BaseSettings {
|
||||
saveVaultKeyLocally: true,
|
||||
enableVaultInScripts: false,
|
||||
saveVaultKeyToOSSecretManager: true,
|
||||
vaultSecretCacheDuration: 30,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,9 @@ const git: GitServiceAPI = {
|
||||
|
||||
const cloudService: cloudServiceBridgeAPI = {
|
||||
authenticate: options => ipcRenderer.invoke('cloudService.authenticate', options),
|
||||
getSecret: options => ipcRenderer.invoke('cloudService.getSecret', options),
|
||||
setCacheMaxAge: options => ipcRenderer.send('cloudService.setCacheMaxAge', options),
|
||||
clearCache: () => ipcRenderer.send('cloudService.clearCache'),
|
||||
};
|
||||
|
||||
const main: Window['main'] = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { EditorFromTextArea, MarkerRange } from 'codemirror';
|
||||
import _ from 'lodash';
|
||||
|
||||
import type { RenderPurpose } from '../common/render';
|
||||
import type { BaseModel } from '../models';
|
||||
import { userSession } from '../models';
|
||||
import { decryptSecretValue, vaultEnvironmentMaskValue, vaultEnvironmentPath } from '../models/environment';
|
||||
import { decryptVaultKeyFromSession } from '../utils/vault';
|
||||
@@ -19,6 +20,7 @@ export interface NunjucksParsedTagArg {
|
||||
displayName?: DisplayName;
|
||||
quotedBy?: '"' | "'";
|
||||
validate?: (value: any) => string;
|
||||
modelFilter?: (model: BaseModel, tagArg: NunjucksParsedTagArg[]) => boolean;
|
||||
hide?: (arg0: NunjucksParsedTagArg[]) => boolean;
|
||||
model?: string;
|
||||
options?: PluginArgumentEnumOption[];
|
||||
|
||||
@@ -277,7 +277,7 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
|
||||
isOwner,
|
||||
});
|
||||
return;
|
||||
};
|
||||
}
|
||||
if (nunjucksTag) {
|
||||
const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions;
|
||||
switch (type) {
|
||||
|
||||
@@ -88,6 +88,7 @@ export const NunjucksModal = forwardRef<NunjucksModalHandle, ModalProps & Props>
|
||||
event.preventDefault();
|
||||
modalRef.current?.hide();
|
||||
}}
|
||||
className='px-2'
|
||||
>{editor}</form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AWSGetSecretConfig } from '../../../main/ipc/cloud-service-integration/aws-service';
|
||||
import type { CloudServiceSecretOption } from '../../../main/ipc/cloud-service-integration/cloud-service';
|
||||
import type { AWSSecretConfig, ExternalVaultConfig } from '../../../main/ipc/cloud-service-integration/types';
|
||||
import type { CloudProviderCredential, CloudProviderName } from '../../../models/cloud-credential';
|
||||
|
||||
export const getExternalVault = async (provider: CloudProviderName, providerCredential: CloudProviderCredential, secretConfig: ExternalVaultConfig) => {
|
||||
switch (provider) {
|
||||
case 'aws':
|
||||
return getAWSSecret(secretConfig as AWSSecretConfig, providerCredential);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getAWSSecret = async (secretConfig: AWSSecretConfig, providerCredential: CloudProviderCredential) => {
|
||||
const {
|
||||
SecretId, VersionId, VersionStage, SecretKey,
|
||||
SecretType = 'plaintext',
|
||||
} = secretConfig;
|
||||
if (!SecretId) {
|
||||
throw new Error('Get secret from AWS failed: Secret Name or ARN is required');
|
||||
}
|
||||
const getSecretOption: CloudServiceSecretOption<AWSGetSecretConfig> = {
|
||||
provider: 'aws',
|
||||
secretId: SecretId,
|
||||
config: {
|
||||
VersionId, VersionStage,
|
||||
},
|
||||
credentials: providerCredential.credentials,
|
||||
};
|
||||
const secretResult = await window.main.cloudService.getSecret(getSecretOption);
|
||||
const { success, error, result } = secretResult;
|
||||
if (success && result) {
|
||||
const { SecretString } = result!;
|
||||
let parsedJSON;
|
||||
if (SecretType === 'plaintext' || !SecretKey) {
|
||||
return SecretString;
|
||||
} else {
|
||||
try {
|
||||
parsedJSON = JSON.parse(SecretString || '{}');
|
||||
} catch (error) {
|
||||
throw new Error(`Get secret from AWS failed: Secret value ${SecretString} can not parsed to key/value pair, please change Secret Type to plaintext`);
|
||||
}
|
||||
if (SecretKey in parsedJSON) {
|
||||
return parsedJSON[SecretKey];
|
||||
}
|
||||
throw new Error(`Get secret from AWS failed: Secret key ${SecretKey} does not exist in key/value secret ${SecretString}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Get secret from AWS failed: ${error?.errorMessage}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { AWSSecretConfig } from '../../../../main/ipc/cloud-service-integration/types';
|
||||
import type { NunjucksParsedTag } from '../../../../templating/utils';
|
||||
import { HelpTooltip } from '../../help-tooltip';
|
||||
|
||||
export interface AWSSecretManagerFormProps {
|
||||
formData: AWSSecretConfig;
|
||||
onChange: (newConfig: AWSSecretConfig) => void;
|
||||
activeTagData: NunjucksParsedTag;
|
||||
}
|
||||
const secretTypeOptions = [
|
||||
{
|
||||
key: 'plaintext',
|
||||
label: 'Plaintext',
|
||||
},
|
||||
{
|
||||
key: 'kv',
|
||||
label: 'Key/Value',
|
||||
},
|
||||
];
|
||||
|
||||
export const AWSSecretManagerForm = (props: AWSSecretManagerFormProps) => {
|
||||
const { formData, onChange } = props;
|
||||
const {
|
||||
SecretId,
|
||||
SecretType,
|
||||
VersionId = '',
|
||||
VersionStage = '',
|
||||
SecretKey = '',
|
||||
} = formData;
|
||||
const [showSecretKeyInput, setShowSecretKeyInput] = useState(SecretType === 'kv');
|
||||
const handleOnChange = (name: keyof AWSSecretConfig, newValue: string) => {
|
||||
const newConfig = {
|
||||
...formData,
|
||||
[name]: newValue,
|
||||
};
|
||||
if (name === 'SecretType') {
|
||||
setShowSecretKeyInput(newValue === 'kv');
|
||||
if (newValue === 'plaintext') {
|
||||
newConfig['SecretKey'] = '';
|
||||
}
|
||||
};
|
||||
onChange(newConfig as unknown as AWSSecretConfig);
|
||||
};
|
||||
return (
|
||||
<form id='aws-secret-manager-form'>
|
||||
<div className="form-row">
|
||||
<div className="form-control">
|
||||
<label>
|
||||
Secret Name Or ARN
|
||||
<HelpTooltip className="space-left">
|
||||
The ARN or name of the secret to retrieve. To retrieve a secret from another account, you must use an ARN.
|
||||
</HelpTooltip>
|
||||
<input
|
||||
name='SecretId'
|
||||
defaultValue={SecretId}
|
||||
onChange={e => handleOnChange('SecretId', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-control">
|
||||
<label>
|
||||
Version Id
|
||||
<HelpTooltip className="space-left">
|
||||
Optional unique identifier of the version of the secret to retrieve.
|
||||
</HelpTooltip>
|
||||
<input
|
||||
name='VersionId'
|
||||
defaultValue={VersionId}
|
||||
onChange={e => handleOnChange('VersionId', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-control">
|
||||
<label>
|
||||
Version Stage
|
||||
<HelpTooltip className="space-left">
|
||||
Optional staging label of the version of the secret to retrieve.
|
||||
</HelpTooltip>
|
||||
<input
|
||||
name='VersionStage'
|
||||
defaultValue={VersionStage}
|
||||
onChange={e => handleOnChange('VersionStage', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-control">
|
||||
<label>
|
||||
Secret Type
|
||||
<select
|
||||
name='SecretType'
|
||||
defaultValue={SecretType || 'plaintext'}
|
||||
onChange={e => handleOnChange('SecretType', e.target.value)}
|
||||
>
|
||||
{secretTypeOptions.map(option => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{showSecretKeyInput &&
|
||||
<div className="form-row">
|
||||
<div className="form-control">
|
||||
<label>
|
||||
Secret Key
|
||||
<HelpTooltip className="space-left">
|
||||
The Secret Key of the retrived key/value secrets.
|
||||
</HelpTooltip>
|
||||
<input
|
||||
name='SecretKey'
|
||||
defaultValue={SecretKey}
|
||||
onChange={e => handleOnChange('SecretKey', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
|
||||
import { debounce } from '../../../../common/misc';
|
||||
import type { AWSSecretConfig, ExternalVaultConfig } from '../../../../main/ipc/cloud-service-integration/types';
|
||||
import { type CloudProviderCredential, type CloudProviderName, type } from '../../../../models/cloud-credential';
|
||||
import { Icon } from '../../icon';
|
||||
import { CloudCredentialModal } from '../../modals/cloud-credential-modal/cloud-credential-modal';
|
||||
import type { ArgConfigFormProps } from '../tag-editor-arg-sub-form';
|
||||
import { AWSSecretManagerForm } from './aws-secret-manager-form';
|
||||
|
||||
export const ExternalVaultForm = (props: ArgConfigFormProps) => {
|
||||
const { onChange, configValue, activeTagData, docs } = props;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const provider = activeTagData.args[0].value as CloudProviderName;
|
||||
const formData = JSON.parse(configValue) as ExternalVaultConfig;
|
||||
const selectedCredentialId = activeTagData.args[1].value;
|
||||
const cloudCredentialDocs = docs[type] as CloudProviderCredential[] || [];
|
||||
const selectedCredentialDoc = cloudCredentialDocs.find(d => d._id === selectedCredentialId);
|
||||
|
||||
const handleFormChange = debounce((newConfig: ExternalVaultConfig) => {
|
||||
const newFormValue = JSON.stringify(newConfig);
|
||||
onChange(newFormValue);
|
||||
}, 1000);
|
||||
let SubForm;
|
||||
|
||||
switch (provider) {
|
||||
case 'aws':
|
||||
SubForm = (
|
||||
<AWSSecretManagerForm
|
||||
formData={formData as AWSSecretConfig}
|
||||
onChange={handleFormChange}
|
||||
activeTagData={activeTagData}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
SubForm = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedCredentialDoc && provider !== 'azure' &&
|
||||
<Button
|
||||
className="px-2 py-1 mb-[--padding-sm] h-full flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] text-[--color-info] text-xs hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all"
|
||||
style={{ marginTop: 'calc(var(--padding-sm) * -1)' }}
|
||||
onPress={() => setShowModal(true)}
|
||||
>
|
||||
<Icon icon="edit" /> Edit Credential
|
||||
</Button>
|
||||
}
|
||||
{SubForm}
|
||||
{showModal &&
|
||||
<CloudCredentialModal
|
||||
provider={provider}
|
||||
providerCredential={selectedCredentialDoc}
|
||||
onClose={() => setShowModal(false)}
|
||||
onComplete={() => onChange(configValue)}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,15 +7,78 @@ import os from 'os';
|
||||
import { CookieJar } from 'tough-cookie';
|
||||
import * as uuid from 'uuid';
|
||||
|
||||
import type { RenderPurpose } from '../../../common/render';
|
||||
import type { ExternalVaultConfig } from '../../../main/ipc/cloud-service-integration/types';
|
||||
import * as models from '../../../models';
|
||||
import type { CloudProviderCredential, CloudProviderName } from '../../../models/cloud-credential';
|
||||
import { vaultEnvironmentMaskValue } from '../../../models/environment';
|
||||
import type { Request, RequestParameter } from '../../../models/request';
|
||||
import type { Response } from '../../../models/response';
|
||||
import type { TemplateTag } from '../../../plugins';
|
||||
import type { PluginTemplateTag } from '../../../templating/extensions';
|
||||
import { invariant } from '../../../utils/invariant';
|
||||
import { buildQueryStringFromParams, joinUrlAndQueryString, smartEncodeUrl } from '../../../utils/url/querystring';
|
||||
import { getExternalVault } from './external-vault';
|
||||
import { fakerFunctions } from './faker-functions';
|
||||
|
||||
const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [
|
||||
{
|
||||
templateTag: {
|
||||
name: 'vault',
|
||||
displayName: 'External Vault',
|
||||
description: 'Link secret from external vault',
|
||||
// external vault is an enterprise feature
|
||||
needsEnterprisePlan: true,
|
||||
args: [
|
||||
{
|
||||
displayName: 'Vault Service Provider',
|
||||
type: 'enum',
|
||||
options: [
|
||||
{ displayName: 'AWS Secrets Manager', value: 'aws' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Credential For Vault Service Provider',
|
||||
type: 'model',
|
||||
modelFilter: (credentialModel, args) => {
|
||||
const providerNameFromArg = args[0].value;
|
||||
const { provider } = credentialModel as CloudProviderCredential;
|
||||
return providerNameFromArg === provider;
|
||||
},
|
||||
model: 'CloudCredential',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
defaultValue: '{}',
|
||||
requireSubForm: true,
|
||||
},
|
||||
],
|
||||
async run(context, provider: CloudProviderName, credentialId: string, configStr: string) {
|
||||
if (!provider) {
|
||||
throw new Error('Get secret from external vault failed: Vault service provider is required');
|
||||
}
|
||||
if (!credentialId) {
|
||||
throw new Error('Get secret from external vault failed: Credential is required');
|
||||
};
|
||||
const providerCredential = await models.cloudCredential.getById(credentialId);
|
||||
if (!providerCredential) {
|
||||
throw new Error('Get secret from external vault failed: No Cloud Credential found');
|
||||
}
|
||||
const renderContext = context.renderPurpose as RenderPurpose;
|
||||
// Get secret from external vaults when send request or in tag-preview, otherwise return defautl mask value
|
||||
if (renderContext === 'preview' || renderContext === 'send') {
|
||||
let secretConfig = {};
|
||||
try {
|
||||
secretConfig = JSON.parse(configStr);
|
||||
} catch (error) {
|
||||
throw new Error('Get secret from external vault failed: Invalid vault secret config');
|
||||
}
|
||||
return getExternalVault(provider, providerCredential, secretConfig as ExternalVaultConfig);
|
||||
}
|
||||
return vaultEnvironmentMaskValue;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
templateTag: {
|
||||
name: 'faker',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { BaseModel } from '../../../models';
|
||||
import type { NunjucksParsedTag } from '../../../templating/utils';
|
||||
import { ExternalVaultForm } from './external-vault/external-vault-form';
|
||||
|
||||
export interface ArgConfigFormProps {
|
||||
configValue: string;
|
||||
activeTagDefinition: NunjucksParsedTag;
|
||||
activeTagData: NunjucksParsedTag;
|
||||
onChange: (newConfigValue: string) => void;
|
||||
docs: Record<string, BaseModel[]>;
|
||||
}
|
||||
const formTagNameMapping = {
|
||||
'vault': ExternalVaultForm,
|
||||
};
|
||||
const isValidJSONString = (input: string) => {
|
||||
try {
|
||||
const parsedJson = JSON.parse(input);
|
||||
// Check if the parsed JSON is an object and not an array or null
|
||||
return typeof parsedJson === 'object' && parsedJson !== null && !Array.isArray(parsedJson);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const couldRenderForm = (name: string) => name in formTagNameMapping;
|
||||
|
||||
export const ArgConfigSubForm = (props: ArgConfigFormProps) => {
|
||||
const { configValue, activeTagDefinition } = props;
|
||||
const tagName = activeTagDefinition.name as keyof typeof formTagNameMapping;
|
||||
const ConfigForm = formTagNameMapping[tagName];
|
||||
|
||||
if (ConfigForm && isValidJSONString(configValue)) {
|
||||
return <ConfigForm {...props} />;
|
||||
}
|
||||
return configValue;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { docsAfterResponseScript } from '../../../common/documentation';
|
||||
import { delay, fnOrString } from '../../../common/misc';
|
||||
import { metaSortKeySort } from '../../../common/sorting';
|
||||
import * as models from '../../../models';
|
||||
import { type as cloudCredentialModelType } from '../../../models/cloud-credential';
|
||||
import type { BaseModel } from '../../../models/index';
|
||||
import { isRequest, type Request } from '../../../models/request';
|
||||
import { isRequestGroup, type RequestGroup } from '../../../models/request-group';
|
||||
@@ -28,6 +29,7 @@ import { FileInputButton } from '../base/file-input-button';
|
||||
import { HelpTooltip } from '../help-tooltip';
|
||||
import { Icon } from '../icon';
|
||||
import { localTemplateTags } from './local-template-tags';
|
||||
import { ArgConfigSubForm, couldRenderForm } from './tag-editor-arg-sub-form';
|
||||
|
||||
interface Props {
|
||||
defaultValue: string;
|
||||
@@ -88,6 +90,8 @@ export const TagEditor: FC<Props> = props => {
|
||||
for (const doc of await db.withDescendants(props.workspace, models.request.type)) {
|
||||
allDocs[doc.type].push(doc);
|
||||
}
|
||||
// add global Cloud Credential data
|
||||
allDocs[cloudCredentialModelType] = await models.cloudCredential.all();
|
||||
// @ts-expect-error -- type unsoundness
|
||||
allDocs[models.request.type] = sortRequests((allDocs[models.request.type] || []).concat(allDocs[models.requestGroup.type] || []), props.workspace._id);
|
||||
setState(state => ({ ...state, allDocs, loadingDocs: false }));
|
||||
@@ -335,19 +339,36 @@ export const TagEditor: FC<Props> = props => {
|
||||
const isVariable = argData.type === 'variable';
|
||||
|
||||
let argInput;
|
||||
const isVariableAllowed = argDefinition.type !== 'model';
|
||||
let isVariableAllowed = argDefinition.type !== 'model';
|
||||
if (!isVariable) {
|
||||
if (argDefinition.type === 'string') {
|
||||
const tagDefinitionName = activeTagDefinition.name;
|
||||
const placeholder =
|
||||
typeof argDefinition.placeholder === 'string' ? argDefinition.placeholder : '';
|
||||
const encoding = argDefinition.encoding || 'utf8';
|
||||
argInput = (<input
|
||||
type="text"
|
||||
defaultValue={sanitizeStrForWin32(strValue)}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
data-encoding={encoding}
|
||||
/>);
|
||||
const needToRenderSubForm = argDefinition.requireSubForm && couldRenderForm(tagDefinitionName);
|
||||
if (needToRenderSubForm) {
|
||||
argInput = (
|
||||
<ArgConfigSubForm
|
||||
configValue={sanitizeStrForWin32(strValue)}
|
||||
onChange={(newConfigValue: string) => updateArg(newConfigValue, index)}
|
||||
activeTagData={activeTagData}
|
||||
activeTagDefinition={activeTagDefinition}
|
||||
docs={state.allDocs}
|
||||
/>
|
||||
);
|
||||
isVariableAllowed = false;
|
||||
} else {
|
||||
argInput = (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={sanitizeStrForWin32(strValue)}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
data-encoding={encoding}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (argDefinition.type === 'enum') {
|
||||
argInput = (
|
||||
<select value={strValue} onChange={handleChange}>
|
||||
@@ -371,6 +392,12 @@ export const TagEditor: FC<Props> = props => {
|
||||
extensions={argDefinition.extensions}
|
||||
/>);
|
||||
} else if (argDefinition.type === 'model') {
|
||||
const modelName = typeof argDefinition.model === 'string' ? argDefinition.model : 'unknown';
|
||||
let targetDoc = state.allDocs[modelName];
|
||||
const modelFilterFunc = argDefinition.modelFilter;
|
||||
if (modelFilterFunc && typeof modelFilterFunc === 'function') {
|
||||
targetDoc = targetDoc.filter(doc => modelFilterFunc(doc, activeTagData.args));
|
||||
}
|
||||
argInput = state.loadingDocs ? (
|
||||
<select disabled={state.loadingDocs}>
|
||||
<option>Loading...</option>
|
||||
@@ -378,7 +405,7 @@ export const TagEditor: FC<Props> = props => {
|
||||
) : (
|
||||
<select value={typeof strValue === 'string' ? strValue : 'unknown'} onChange={handleChange}>
|
||||
<option value="n/a">-- Select Item --</option>
|
||||
{state.allDocs[typeof argDefinition.model === 'string' ? argDefinition.model : 'unknown']?.map((doc: any) => {
|
||||
{targetDoc.map((doc: any) => {
|
||||
let namePrefix: string | null = null;
|
||||
// Show parent folder with name if it's a request
|
||||
if (isRequest(doc)) {
|
||||
|
||||
@@ -7,10 +7,10 @@ import { getKeys } from '../../../templating/utils';
|
||||
import type { RequestLoaderData } from '../../routes/request';
|
||||
import type { WorkspaceLoaderData } from '../../routes/workspace';
|
||||
let getRenderContextPromiseCache: any = {};
|
||||
|
||||
export interface UseNunjucksOptions {
|
||||
renderContext: Pick<Partial<RenderContextOptions>, 'purpose' | 'extraInfo'>;
|
||||
}
|
||||
|
||||
export const initializeNunjucksRenderPromiseCache = () => {
|
||||
getRenderContextPromiseCache = {};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user