diff --git a/packages/pieces/community/ai/package.json b/packages/pieces/community/ai/package.json index c168fb67ce9..1d975447b53 100644 --- a/packages/pieces/community/ai/package.json +++ b/packages/pieces/community/ai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-ai", - "version": "0.0.9", + "version": "0.1.0", "type": "commonjs", "main": "./src/index.js", "types": "./src/index.d.ts", diff --git a/packages/pieces/community/amazon-s3/package.json b/packages/pieces/community/amazon-s3/package.json index b20e57b98c1..ef13844597b 100644 --- a/packages/pieces/community/amazon-s3/package.json +++ b/packages/pieces/community/amazon-s3/package.json @@ -1,4 +1,8 @@ { "name": "@activepieces/piece-amazon-s3", - "version": "0.4.0" + "version": "0.5.0", + "dependencies": { + "@aws-sdk/client-secrets-manager": "^3.0.0", + "openpgp": "^6.3.0" + } } diff --git a/packages/pieces/community/amazon-s3/src/index.ts b/packages/pieces/community/amazon-s3/src/index.ts index ba3bbf13d75..62a8a6b5322 100644 --- a/packages/pieces/community/amazon-s3/src/index.ts +++ b/packages/pieces/community/amazon-s3/src/index.ts @@ -12,6 +12,7 @@ import { generateSignedUrl } from './lib/actions/generate-signed-url'; import { moveFile } from './lib/actions/move-file'; import { deleteFile } from './lib/actions/delete-file'; import { listFiles } from './lib/actions/list-files'; +import { decryptPgpFile } from './lib/actions/decrypt-pgp-file'; const description = ` This piece allows you to upload files to Amazon S3 or other S3 compatible services. @@ -198,6 +199,6 @@ export const amazonS3 = createPiece({ authors: ["Willianwg","kishanprmr","MoShizzle","AbdulTheActivePiecer","khaledmashaly","abuaboud", "Kevinyu-alan"], categories: [PieceCategory.DEVELOPER_TOOLS], auth: amazonS3Auth, - actions: [amazons3UploadFile, readFile, generateSignedUrl, moveFile, deleteFile, listFiles], + actions: [amazons3UploadFile, readFile, generateSignedUrl, moveFile, deleteFile, listFiles, decryptPgpFile], triggers: [newFile], }); diff --git a/packages/pieces/community/amazon-s3/src/lib/actions/decrypt-pgp-file.ts b/packages/pieces/community/amazon-s3/src/lib/actions/decrypt-pgp-file.ts new file mode 100644 index 00000000000..3c6c82759a1 --- /dev/null +++ b/packages/pieces/community/amazon-s3/src/lib/actions/decrypt-pgp-file.ts @@ -0,0 +1,207 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; +import * as openpgp from 'openpgp'; +import { amazonS3Auth } from '../..'; +import { createS3, createSecretsManagerClient } from '../common'; + +export const decryptPgpFile = createAction({ + auth: amazonS3Auth, + name: 'decrypt-pgp-file', + displayName: 'Decrypt PGP File', + description: 'Decrypt a PGP encrypted file from S3 using a private key stored in AWS Secrets Manager', + props: { + key: Property.ShortText({ + displayName: 'S3 File Key', + description: 'The key (path) of the encrypted file in S3', + required: true, + }), + secretArn: Property.ShortText({ + displayName: 'Secret ARN', + description: 'The ARN of the secret in AWS Secrets Manager containing the PGP private key', + required: true, + }), + passphraseArn: Property.ShortText({ + displayName: 'Passphrase ARN', + description: 'Optional ARN of the secret in AWS Secrets Manager containing the passphrase for the private key (if the key is encrypted)', + required: false, + }), + secretsManagerRegion: Property.ShortText({ + displayName: 'Secrets Manager Region', + description: 'The AWS region where the Secrets Manager secret is stored (defaults to S3 region if not provided)', + required: false, + }), + }, + async run(context) { + const { bucket } = context.auth.props; + const { key, secretArn, passphraseArn, secretsManagerRegion } = context.propsValue; + const { accessKeyId, secretAccessKey, region } = context.auth.props; + + // Create S3 client + const s3 = createS3(context.auth.props); + + // Download the encrypted file from S3 + let encryptedData: Buffer; + try { + const file = await s3.getObject({ + Bucket: bucket, + Key: key, + }); + + const base64 = await file.Body?.transformToString('base64'); + if (!base64) { + throw new Error(`Could not read file ${key} from S3`); + } + encryptedData = Buffer.from(base64, 'base64'); + } catch (error: any) { + throw new Error(`Failed to download file from S3: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + // Create AWS Secrets Manager client + const secretsClient = createSecretsManagerClient({ + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + region: secretsManagerRegion || region, + }); + + // Fetch the secret key from AWS Secrets Manager + let privateKeyArmored: string; + try { + const command = new GetSecretValueCommand({ + SecretId: secretArn, + }); + const response = await secretsClient.send(command); + + if (!response.SecretString) { + throw new Error(`Secret ${secretArn} does not contain a string value`); + } + + // Trim whitespace from the key (AWS Secrets Manager might add extra whitespace) + privateKeyArmored = response.SecretString.trim(); + } catch (error: any) { + // Provide more specific error messages for common issues + if (error.name === 'AccessDeniedException') { + throw new Error( + `Access denied when retrieving secret ${secretArn}. ` + + `Please ensure your AWS credentials have the secretsmanager:GetSecretValue permission for this secret.` + ); + } + if (error.name === 'ResourceNotFoundException') { + throw new Error( + `Secret ${secretArn} not found. Please verify the secret ARN is correct and exists in region ${secretsManagerRegion || region}.` + ); + } + if (error.name === 'InvalidParameterException') { + throw new Error( + `Invalid secret ARN: ${secretArn}. Please verify the ARN format is correct.` + ); + } + throw new Error( + `Failed to retrieve secret from AWS Secrets Manager: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + // Fetch the passphrase from AWS Secrets Manager if provided + let passphrase: string | undefined; + if (passphraseArn) { + try { + const passphraseCommand = new GetSecretValueCommand({ + SecretId: passphraseArn, + }); + const passphraseResponse = await secretsClient.send(passphraseCommand); + + if (!passphraseResponse.SecretString) { + throw new Error(`Secret ${passphraseArn} does not contain a string value`); + } + + passphrase = passphraseResponse.SecretString.trim(); + } catch (error: any) { + // Provide more specific error messages for common issues + if (error.name === 'AccessDeniedException') { + throw new Error( + `Access denied when retrieving passphrase secret ${passphraseArn}. ` + + `Please ensure your AWS credentials have the secretsmanager:GetSecretValue permission for this secret.` + ); + } + if (error.name === 'ResourceNotFoundException') { + throw new Error( + `Passphrase secret ${passphraseArn} not found. Please verify the secret ARN is correct and exists in region ${secretsManagerRegion || region}.` + ); + } + if (error.name === 'InvalidParameterException') { + throw new Error( + `Invalid passphrase secret ARN: ${passphraseArn}. Please verify the ARN format is correct.` + ); + } + throw new Error( + `Failed to retrieve passphrase from AWS Secrets Manager: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + // Read the encrypted file data + const encryptedString = encryptedData.toString('utf8'); + + // Decrypt the file using OpenPGP + try { + let message; + try { + message = await openpgp.readMessage({ + armoredMessage: encryptedString, + }); + } catch { + // If armored fails, try as binary + message = await openpgp.readMessage({ + binaryMessage: encryptedData, + }); + } + + // Read the private key + let privateKey = await openpgp.readPrivateKey({ + armoredKey: privateKeyArmored, + }); + + if (!privateKey.isPrivate()) { + throw new Error('The provided key is not a private key. Please ensure you are using a private key, not a public key.'); + } + + // Check if the key is encrypted and needs a passphrase + if (!privateKey.isDecrypted()) { + if (!passphrase) { + throw new Error('Private key is encrypted but no passphrase was provided. Please provide a Passphrase ARN.'); + } + try { + const decryptedKey = await openpgp.decryptKey({ + privateKey: privateKey, + passphrase: passphrase, + }); + privateKey = decryptedKey; + } catch (error) { + throw new Error(`Failed to unlock private key with passphrase: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Decrypt the message + const decrypted = await openpgp.decrypt({ + message, + decryptionKeys: privateKey, + format: 'binary', + config: { + allowInsecureDecryptionWithSigningKeys: false, + }, + }); + + // Get the decrypted data + const decryptedData = new Uint8Array(await decrypted.data); + + // Write the decrypted file + const fileName = key.split("/").at(-1)?.toLowerCase().replace(/\.(pgp|gpg)$/i, ''); + + return await context.files.write({ + fileName: fileName ?? 'decrypted_file', + data: Buffer.from(decryptedData), + }); + } catch (error) { + throw new Error(`Failed to decrypt file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }, +}); diff --git a/packages/pieces/community/amazon-s3/src/lib/common.ts b/packages/pieces/community/amazon-s3/src/lib/common.ts index d0a6d43f03d..d84629f94ea 100644 --- a/packages/pieces/community/amazon-s3/src/lib/common.ts +++ b/packages/pieces/community/amazon-s3/src/lib/common.ts @@ -1,5 +1,6 @@ import { isNil } from '@activepieces/shared'; import { S3 } from '@aws-sdk/client-s3'; +import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; export function createS3(auth: { accessKeyId: string; @@ -19,3 +20,19 @@ export function createS3(auth: { }); return s3; } + +export function createSecretsManagerClient(auth: { + accessKeyId: string; + secretAccessKey: string; + region: string | undefined; +}) { + const client = new SecretsManagerClient({ + credentials: { + accessKeyId: auth.accessKeyId, + secretAccessKey: auth.secretAccessKey, + }, + region: auth.region, + endpoint: undefined, + }); + return client; +} diff --git a/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx b/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx index 9658aab6c29..7576718a6ec 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx @@ -8,9 +8,11 @@ import { rectIntersection, useSensor, useSensors, + PointerSensorOptions, } from '@dnd-kit/core'; import { ReactFlowInstance, useReactFlow } from '@xyflow/react'; import { t } from 'i18next'; +import type { PointerEvent } from 'react'; import { useCallback, useRef } from 'react'; import { toast } from 'sonner'; @@ -110,7 +112,7 @@ const FlowDragLayer = ({ children }: { children: React.ReactNode }) => { }; const sensors = useSensors( - useSensor(PointerSensor, { + useSensor(PointerSensorIgnoringInteractiveItems, { activationConstraint: { distance: 10, }, @@ -214,3 +216,50 @@ function handleNoteDragEnd({ } } } + +class PointerSensorIgnoringInteractiveItems extends PointerSensor { + static activators = [ + { + eventName: 'onPointerDown' as const, + handler: ( + { nativeEvent: event }: PointerEvent, + { onActivation }: PointerSensorOptions, + ) => { + const target = event.target as HTMLElement; + if (target?.closest('[contenteditable="true"]')) { + return false; + } + + if ( + !event.isPrimary || + event.button !== 0 || + isInteractiveElement(event.target as Element) + ) { + return false; + } + + onActivation?.({ event }); + return true; + }, + }, + ]; +} + +function isInteractiveElement(element: Element | null): boolean { + const interactiveElements = [ + 'button', + 'input', + 'textarea', + 'select', + 'option', + ]; + + if ( + element?.tagName && + interactiveElements.includes(element.tagName.toLowerCase()) + ) { + return true; + } + + return false; +} diff --git a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/index.tsx b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/index.tsx index eef3bb8a37f..c30015acc00 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/index.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/index.tsx @@ -31,13 +31,31 @@ const ApNoteCanvasNode = (props: NodeProps & Omit) => { type: flowCanvasConsts.DRAGGED_NOTE_TAG, }, }); - + //because react flow only detects nowheel class, it doesn't work with focus-within:nowheel + const [isFocusWithin, setIsFocusWithin] = useState(false); const [size, setSize] = useState(props.data.size); if (draggedNote?.id === props.id || note === null) { return null; } return ( -
+
setIsFocusWithin(true)} + onBlur={() => setIsFocusWithin(false)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setIsFocusWithin(false); + if ( + document.activeElement instanceof HTMLElement && + document.activeElement.closest('.note-node') + ) { + document.activeElement.blur(); + } + } + }} + > { }} > {!isDragging && !readonly && editorRef.current && ( -
+
e.stopPropagation()} + > { NoteColorVariantClassName[color], )} onChange={(value: string) => { - setLocalNote({ ...localNote, content: value }); - debouncedUpdateContent(id, value); + if (value !== localNote.content) { + setLocalNote({ ...localNote, content: value }); + debouncedUpdateContent(id, value); + } }} />
diff --git a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-tools.tsx b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-tools.tsx index a84bcaf715d..6298e57eaf1 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-tools.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-tools.tsx @@ -1,6 +1,6 @@ import { Editor } from '@tiptap/core'; import { TrashIcon } from 'lucide-react'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { useBuilderStateContext } from '@/app/builder/builder-hooks'; import { Button } from '@/components/ui/button'; @@ -21,7 +21,10 @@ export const NoteTools = ({ editor, currentColor, id }: NoteToolsProps) => { state.deleteNote, ]); return ( -
+
{ + const [open, setOpen] = useState(false); + const popoverTriggerRef = useRef(null); return ( - +
@@ -84,9 +91,11 @@ const NoteColorPicker = ({
{ setCurrentColor(color); + setOpen(false); + popoverTriggerRef.current?.focus(); }} >
, ) => { + const [showTextCursor, setShowTextCursor] = useState( + !onlyEditableOnDoubleClick && !disabled, + ); const editor = useEditor({ extensions: [ Document, @@ -89,7 +92,11 @@ export const MarkdownInput = React.forwardRef< spellcheck: 'false', }, }, + onFocus: () => { + setShowTextCursor(true); + }, onBlur: () => { + setShowTextCursor(false); window.getSelection()?.removeAllRanges(); if (onlyEditableOnDoubleClick) { editor.setEditable(false, false); @@ -101,26 +108,20 @@ export const MarkdownInput = React.forwardRef< }); useImperativeHandle(ref, () => editor, [editor]); - // Stop all events from bubbling to prevent dnd-kit/React Flow interference with selection - const stopEventPropagation = (e: React.SyntheticEvent) => { - if (disabled || !editor.isEditable) return; - e.stopPropagation(); - }; - return ( - //gotta add this nodrag nopan to prevent dnd-kit and React Flow interference with selection
{ + if (e.key === 'Escape') { + editor.commands.blur(); + } + }} onDoubleClick={() => { if (onlyEditableOnDoubleClick && !disabled) { - editor.setEditable(true, false); + editor.setEditable(true); } }} > @@ -137,7 +138,7 @@ export const MarkdownInput = React.forwardRef<
)} diff --git a/packages/react-ui/src/features/agents/ai-model/hooks.ts b/packages/react-ui/src/features/agents/ai-model/hooks.ts index 5ec8ce67b95..7bbf1786e99 100644 --- a/packages/react-ui/src/features/agents/ai-model/hooks.ts +++ b/packages/react-ui/src/features/agents/ai-model/hooks.ts @@ -1,7 +1,8 @@ +import { Provider } from '@radix-ui/react-tooltip'; import { useQuery } from '@tanstack/react-query'; import { aiProviderApi } from '@/features/platform-admin/lib/ai-provider-api'; -import { AIProviderModel, isNil } from '@activepieces/shared'; +import { AIProviderModel, AIProviderName, isNil } from '@activepieces/shared'; type Provider = | 'activepieces' @@ -35,7 +36,13 @@ const ALLOWED_MODELS_BY_PROVIDER: Partial> = openai: OPENAI_MODELS, anthropic: ANTHROPIC_MODELS, google: GOOGLE_MODELS, - activepieces: [...OPENAI_MODELS, ...ANTHROPIC_MODELS, ...GOOGLE_MODELS], + activepieces: [ + ...OPENAI_MODELS.map((model) => `${AIProviderName.OPENAI}/${model}`), + ...ANTHROPIC_MODELS.map( + (model) => `${AIProviderName.ANTHROPIC}/${model}`, + ), + ...GOOGLE_MODELS.map((model) => `${AIProviderName.GOOGLE}/${model}`), + ], }; function getAllowedModelsForProvider( diff --git a/packages/react-ui/src/styles.css b/packages/react-ui/src/styles.css index 9b213fba811..037b6a4d5b4 100644 --- a/packages/react-ui/src/styles.css +++ b/packages/react-ui/src/styles.css @@ -519,7 +519,7 @@ } -.note-node:hover { +.note-node:focus-within:hover { ::-webkit-scrollbar-thumb { background: hsla(0, 1%, 66%, 0.328); border-radius: 100px;