External Vault AWS: Add new settings page for cloud service credentials[INS-4623] (#2)

* Initial check-in for smaller part of AWS change, only including setting modal for add cloud service provider credentials.
* fix issue from comment
This commit is contained in:
Kent Wang
2025-02-19 16:11:27 +08:00
committed by Jay Wu
parent 5203ce5bbc
commit 1802f2f275
21 changed files with 2426 additions and 1 deletions

1648
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,8 @@
"dependencies": {
"@apideck/better-ajv-errors": "^0.3.6",
"@apidevtools/swagger-parser": "10.1.0",
"@aws-sdk/client-secrets-manager": "^3.686.0",
"@aws-sdk/client-sts": "^3.686.0",
"@bufbuild/protobuf": "^1.8.0",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-node": "^1.4.0",

View File

@@ -13,6 +13,7 @@ import { SegmentEvent, trackSegmentEvent } from './main/analytics';
import { registerInsomniaProtocols } from './main/api.protocol';
import { backupIfNewerVersionAvailable } from './main/backup';
import { registerGitServiceAPI } from './main/git-service';
import { registerCloudServiceHandlers } from './main/ipc/cloud-service-integration/cloud-service';
import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron';
import { registergRPCHandlers } from './main/ipc/grpc';
import { registerMainHandlers } from './main/ipc/main';
@@ -69,6 +70,7 @@ app.on('ready', async () => {
registerWebSocketHandlers();
registerCurlHandlers();
registerSecretStorageHandlers();
registerCloudServiceHandlers();
/**
* There's no option that prevents Electron from fetching spellcheck dictionaries from Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from fetching.

View File

@@ -0,0 +1,46 @@
import { GetCallerIdentityCommand, type GetCallerIdentityCommandOutput, STSClient, STSServiceException } from '@aws-sdk/client-sts';
import type { AWSTemporaryCredential, CloudProviderName } from '../../../models/cloud-credential';
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;
constructor(credential: AWSTemporaryCredential) {
this._credential = credential;
}
async authenticate(): Promise<CloudServiceResult<GetCallerIdentityCommandOutput>> {
const { region, accessKeyId, secretAccessKey, sessionToken } = this._credential;
const stsClient = new STSClient({
region,
credentials: {
accessKeyId, secretAccessKey, sessionToken,
},
});
try {
const response = await stsClient.send(new GetCallerIdentityCommand({}));
return {
success: true,
result: response,
};
} catch (error) {
const errorDetail = {
errorCode: error.code || 'UnknownError',
errorMessage: error.message || 'Failed to authenticate with AWS. An unknown error occurred',
};
if (error instanceof STSServiceException) {
errorDetail.errorCode = error.name || errorDetail.errorCode;
errorDetail.errorMessage = error.message || errorDetail.errorMessage;
}
return {
success: false,
result: null,
error: errorDetail,
};
}
}
};

View File

@@ -0,0 +1,39 @@
import type { AWSTemporaryCredential, BaseCloudCredential, CloudProviderName } from '../../../models/cloud-credential';
import { ipcMainHandle } from '../electron';
import { type AWSGetSecretConfig, AWSService } from './aws-service';
export interface cloudServiceBridgeAPI {
authenticate: typeof cloudServiceProviderAuthentication;
}
export interface CloudServiceAuthOption {
provider: CloudProviderName;
credentials: BaseCloudCredential['credentials'];
}
export interface CloudServiceSecretOption<T extends {}> extends CloudServiceAuthOption {
secretId: string;
config: T;
}
export type CloudServiceGetSecretConfig = AWSGetSecretConfig;
export function registerCloudServiceHandlers() {
ipcMainHandle('cloudService.authenticate', (_event, options) => cloudServiceProviderAuthentication(options));
}
// factory pattern to create cloud service class based on its provider name
class ServiceFactory {
static createCloudService(name: CloudProviderName, credential: BaseCloudCredential['credentials']) {
switch (name) {
case 'aws':
return new AWSService(credential as AWSTemporaryCredential);
default:
throw new Error('Invalid cloud service provider name');
}
}
};
// authenticate with cloud service provider
const cloudServiceProviderAuthentication = (options: CloudServiceAuthOption) => {
const { provider, credentials } = options;
const cloudService = ServiceFactory.createCloudService(provider, credentials);
return cloudService.authenticate();
};

View File

@@ -0,0 +1,23 @@
export interface CloudServiceError {
errorCode: string;
errorMessage: string;
}
export interface CloudServiceResult<T extends Record<string, any>> {
success: boolean;
result?: T | null;
error?: CloudServiceError;
}
export interface ICloudService {
authenticate(...args: any[]): Promise<any>;
}
export type AWSSecretType = 'kv' | 'plaintext';
export interface AWSSecretConfig {
SecretId: string;
VersionId?: string;
VersionStage?: string;
SecretType: AWSSecretType;
SecretKey?: string;
};
export type ExternalVaultConfig = AWSSecretConfig;

View File

@@ -34,6 +34,7 @@ export type HandleChannels =
| 'secretStorage.deleteSecret'
| 'secretStorage.encryptString'
| 'secretStorage.decryptString'
| 'cloudService.authenticate'
| 'git.loadGitRepository'
| 'git.getGitBranches'
| 'git.gitFetchAction'

View File

@@ -15,6 +15,7 @@ import type { CurlBridgeAPI } from '../network/curl';
import { cancelCurlRequest, curlRequest } from '../network/libcurl-promise';
import { addExecutionStep, completeExecutionStep, getExecution, startExecution, type TimingStep, updateLatestStepName } from '../network/request-timing';
import type { WebSocketBridgeAPI } from '../network/websocket';
import type { cloudServiceBridgeAPI } from './cloud-service-integration/cloud-service';
import { ipcMainHandle, ipcMainOn, ipcMainOnce, type RendererOnChannels } from './electron';
import extractPostmanDataDumpHandler from './extractPostmanDataDump';
import type { gRPCBridgeAPI } from './grpc';
@@ -40,6 +41,7 @@ export interface RendererToMainBridgeAPI {
curl: CurlBridgeAPI;
git: GitServiceAPI;
secretStorage: secretStorageBridgeAPI;
cloudService: cloudServiceBridgeAPI;
trackSegmentEvent: (options: { event: string; properties?: Record<string, unknown> }) => void;
trackPageView: (options: { name: string }) => void;
showNunjucksContextMenu: (options: { key: string; nunjucksTag?: { template: string; range: MarkerRange } }) => void;

View File

@@ -0,0 +1,79 @@
import { database as db } from '../common/database';
import type { BaseModel } from './index';
export type CloudProviderName = 'aws' | 'azure' | 'gcp';
export enum AWSCredentialType {
temp = 'temporary'
}
export interface AWSTemporaryCredential {
type: AWSCredentialType.temp;
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
region: string;
}
interface IBaseCloudCredential {
name: string;
provider: CloudProviderName;
}
export interface AWSCloudCredential extends IBaseCloudCredential {
name: string;
provider: 'aws';
credentials: AWSTemporaryCredential;
}
export type BaseCloudCredential = AWSCloudCredential;
export type CloudProviderCredential = BaseModel & BaseCloudCredential;
export const name = 'Cloud Credential';
export const type = 'CloudCredential';
export const prefix = 'cloudCred';
export const canDuplicate = false;
export const canSync = false;
export const isCloudCredential = (model: Pick<BaseModel, 'type'>): model is CloudProviderCredential => (
model.type === type
);
export function getProviderDisplayName(provider: CloudProviderName) {
return {
aws: 'AWS',
azure: 'Azure',
gcp: 'GCP',
}[provider] || '';
};
export function init(): Partial<BaseCloudCredential> {
return {
name: '',
provider: undefined,
credentials: undefined,
};
}
export function migrate(doc: BaseCloudCredential) {
return doc;
}
export function create(patch: Partial<CloudProviderCredential> = {}) {
return db.docCreate<CloudProviderCredential>(type, patch);
}
export async function getById(id: string) {
return db.getWhere<CloudProviderCredential>(type, { _id: id });
}
export function update(credential: CloudProviderCredential, patch: Partial<CloudProviderCredential>) {
return db.docUpdate<CloudProviderCredential>(credential, patch);
}
export function remove(credential: CloudProviderCredential) {
return db.remove(credential);
}
export function getByName(name: string, provider: CloudProviderName) {
return db.find<CloudProviderCredential>(type, { name, provider });
}
export function all() {
return db.all<CloudProviderCredential>(type);
}

View File

@@ -20,6 +20,7 @@ import { generateId } from '../common/misc';
import * as _apiSpec from './api-spec';
import * as _caCertificate from './ca-certificate';
import * as _clientCertificate from './client-certificate';
import * as _cloudCredential from './cloud-credential';
import * as _cookieJar from './cookie-jar';
import * as _environment from './environment';
import * as _gitCredentials from './git-credentials';
@@ -101,6 +102,7 @@ export const workspace = _workspace;
export const workspaceMeta = _workspaceMeta;
export * as organization from './organization';
export const userSession = _userSession;
export const cloudCredential = _cloudCredential;
export function all() {
// NOTE: This list should be from most to least specific (ie. parents above children)
@@ -141,6 +143,7 @@ export function all() {
webSocketRequest,
webSocketResponse,
userSession,
cloudCredential,
] as const;
}

View File

@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer, webUtils as _webUtils } from 'electron';
import type { GitServiceAPI } from './main/git-service';
import type { cloudServiceBridgeAPI } from './main/ipc/cloud-service-integration/cloud-service';
import type { gRPCBridgeAPI } from './main/ipc/grpc';
import type { secretStorageBridgeAPI } from './main/ipc/secret-storage';
import type { CurlBridgeAPI } from './main/network/curl';
@@ -83,6 +84,10 @@ const git: GitServiceAPI = {
completeSignInToGitLab: options => ipcRenderer.invoke('git.completeSignInToGitLab', options),
};
const cloudService: cloudServiceBridgeAPI = {
authenticate: options => ipcRenderer.invoke('cloudService.authenticate', options),
};
const main: Window['main'] = {
startExecution: options => ipcRenderer.send('startExecution', options),
addExecutionStep: options => ipcRenderer.send('addExecutionStep', options),
@@ -112,6 +117,7 @@ const main: Window['main'] = {
grpc,
curl,
secretStorage,
cloudService,
trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options),
trackPageView: options => ipcRenderer.send('trackPageView', options),
showNunjucksContextMenu: options => ipcRenderer.send('show-nunjucks-context-menu', options),

View File

@@ -0,0 +1,13 @@
/* eslint-disable react/no-unknown-property */
import React, { memo, type SVGProps } from 'react';
export const SvgIcnAzureLogo = memo<SVGProps<SVGSVGElement>>(props => (
<svg viewBox="-0.4500000000000005 0.38 800.8891043012813 754.2299999999999" width="1em" height="1em" role="img" {...props}>
<linearGradient id="a" gradientUnits="userSpaceOnUse" x1="353.1" x2="107.1" y1="56.3" y2="783"><stop offset="0" stop-color="#114a8b" /><stop offset="1" stop-color="#0669bc" /></linearGradient>
<linearGradient id="b" gradientUnits="userSpaceOnUse" x1="429.8" x2="372.9" y1="394.9" y2="414.2"><stop offset="0" stop-opacity=".3" /><stop offset=".1" stop-opacity=".2" /><stop offset=".3" stop-opacity=".1" /><stop offset=".6" stop-opacity=".1" /><stop offset="1" stop-opacity="0" /></linearGradient>
<linearGradient id="c" gradientUnits="userSpaceOnUse" x1="398.4" x2="668.4" y1="35.1" y2="754.4"><stop offset="0" stop-color="#3ccbf4" /><stop offset="1" stop-color="#2892df" /></linearGradient>
<path d="M266.71.4h236.71L257.69 728.9a37.8 37.8 0 0 1-5.42 10.38c-2.33 3.16-5.14 5.93-8.33 8.22s-6.71 4.07-10.45 5.27-7.64 1.82-11.56 1.82H37.71c-5.98 0-11.88-1.42-17.2-4.16A37.636 37.636 0 0 1 7.1 738.87a37.762 37.762 0 0 1-6.66-16.41c-.89-5.92-.35-11.97 1.56-17.64L230.94 26.07c1.25-3.72 3.08-7.22 5.42-10.38 2.33-3.16 5.15-5.93 8.33-8.22 3.19-2.29 6.71-4.07 10.45-5.27S262.78.38 266.7.38v.01z" fill="url(#a)" />
<path d="M703.07 754.59H490.52c-2.37 0-4.74-.22-7.08-.67-2.33-.44-4.62-1.1-6.83-1.97s-4.33-1.95-6.34-3.21a38.188 38.188 0 0 1-5.63-4.34l-241.2-225.26a17.423 17.423 0 0 1-5.1-8.88 17.383 17.383 0 0 1 7.17-18.21c2.89-1.96 6.3-3.01 9.79-3.01h375.36l92.39 265.56z" fill="#0078d4" />
<path d="M504.27.4l-165.7 488.69 270.74-.06 92.87 265.56H490.43c-2.19-.02-4.38-.22-6.54-.61s-4.28-.96-6.34-1.72a38.484 38.484 0 0 1-11.36-6.51L303.37 593.79l-45.58 134.42c-1.18 3.36-2.8 6.55-4.82 9.48a40.479 40.479 0 0 1-16.05 13.67 40.03 40.03 0 0 1-10.13 3.23H37.82c-6.04.02-12-1.42-17.37-4.2A37.664 37.664 0 0 1 .43 722a37.77 37.77 0 0 1 1.87-17.79L230.87 26.58c1.19-3.79 2.98-7.36 5.3-10.58 2.31-3.22 5.13-6.06 8.33-8.4s6.76-4.16 10.53-5.38S262.75.38 266.72.4h237.56z" fill="url(#b)" />
<path d="M797.99 704.82a37.847 37.847 0 0 1 1.57 17.64 37.867 37.867 0 0 1-6.65 16.41 37.691 37.691 0 0 1-30.61 15.72H498.48c5.98 0 11.88-1.43 17.21-4.16 5.32-2.73 9.92-6.7 13.41-11.56s5.77-10.49 6.66-16.41.35-11.97-1.56-17.64L305.25 26.05a37.713 37.713 0 0 0-13.73-18.58c-3.18-2.29-6.7-4.06-10.43-5.26S273.46.4 269.55.4h263.81c3.92 0 7.81.61 11.55 1.81 3.73 1.2 7.25 2.98 10.44 5.26 3.18 2.29 5.99 5.06 8.32 8.21s4.15 6.65 5.41 10.37l228.95 678.77z" fill="url(#c)" />
</svg>
));

View File

@@ -0,0 +1,8 @@
import React, { memo, type SVGProps } from 'react';
export const SvgIcnGCPLogo = memo<SVGProps<SVGSVGElement>>(props => (
<svg viewBox="0 0 256 206" width="1em" height="1em" role="img" {...props}>
<path d="m170.2517 56.8186 22.253-22.253 1.483-9.37c-40.551-36.873-105.012-32.692-141.567 8.724-10.154 11.503-17.687 25.844-21.704 40.653l7.97-1.123 44.505-7.339 3.436-3.514c19.797-21.742 53.27-24.667 76.128-6.168z" fill="#ea4335" />
<path d="m224.2048 73.9182c-5.115-18.836-15.616-35.769-30.217-48.722l-31.232 31.232c13.188 10.776 20.701 27.01 20.379 44.037v5.544c15.351 0 27.797 12.445 27.797 27.796 0 15.352-12.446 27.485-27.797 27.485h-55.671l-5.466 5.934v33.34l5.466 5.231h55.671c39.93.311 72.552-31.494 72.863-71.424.188-24.203-11.745-46.893-31.793-60.453" fill="#4285f4" />
<path d="m71.8704 205.7957h55.593v-44.506h-55.593c-3.961-.001-7.797-.852-11.399-2.498l-7.887 2.42-22.409 22.253-1.952 7.574c12.567 9.489 27.9 14.825 43.647 14.757" fill="#34a853" /><path d="m71.8704 61.4255c-39.931.238-72.107 32.802-71.869 72.732.133 22.298 10.547 43.288 28.222 56.881l32.248-32.247c-13.991-6.321-20.208-22.786-13.887-36.776 6.32-13.99 22.786-20.207 36.775-13.887 6.165 2.785 11.102 7.723 13.887 13.887l32.248-32.247c-13.721-17.937-35.041-28.424-57.624-28.343" fill="#fbbc05" />
</svg>
));

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { Button, Input, Label, TextField } from 'react-aria-components';
import { AWSCredentialType, type AWSTemporaryCredential, type BaseCloudCredential, type CloudProviderCredential, type CloudProviderName } from '../../../../models/cloud-credential';
import { Icon } from '../../icon';
export interface AWSCredentialFormProps {
data?: CloudProviderCredential;
onSubmit: (newData: BaseCloudCredential) => void;
isLoading: boolean;
errorMessage?: string;
}
const initialFormValue = {
name: '',
credentials: {
accessKeyId: '',
secretAccessKey: '',
sessionToken: '',
region: '',
},
};
export const providerType: CloudProviderName = 'aws';
const ToggleBtn = (props: { isHidden: boolean; onShowHideInput: () => void }) => {
const { isHidden, onShowHideInput } = props;
return (
<Button
className="px-4 h-8 min-w-[12ch] py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={onShowHideInput}
>
{isHidden ? <i className="fa fa-eye-slash" /> : <i className="fa fa-eye" />}
</Button>
);
};
export const AWSCredentialForm = (props: AWSCredentialFormProps) => {
const { data, onSubmit, isLoading, errorMessage } = props;
const isEdit = !!data;
const { name, credentials } = data || initialFormValue;
const { accessKeyId, secretAccessKey, sessionToken, region } = credentials! as AWSTemporaryCredential;
const [hideValueItemNames, setHideValueItemNames] = useState(['accessKeyId', 'secretAccessKey', 'sessionToken']);
const showOrHideItemValue = (name: string) => {
if (hideValueItemNames.includes(name)) {
setHideValueItemNames(hideValueItemNames.filter(n => n !== name));
} else {
setHideValueItemNames([...hideValueItemNames, name]);
}
};
return (
<form
className='flex flex-col gap-2 flex-shrink-0'
onSubmit={e => {
e.preventDefault();
e.stopPropagation();
const formData = new FormData(e.currentTarget);
const { name, accessKeyId, secretAccessKey, sessionToken, region } = Object.fromEntries(formData.entries()) as Record<string, string>;
// hard-code here since we only support AWS temporary token for now
const type = AWSCredentialType.temp;
const newData = {
name,
provider: providerType,
credentials: { accessKeyId, secretAccessKey, sessionToken, region, type },
};
onSubmit(newData);
}}
>
<div className='flex flex-col gap-2'>
<TextField
className="flex flex-col gap-2"
defaultValue={name}
>
<Label className='col-span-4'>
Credential Name:
</Label>
<Input
required
className='py-1 h-8 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors flex-1 placeholder:italic placeholder:opacity-60 col-span-3'
type="text"
name="name"
placeholder="Credential name"
/>
</TextField>
<TextField
className="flex flex-col gap-2"
defaultValue={accessKeyId}
>
<Label className='col-span-4'>
Access Key Id:
</Label>
<div className='flex items-center gap-2'>
<Input
required
className='py-1 h-8 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors flex-1 placeholder:italic placeholder:opacity-60 col-span-3'
type={hideValueItemNames.includes('accessKeyId') ? 'password' : 'text'}
name="accessKeyId"
placeholder="Access Key Id"
/>
<ToggleBtn
isHidden={hideValueItemNames.includes('accessKeyId')}
onShowHideInput={() => showOrHideItemValue('accessKeyId')}
/>
</div>
</TextField>
<TextField
className="flex flex-col gap-2"
defaultValue={secretAccessKey}
>
<Label className='col-span-4'>
Secret Access Key:
</Label>
<div className='flex items-center gap-2'>
<Input
required
className='py-1 h-8 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors flex-1 placeholder:italic placeholder:opacity-60 col-span-3'
type={hideValueItemNames.includes('secretAccessKey') ? 'password' : 'text'}
name="secretAccessKey"
placeholder="Secret Access Key"
/>
<ToggleBtn
isHidden={hideValueItemNames.includes('secretAccessKey')}
onShowHideInput={() => showOrHideItemValue('secretAccessKey')}
/>
</div>
</TextField>
<TextField
className="flex flex-col gap-2"
defaultValue={sessionToken}
>
<Label className='col-span-4'>
Session Token:
</Label>
<div className='flex items-center gap-2'>
<Input
required
className='py-1 h-8 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors flex-1 placeholder:italic placeholder:opacity-60 col-span-3'
type={hideValueItemNames.includes('sessionToken') ? 'password' : 'text'}
name="sessionToken"
placeholder="AWS Secret Token"
/>
<ToggleBtn
isHidden={hideValueItemNames.includes('sessionToken')}
onShowHideInput={() => showOrHideItemValue('sessionToken')}
/>
</div>
</TextField>
<TextField
className="flex flex-col gap-2"
defaultValue={region}
>
<Label className='col-span-4'>
Region:
</Label>
<Input
required
className='py-1 h-8 w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors flex-1 placeholder:italic placeholder:opacity-60 col-span-3'
type="text"
name="region"
placeholder="Region"
/>
</TextField>
</div>
{errorMessage &&
<p className="notice error margin-top-sm no-margin-bottom">{errorMessage}</p>
}
<div className='w-full flex flex-row items-center justify-end gap-[--padding-md] pt-[--padding-md]'>
<Button
className="hover:no-underline text-right bg-[--color-surprise] hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font-surprise] transition-colors rounded-sm"
type='submit'
isDisabled={isLoading}
>
{isLoading && <Icon icon="spinner" className="text-[--color-font] animate-spin m-auto inline-block mr-2" />}
{isEdit ? 'Update' : 'Create'}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,96 @@
import React, { useEffect, useMemo } from 'react';
import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components';
import { useFetcher } from 'react-router-dom';
import { type BaseCloudCredential, type CloudProviderCredential, type CloudProviderName, getProviderDisplayName } from '../../../../models/cloud-credential';
import { Icon } from '../../icon';
import { AWSCredentialForm } from './aws-credential-form';
export interface CloudCredentialModalProps {
provider: CloudProviderName;
providerCredential?: CloudProviderCredential;
onClose: (data?: any) => void;
onComplete?: (data?: any) => void;
};
export const CloudCredentialModal = (props: CloudCredentialModalProps) => {
const { provider, providerCredential, onClose, onComplete } = props;
const providerDisplayName = getProviderDisplayName(provider);
const cloudCredentialFetcher = useFetcher();
const isEditing = !!providerCredential;
const fetchErrorMessage = useMemo(() => {
if (cloudCredentialFetcher.data && 'error' in cloudCredentialFetcher.data && cloudCredentialFetcher.data.error && cloudCredentialFetcher.state === 'idle') {
const errorMessage: string = cloudCredentialFetcher.data.error || `An unexpected error occurred while authenticating with ${getProviderDisplayName(provider)}.`;
return errorMessage;
}
return undefined;
}, [cloudCredentialFetcher.data, cloudCredentialFetcher.state, provider]);
const handleFormSubmit = (data: BaseCloudCredential & { isAuthenticated?: boolean }) => {
const { name, credentials, isAuthenticated = false } = data;
const formAction = isEditing ? `/cloud-credential/${providerCredential._id}/update` : '/cloud-credential/new';
cloudCredentialFetcher.submit(
JSON.stringify({ name, credentials, provider, isAuthenticated }),
{
action: formAction,
method: 'post',
encType: 'application/json',
}
);
};
useEffect(() => {
// close modal if submit success
if (cloudCredentialFetcher.data && !cloudCredentialFetcher.data.error && cloudCredentialFetcher.state === 'idle') {
const newCredentialData = cloudCredentialFetcher.data;
onClose(newCredentialData);
onComplete && onComplete(newCredentialData);
};
}, [cloudCredentialFetcher.data, cloudCredentialFetcher.state, onClose, onComplete]);
return (
<ModalOverlay
isOpen
isDismissable
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="w-full h-[--visual-viewport-height] fixed z-[9999] top-0 left-0 flex items-start justify-center bg-black/30"
>
<Modal
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="max-h-[75%] overflow-auto flex flex-col w-full max-w-3xl rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] bg-[--color-bg] text-[--color-font] m-24"
>
<Dialog
className="outline-none flex-1 h-full flex flex-col overflow-hidden"
>
{({ close }) => (
<div className='flex-1 flex flex-col gap-4 overflow-hidden'>
<div className='flex gap-2 items-center justify-between'>
<Heading slot="title" className='text-2xl'>{providerCredential ? `Edit ${providerDisplayName} credential` : `Authenticate With ${providerDisplayName}`}</Heading>
<Button
className="flex flex-shrink-0 items-center justify-center aspect-square h-6 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
id="close-add-cloud-crendeital-modal"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
{provider === 'aws' &&
<AWSCredentialForm
data={providerCredential}
isLoading={cloudCredentialFetcher.state !== 'idle'}
onSubmit={handleFormSubmit}
errorMessage={fetchErrorMessage}
/>
}
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -8,6 +8,7 @@ import { ModalBody } from '../base/modal-body';
import { ModalHeader } from '../base/modal-header';
import { AI } from '../settings/ai';
import { BooleanSetting } from '../settings/boolean-setting';
import { CloudServiceCredentialList } from '../settings/cloud-service-credentials';
import { General } from '../settings/general';
import { ImportExport } from '../settings/import-export';
import { MaskedSetting } from '../settings/masked-setting';
@@ -27,6 +28,7 @@ export const TAB_INDEX_SHORTCUTS = 'keyboard';
export const TAB_INDEX_THEMES = 'themes';
export const TAB_INDEX_PLUGINS = 'plugins';
export const TAB_INDEX_AI = 'ai';
export const TAB_CLOUD_CREDENTIAL = 'cloudCred';
export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => {
const [defaultTabKey, setDefaultTabKey] = useState('general');
@@ -98,6 +100,12 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
>
Plugins
</Tab>
<Tab
className='flex-shrink-0 h-full flex items-center justify-between cursor-pointer gap-2 outline-none select-none px-3 py-1 text-[--hl] aria-selected:text-[--color-font] hover:bg-[--hl-sm] hover:text-[--color-font] aria-selected:bg-[--hl-xs] aria-selected:focus:bg-[--hl-sm] aria-selected:hover:bg-[--hl-sm] focus:bg-[--hl-sm] transition-colors duration-300'
id='cloudCred'
>
Cloud Credentials
</Tab>
<Tab
className='flex-shrink-0 h-full flex items-center justify-between cursor-pointer gap-2 outline-none select-none px-3 py-1 text-[--hl] aria-selected:text-[--color-font] hover:bg-[--hl-sm] hover:text-[--color-font] aria-selected:bg-[--hl-xs] aria-selected:focus:bg-[--hl-sm] aria-selected:hover:bg-[--hl-sm] focus:bg-[--hl-sm] transition-colors duration-300'
id='ai'
@@ -153,6 +161,9 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
<TabPanel className='w-full h-full overflow-y-auto p-4' id='plugins'>
<Plugins />
</TabPanel>
<TabPanel className='w-full h-full overflow-y-auto p-4' id='cloudCred'>
<CloudServiceCredentialList />
</TabPanel>
<TabPanel className='w-full h-full overflow-y-auto p-4' id='ai'>
<AI />
</TabPanel>

View File

@@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components';
import { useFetcher } from 'react-router-dom';
import { type CloudProviderCredential, type CloudProviderName, getProviderDisplayName } from '../../../models/cloud-credential';
import { usePlanData } from '../../hooks/use-plan';
import { useRootLoaderData } from '../../routes/root';
import { Icon } from '../icon';
import { showModal } from '../modals';
import { AskModal } from '../modals/ask-modal';
import { CloudCredentialModal } from '../modals/cloud-credential-modal/cloud-credential-modal';
import { UpgradeNotice } from '../upgrade-notice';
interface createCredentialItemType {
name: string;
id: CloudProviderName;
icon: JSX.Element;
}
const createCredentialItemList: createCredentialItemType[] = [
{
id: 'aws',
name: getProviderDisplayName('aws'),
icon: <i className="ml-1 fa-brands fa-aws" />,
},
];
const buttonClassName = 'disabled:opacity-50 h-7 aspect-square aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] transition-all text-sm py-1 px-2';
export const CloudServiceCredentialList = () => {
const { isOwner, isEnterprisePlan } = usePlanData();
const { cloudCredentials } = useRootLoaderData();
const [modalState, setModalState] = useState<{ show: boolean; provider: CloudProviderName; credential?: CloudProviderCredential }>();
const deleteCredentialFetcher = useFetcher();
const handleDeleteItem = (id: string, name: string) => {
showModal(AskModal, {
title: 'Delete Cloud Credential?',
message: `Are you sure to delete ${name}?`,
onDone: async (isYes: boolean) => {
if (isYes) {
deleteCredentialFetcher.submit({}, {
action: `/cloud-credential/${id}/delete`,
method: 'delete',
});
}
},
});
};
const hideModal = () => {
setModalState(prevState => {
const newState = {
show: false,
provider: prevState!.provider,
credentials: undefined,
};
return newState;
});
};
const handleCreateCloudServiceCredential = (key: CloudProviderName) => {
setModalState({ show: true, provider: key as CloudProviderName });
};
if (!isEnterprisePlan) {
return (
<UpgradeNotice
isOwner={isOwner}
featureName='Cloud Credentials feature'
newPlan='enterprise'
/>
);
}
return (
<div>
<div className='flex justify-between items-end'>
<h2 className='font-bold text-lg bg-[--color-bg] z-10'>Service Provider Credential List</h2>
<MenuTrigger>
<Button
aria-label="Create in project"
className="flex items-center justify-center px-4 py-2 gap-2 h-full bg-[--hl-xxs] aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="plus-circle" /> Add Credential
</Button>
<Popover
className="min-w-max"
placement='bottom right'
>
<Menu
aria-label="Create cloud service credential actions"
selectionMode="single"
onAction={key => handleCreateCloudServiceCredential(key as CloudProviderName)}
items={createCredentialItemList}
className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none"
>
{item => (
<MenuItem
key={item.id}
id={item.id}
className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xxs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors"
aria-label={item.name}
>
{item.icon}
<span>{item.name}</span>
</MenuItem>
)}
</Menu>
</Popover>
</MenuTrigger>
</div>
{cloudCredentials.length === 0 ?
<div className="text-center faint italic pad">No cloud servicie provider credentials found</div> :
<table className="table--fancy table--striped table--valign-middle margin-top margin-bottom">
<thead>
<tr>
<th className='normal-case'>Name</th>
<th className='normal-case'>Service Provider</th>
<th className='normal-case'>Action</th>
</tr>
</thead>
<tbody>
{cloudCredentials.map(cloudCred => {
const { _id, name, provider } = cloudCred;
const credentialItem = createCredentialItemList.find(item => item.id === provider);
return (
<tr key={_id}>
<td >
{name}
</td>
<td className='w-36'>
{credentialItem && (
<div className='flex items-center gap-2'>
{credentialItem.icon}
<span>{credentialItem.name}</span>
</div>
)}
</td>
<td className='w-52 whitespace-nowrap'>
<div className='flex gap-2'>
<Button
className={`${buttonClassName} w-16`}
onPress={() => setModalState({ show: true, provider: provider!, credential: cloudCred })}
>
<Icon icon="edit" />&nbsp;&nbsp;Edit
</Button>
<Button
className={`${buttonClassName} w-20`}
onPress={() => handleDeleteItem(_id, name)}
>
<Icon icon="trash" />&nbsp;&nbsp;Delete
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
}
{modalState && modalState.show &&
<CloudCredentialModal
provider={modalState.provider}
providerCredential={modalState.credential}
onClose={hideModal}
/>
}
</div>
);
};

View File

@@ -2,6 +2,7 @@ import React, { type NamedExoticComponent, type SVGProps } from 'react';
import type { ValueOf } from 'type-fest';
import { SvgIcnArrowRight } from './assets/svgr/IcnArrowRight';
import { SvgIcnAzureLogo } from './assets/svgr/IcnAzureLogo';
import { SvgIcnBitbucketLogo } from './assets/svgr/IcnBitbucketLogo';
import { SvgIcnBrackets } from './assets/svgr/IcnBrackets';
import { SvgIcnBug } from './assets/svgr/IcnBug';
@@ -23,6 +24,7 @@ import { SvgIcnErrors } from './assets/svgr/IcnErrors';
import { SvgIcnFile } from './assets/svgr/IcnFile';
import { SvgIcnFolder } from './assets/svgr/IcnFolder';
import { SvgIcnFolderOpen } from './assets/svgr/IcnFolderOpen';
import { SvgIcnGCPLogo } from './assets/svgr/IcnGCPLogo';
import { SvgIcnGear } from './assets/svgr/IcnGear';
import { SvgIcnGitBranch } from './assets/svgr/IcnGitBranch';
import { SvgIcnGithubLogo } from './assets/svgr/IcnGithubLogo';
@@ -131,6 +133,8 @@ export const IconEnum = {
receive: 'receive',
sent: 'sent',
systemEvent: 'system-event',
gcpLogo: 'gcp-logo',
azureLogo: 'azure-logo',
/** Blank icon */
empty: 'empty',
} as const;
@@ -193,6 +197,8 @@ const icons: Record<IconId, [ThemeKeys, NamedExoticComponent<SVGProps<SVGSVGElem
[IconEnum.sent]: [ThemeEnum.default, SvgIcnSent],
[IconEnum.checkmarkCircle]: [ThemeEnum.default, SvgIcnCheckmarkCircle],
[IconEnum.systemEvent]: [ThemeEnum.default, SvgIcnSystemEvent],
[IconEnum.gcpLogo]: [ThemeEnum.default, SvgIcnGCPLogo],
[IconEnum.azureLogo]: [ThemeEnum.default, SvgIcnAzureLogo],
};
export type IconId = ValueOf<typeof IconEnum>;

View File

@@ -205,6 +205,32 @@ async function renderApp() {
action: async (...args) =>
(await import('./routes/actions')).updateSettingsAction(...args),
},
{
path: 'cloud-credential',
children: [
{
path: 'new',
action: async (...args) =>
(
await import('./routes/actions')
).createCloudCredentialAction(...args),
},
{
path: ':cloudCredentialId/update',
action: async (...args) =>
(
await import('./routes/actions')
).updateCloudCredentialAction(...args),
},
{
path: ':cloudCredentialId/delete',
action: async (...args) =>
(
await import('./routes/actions')
).deleteCloudCredentialAction(...args),
},
],
},
{
path: 'untracked-projects',
loader: async (...args) => (await import('./routes/untracked-projects')).loader(...args),

View File

@@ -1547,3 +1547,67 @@ export const toggleExpandAllRequestGroupsAction: ActionFunction = async ({ param
}));
return null;
};
export const createCloudCredentialAction: ActionFunction = async ({ request }) => {
const patch = await request.json();
const { name, provider, credentials, isAuthenticated } = patch;
invariant(typeof name === 'string', 'Name is required');
invariant(provider, 'Cloud Provier name is required');
if (name && provider && credentials) {
if (isAuthenticated) {
// find credential with same name for oauth authenticated cloud service
const existingCredential = await models.cloudCrendential.getByName(name, provider);
if (existingCredential.length === 0) {
await models.cloudCrendential.create(patch);
} else {
await models.cloudCrendential.update(existingCredential[0], patch);
}
return credentials;
} else {
const authenciateResponse = await window.main.cloudService.authenticate({ provider, credentials });
const { success, error, result } = authenciateResponse!;
if (success) {
await models.cloudCrendential.create(patch);
} else {
return {
error: error?.errorMessage,
};
}
return result;
}
}
return { error: 'Invalid paramters for creating cloud credential' };
};
export const updateCloudCredentialAction: ActionFunction = async ({ request, params }) => {
const { cloudCredentialId } = params;
invariant(typeof cloudCredentialId === 'string', 'Credential ID is required');
const patch = await request.json();
const { name, provider, credentials } = patch;
invariant(typeof name === 'string', 'Name is required');
invariant(provider, 'Cloud Provier name is required');
if (name && provider && credentials) {
const authenciateResponse = await window.main.cloudService.authenticate({ provider, credentials });
const { success, error, result } = authenciateResponse!;
if (success) {
const originCredential = await models.cloudCrendential.getById(cloudCredentialId);
invariant(originCredential, 'No Cloud Credential found');
await models.cloudCrendential.update(originCredential, patch);
} else {
return {
error: error?.errorMessage,
};
}
return result;
}
return { error: 'Invalid paramters for updating cloud credential' };
};
export const deleteCloudCredentialAction: ActionFunction = async ({ params }) => {
const { cloudCredentialId } = params;
invariant(typeof cloudCredentialId === 'string', 'Cloud Credential ID is required');
const cloudCredential = await models.cloudCrendential.getById(cloudCredentialId);
invariant(cloudCredential, 'Cloud Credential not found');
await models.cloudCrendential.remove(cloudCredential);
return null;
};

View File

@@ -6,6 +6,7 @@ import { type LoaderFunction, Outlet, useFetcher, useNavigate, useParams, useRou
import { isDevelopment } from '../../common/constants';
import * as models from '../../models';
import type { CloudProviderCredential } from '../../models/cloud-credential';
import type { Settings } from '../../models/settings';
import type { UserSession } from '../../models/user-session';
import { reloadPlugins } from '../../plugins';
@@ -31,6 +32,7 @@ export interface RootLoaderData {
settings: Settings;
workspaceCount: number;
userSession: UserSession;
cloudCredentials: CloudProviderCredential[];
}
export const useRootLoaderData = () => {
@@ -41,11 +43,13 @@ export const loader: LoaderFunction = async (): Promise<RootLoaderData> => {
const settings = await models.settings.get();
const workspaceCount = await models.workspace.count();
const userSession = await models.userSession.getOrCreate();
const cloudCredentials = await models.cloudCrendential.all();
return {
settings,
workspaceCount,
userSession,
cloudCredentials,
};
};