Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,15 @@
export class ApStorage {
private static instance: Storage;
private constructor(value: Storage) {
ApStorage.instance = value;
}
static getInstance() {
if (!ApStorage.instance) {
ApStorage.instance = window.localStorage;
}
return ApStorage.instance;
}
static setInstanceToSessionStorage() {
ApStorage.instance = window.sessionStorage;
}
}

View File

@@ -0,0 +1,171 @@
import axios, {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
HttpStatusCode,
isAxiosError,
} from 'axios';
import qs from 'qs';
import { authenticationSession } from '@/lib/authentication-session';
import { ApErrorParams, ErrorCode, isNil } from '@activepieces/shared';
export const API_BASE_URL =
import.meta.env.MODE === 'cloud'
? 'https://cloud.activepieces.com'
: window.location.origin;
export const API_URL = `${API_BASE_URL}/api`;
const disallowedRoutes = [
'/v1/managed-authn/external-token',
'/v1/authentication/sign-in',
'/v1/authentication/sign-up',
'/v1/authn/local/verify-email',
'/v1/authn/federated/login',
'/v1/authn/federated/claim',
'/v1/otp',
'/v1/human-input',
'/v1/authn/local/reset-password',
'/v1/user-invitations/accept',
'/v1/webhooks',
];
//This is important to avoid redirecting to sign-in page when the user is deleted for embedding scenarios
const ignroedGlobalErrorHandlerRoutes = ['/v1/users/me'];
function isUrlRelative(url: string) {
return !url.startsWith('http') && !url.startsWith('https');
}
function globalErrorHandler(error: AxiosError) {
if (api.isError(error)) {
const errorCode: ErrorCode | undefined = (
error.response?.data as { code: ErrorCode }
)?.code;
if (
errorCode === ErrorCode.SESSION_EXPIRED ||
errorCode === ErrorCode.INVALID_BEARER_TOKEN
) {
authenticationSession.logOut();
console.log(errorCode);
window.location.href = '/sign-in';
}
}
}
function request<TResponse>(
url: string,
config: AxiosRequestConfig = {},
): Promise<TResponse> {
const resolvedUrl = !isUrlRelative(url) ? url : `${API_URL}${url}`;
const isApWebsite = resolvedUrl.startsWith(API_URL);
const unAuthenticated = disallowedRoutes.some((route) =>
resolvedUrl.replace(API_URL, '').startsWith(route),
);
return axios({
url: resolvedUrl,
...config,
headers: {
...config.headers,
Authorization: getToken(
unAuthenticated,
isApWebsite,
authenticationSession.getToken(),
),
},
})
.then((response) =>
config.responseType === 'blob'
? response.data
: (response.data as TResponse),
)
.catch((error) => {
if (
isAxiosError(error) &&
!ignroedGlobalErrorHandlerRoutes.includes(url)
) {
globalErrorHandler(error);
}
throw error;
});
}
function getToken(
unAuthenticated: boolean,
isApWebsite: boolean,
token: string | null,
) {
if (unAuthenticated || !isApWebsite) {
return undefined;
}
if (isNil(token)) {
return undefined;
}
return `Bearer ${token}`;
}
export type HttpError = AxiosError<unknown, AxiosResponse<unknown>>;
export const api = {
isApError(error: unknown, errorCode: ErrorCode): error is HttpError {
if (!isAxiosError(error)) {
return false;
}
const responseData = error.response?.data as ApErrorParams;
return responseData.code === errorCode;
},
isError(error: unknown): error is HttpError {
return isAxiosError(error);
},
any: <TResponse>(url: string, config?: AxiosRequestConfig) =>
request<TResponse>(url, config),
get: <TResponse>(url: string, query?: unknown, config?: AxiosRequestConfig) =>
request<TResponse>(url, {
params: query,
paramsSerializer: (params) => {
return qs.stringify(params, {
arrayFormat: 'repeat',
});
},
...config,
}),
delete: <TResponse>(
url: string,
query?: Record<string, string>,
body?: unknown,
) =>
request<TResponse>(url, {
method: 'DELETE',
params: query,
data: body,
paramsSerializer: (params) => {
return qs.stringify(params, {
arrayFormat: 'repeat',
});
},
}),
post: <TResponse, TBody = unknown, TParams = unknown>(
url: string,
body?: TBody,
params?: TParams,
headers?: Record<string, string>,
) =>
request<TResponse>(url, {
method: 'POST',
data: body,
headers: { 'Content-Type': 'application/json', ...headers },
params: params,
}),
patch: <TResponse, TBody = unknown, TParams = unknown>(
url: string,
body?: TBody,
params?: TParams,
) =>
request<TResponse>(url, {
method: 'PATCH',
data: body,
headers: { 'Content-Type': 'application/json' },
params: params,
}),
httpStatus: HttpStatusCode,
};

View File

@@ -0,0 +1,68 @@
import { api } from '@/lib/api';
import {
CreateOtpRequestBody,
ResetPasswordRequestBody,
VerifyEmailRequestBody,
} from '@activepieces/ee-shared';
import {
AuthenticationResponse,
ClaimTokenRequest,
FederatedAuthnLoginResponse,
ProjectRole,
SignInRequest,
SignUpRequest,
SwitchPlatformRequest,
SwitchProjectRequest,
ThirdPartyAuthnProviderEnum,
UserIdentity,
} from '@activepieces/shared';
export const authenticationApi = {
signIn(request: SignInRequest) {
return api.post<AuthenticationResponse>(
'/v1/authentication/sign-in',
request,
);
},
signUp(request: SignUpRequest) {
return api.post<AuthenticationResponse>(
'/v1/authentication/sign-up',
request,
);
},
getFederatedAuthLoginUrl(providerName: ThirdPartyAuthnProviderEnum) {
return api.get<FederatedAuthnLoginResponse>(`/v1/authn/federated/login`, {
providerName,
});
},
getCurrentProjectRole() {
return api.get<ProjectRole | null>('/v1/project-members/role');
},
claimThirdPartyRequest(request: ClaimTokenRequest) {
return api.post<AuthenticationResponse>(
'/v1/authn/federated/claim',
request,
);
},
sendOtpEmail(request: CreateOtpRequestBody) {
return api.post<void>('/v1/otp', request);
},
resetPassword(request: ResetPasswordRequestBody) {
return api.post<void>('/v1/authn/local/reset-password', request);
},
verifyEmail(request: VerifyEmailRequestBody) {
return api.post<UserIdentity>('/v1/authn/local/verify-email', request);
},
switchProject(request: SwitchProjectRequest) {
return api.post<AuthenticationResponse>(
`/v1/authentication/switch-project`,
request,
);
},
switchPlatform(request: SwitchPlatformRequest) {
return api.post<AuthenticationResponse>(
`/v1/authentication/switch-platform`,
request,
);
},
};

View File

@@ -0,0 +1,107 @@
import dayjs from 'dayjs';
import { jwtDecode } from 'jwt-decode';
import {
AuthenticationResponse,
isNil,
UserPrincipal,
} from '@activepieces/shared';
import { ApStorage } from './ap-browser-storage';
import { authenticationApi } from './authentication-api';
const tokenKey = 'token';
export const authenticationSession = {
saveResponse(response: AuthenticationResponse, isEmbedding: boolean) {
if (isEmbedding) {
ApStorage.setInstanceToSessionStorage();
}
ApStorage.getInstance().setItem(tokenKey, response.token);
window.dispatchEvent(new Event('storage'));
},
isJwtExpired(token: string): boolean {
if (!token) {
return true;
}
try {
const decoded = jwtDecode(token);
if (decoded && decoded.exp && dayjs().isAfter(dayjs.unix(decoded.exp))) {
return true;
}
return false;
} catch (e) {
return true;
}
},
getToken(): string | null {
return ApStorage.getInstance().getItem(tokenKey) ?? null;
},
getProjectId(): string | null {
const token = this.getToken();
if (isNil(token)) {
return null;
}
const decodedJwt = getDecodedJwt(token);
return decodedJwt.projectId;
},
getCurrentUserId(): string | null {
const token = this.getToken();
if (isNil(token)) {
return null;
}
const decodedJwt = getDecodedJwt(token);
return decodedJwt.id;
},
appendProjectRoutePrefix(path: string): string {
const projectId = this.getProjectId();
if (isNil(projectId)) {
return path;
}
return `/projects/${projectId}${path.startsWith('/') ? path : `/${path}`}`;
},
getPlatformId(): string | null {
const token = this.getToken();
if (isNil(token)) {
return null;
}
const decodedJwt = getDecodedJwt(token);
return decodedJwt.platform.id;
},
async switchToPlatform(platformId: string) {
if (authenticationSession.getPlatformId() === platformId) {
return;
}
const result = await authenticationApi.switchPlatform({
platformId,
});
ApStorage.getInstance().setItem(tokenKey, result.token);
window.location.href = '/';
},
async switchToProject(projectId: string) {
if (authenticationSession.getProjectId() === projectId) {
return;
}
const result = await authenticationApi.switchProject({ projectId });
ApStorage.getInstance().setItem(tokenKey, result.token);
window.dispatchEvent(new Event('storage'));
},
isLoggedIn(): boolean {
const token = this.getToken();
if (isNil(token)) {
return false;
}
return !this.isJwtExpired(token);
},
clearSession() {
ApStorage.getInstance().removeItem(tokenKey);
},
logOut() {
this.clearSession();
window.location.href = '/sign-in';
},
};
function getDecodedJwt(token: string): UserPrincipal {
return jwtDecode<UserPrincipal>(token);
}

View File

@@ -0,0 +1,71 @@
const hexToHslString = (hex: string) => {
const { hue, saturation, lightness } = parseToHsl(hex);
return `${hue.toFixed(1)} ${(saturation * 100).toFixed(1)}% ${(
lightness * 100
).toFixed(1)}%`;
};
const parseToHsl = (hex: string) => {
// Remove the '#' character if it exists
hex = hex.replace(/^#/, '');
// Convert 3-digit hex to 6-digit hex
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char + char)
.join('');
}
// Convert hex to RGB
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
// Find the maximum and minimum values to get lightness
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
// Calculate lightness
const lightness = (max + min) / 2;
let hue = 0;
let saturation = 0;
if (max !== min) {
const delta = max - min;
// Calculate saturation
saturation =
lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
// Calculate hue
switch (max) {
case r:
hue = (g - b) / delta + (g < b ? 6 : 0);
break;
case g:
hue = (b - r) / delta + 2;
break;
case b:
hue = (r - g) / delta + 4;
break;
}
hue /= 6;
}
// Convert hue to degrees
hue = hue * 360;
return {
hue,
saturation,
lightness,
};
};
export const colorsUtils = {
hexToHslString,
parseToHsl,
};

View File

@@ -0,0 +1,36 @@
// @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx
import * as React from 'react';
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === 'function') {
ref(value);
} else if (ref !== null && ref !== undefined) {
(ref as React.MutableRefObject<T>).current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]) {
return (node: T) => refs.forEach((ref) => setRef(ref, node));
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };

View File

@@ -0,0 +1,8 @@
import { api } from './api';
export type FlagsMap = Record<string, boolean | string | object | undefined>;
export const flagsApi = {
getAll() {
return api.get<FlagsMap>(`/v1/flags`);
},
};

View File

@@ -0,0 +1,9 @@
import { GetSystemHealthChecksResponse } from '@activepieces/shared';
import { api } from './api';
export const healthApi = {
getSystemHealthChecks(): Promise<GetSystemHealthChecksResponse> {
return api.get<GetSystemHealthChecksResponse>('/v1/health/system');
},
};

View File

@@ -0,0 +1,18 @@
import { api } from '@/lib/api';
import { AuthenticationResponse } from '@activepieces/shared';
/**
* Django Trust Authentication API
*
* Uses the custom django-trust endpoint instead of EE managed-authn.
* This allows SmoothSchedule to embed Activepieces using Django-signed JWTs.
*/
export const managedAuthApi = {
generateApToken: async (request: { externalAccessToken: string }) => {
// Use our custom django-trust authentication endpoint
return api.post<AuthenticationResponse>(
`/v1/authentication/django-trust`,
{ token: request.externalAccessToken },
);
},
};

View File

@@ -0,0 +1,40 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useEmbedding } from '../components/embed-provider';
export const useNewWindow = () => {
const { embedState } = useEmbedding();
const navigate = useNavigate();
if (embedState.isEmbedded) {
return (route: string, searchParams?: string) =>
navigate({
pathname: route,
search: searchParams,
});
} else {
return (route: string, searchParams?: string) =>
window.open(
`${route}${searchParams ? '?' + searchParams : ''}`,
'_blank',
'noopener noreferrer',
);
}
};
export const FROM_QUERY_PARAM = 'from';
/**State param is for oauth2 flow, it is used to redirect to the page after login*/
export const STATE_QUERY_PARAM = 'state';
export const LOGIN_QUERY_PARAM = 'activepiecesLogin';
export const PROVIDER_NAME_QUERY_PARAM = 'providerName';
export const useDefaultRedirectPath = () => {
return '/flows';
};
export const useRedirectAfterLogin = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const defaultRedirectPath = useDefaultRedirectPath();
const from = searchParams.get(FROM_QUERY_PARAM) ?? defaultRedirectPath;
return () => navigate(from);
};

View File

@@ -0,0 +1,208 @@
import { nanoid } from 'nanoid';
import { useSearchParams } from 'react-router-dom';
import { OAuth2Property } from '@activepieces/pieces-framework';
import {
AppConnectionType,
BOTH_CLIENT_CREDENTIALS_AND_AUTHORIZATION_CODE,
isNil,
OAuth2GrantType,
ThirdPartyAuthnProviderEnum,
} from '@activepieces/shared';
import {
FROM_QUERY_PARAM,
LOGIN_QUERY_PARAM,
PROVIDER_NAME_QUERY_PARAM,
STATE_QUERY_PARAM,
} from './navigation-utils';
let currentPopup: Window | null = null;
function useThirdPartyLogin() {
const [searchParams] = useSearchParams();
return (loginUrl: string, providerName: ThirdPartyAuthnProviderEnum) => {
const from = searchParams.get(FROM_QUERY_PARAM) || '/flows';
const state = {
[PROVIDER_NAME_QUERY_PARAM]: providerName,
[FROM_QUERY_PARAM]: from,
[LOGIN_QUERY_PARAM]: 'true',
};
const loginUrlWithState = new URL(loginUrl);
loginUrlWithState.searchParams.set(
STATE_QUERY_PARAM,
JSON.stringify(state),
);
window.location.href = loginUrlWithState.toString();
};
}
async function openOAuth2Popup(
params: OAuth2PopupParams,
): Promise<OAuth2PopupResponse> {
closeOAuth2Popup();
const pckeChallenge = nanoid(43);
const url = await constructUrl(params, pckeChallenge);
currentPopup = openWindow(url);
return {
code: await getCode(params.redirectUrl),
codeChallenge: params.pkce ? pckeChallenge : undefined,
};
}
function openWindow(url: string): Window | null {
const winFeatures = [
'resizable=no',
'toolbar=no',
'left=100',
'top=100',
'scrollbars=no',
'menubar=no',
'status=no',
'directories=no',
'location=no',
'width=600',
'height=800',
].join(', ');
return window.open(url, '_blank', winFeatures);
}
function closeOAuth2Popup() {
currentPopup?.close();
}
async function generateCodeChallenge(codeVerifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
const base64String = btoa(String.fromCharCode(...new Uint8Array(digest)));
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function constructUrl(params: OAuth2PopupParams, pckeChallenge: string) {
const queryParams: Record<string, string> = {
response_type: 'code',
client_id: params.clientId,
redirect_uri: params.redirectUrl,
access_type: 'offline',
state: nanoid(),
prompt: 'consent',
scope: params.scope,
...(params.extraParams || {}),
};
if (params.prompt === 'omit') {
delete queryParams['prompt'];
} else if (!isNil(params.prompt)) {
queryParams['prompt'] = params.prompt;
}
if (params.pkce) {
const method = params.pkceMethod || 'plain';
queryParams['code_challenge_method'] = method;
if (method === 'S256') {
queryParams['code_challenge'] = await generateCodeChallenge(
pckeChallenge,
);
} else {
queryParams['code_challenge'] = pckeChallenge;
}
}
const url = new URL(params.authUrl);
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== '') {
url.searchParams.append(key, value);
}
});
return url.toString();
}
function getCode(redirectUrl: string): Promise<string> {
return new Promise<string>((resolve) => {
window.addEventListener('message', function handler(event) {
if (
redirectUrl &&
redirectUrl.startsWith(event.origin) &&
event.data['code']
) {
resolve(decodeURIComponent(event.data.code));
closeOAuth2Popup();
window.removeEventListener('message', handler);
}
});
});
}
type OAuth2PopupParams = {
authUrl: string;
clientId: string;
redirectUrl: string;
scope: string;
prompt?: 'none' | 'consent' | 'login' | 'omit';
pkce: boolean;
pkceMethod?: 'plain' | 'S256';
extraParams?: Record<string, string>;
};
type OAuth2PopupResponse = {
code: string;
codeChallenge: string | undefined;
};
function getGrantType(property: OAuth2Property<any>) {
if (
isNil(property.grantType) ||
property.grantType === BOTH_CLIENT_CREDENTIALS_AND_AUTHORIZATION_CODE
) {
return OAuth2GrantType.AUTHORIZATION_CODE;
}
return property.grantType;
}
function getPredefinedOAuth2App(
piecesOAuth2AppsMap: PiecesOAuth2AppsMap,
pieceName: string,
): OAuth2App | null {
const pieceOAuth2Apps = piecesOAuth2AppsMap[pieceName];
if (isNil(pieceOAuth2Apps)) {
return null;
}
if (pieceOAuth2Apps.platformOAuth2App) {
return pieceOAuth2Apps.platformOAuth2App;
}
if (pieceOAuth2Apps.cloudOAuth2App) {
return pieceOAuth2Apps.cloudOAuth2App;
}
return null;
}
export type OAuth2App =
| {
oauth2Type:
| AppConnectionType.CLOUD_OAUTH2
| AppConnectionType.PLATFORM_OAUTH2;
clientId: string;
}
| {
oauth2Type: AppConnectionType.OAUTH2;
clientId: null;
};
export type PiecesOAuth2AppsMap = Record<
string,
| {
cloudOAuth2App: OAuth2App | null;
platformOAuth2App: OAuth2App | null;
}
| undefined
>;
export const oauth2Utils = {
openOAuth2Popup,
useThirdPartyLogin,
getGrantType,
getPredefinedOAuth2App,
};

View File

@@ -0,0 +1,11 @@
import { api } from '@/lib/api';
import { ListProjectRequestForPlatformQueryParams } from '@activepieces/ee-shared';
import { ProjectWithLimits, SeekPage } from '@activepieces/shared';
export const platformProjectApi = {
list: async (
params: ListProjectRequestForPlatformQueryParams,
): Promise<SeekPage<ProjectWithLimits>> => {
return api.get<SeekPage<ProjectWithLimits>>(`/v1/projects`, params);
},
};

View File

@@ -0,0 +1,20 @@
import { api } from '@/lib/api';
import {
SeekPage,
UpdateUserRequestBody,
User,
UserWithMetaInformation,
ListUsersRequestBody,
} from '@activepieces/shared';
export const platformUserApi = {
list(request: ListUsersRequestBody) {
return api.get<SeekPage<UserWithMetaInformation>>('/v1/users', request);
},
delete(id: string) {
return api.delete(`/v1/users/${id}`);
},
update(id: string, request: UpdateUserRequestBody): Promise<User> {
return api.post<User>(`/v1/users/${id}`, request);
},
};

View File

@@ -0,0 +1,50 @@
import {
PlatformWithoutSensitiveData,
UpdatePlatformRequestBody,
} from '@activepieces/shared';
import { api } from './api';
import { authenticationSession } from './authentication-session';
export const platformApi = {
deleteAccount() {
return api.delete<void>(
`/v1/platforms/${authenticationSession.getPlatformId()}`,
);
},
getCurrentPlatform() {
const platformId = authenticationSession.getPlatformId();
if (!platformId) {
throw Error('No platform id found');
}
return api.get<PlatformWithoutSensitiveData>(`/v1/platforms/${platformId}`);
},
verifyLicenseKey(licenseKey: string) {
const platformId = authenticationSession.getPlatformId();
if (!platformId) {
throw Error('No platform id found');
}
return api.post<void>(`/v1/license-keys/verify`, {
platformId,
licenseKey,
});
},
update(req: UpdatePlatformRequestBody, platformId: string) {
return api.post<PlatformWithoutSensitiveData>(
`/v1/platforms/${platformId}`,
req,
);
},
updateWithFormData(formdata: FormData, platformId: string) {
return api.post<PlatformWithoutSensitiveData>(
`/v1/platforms/${platformId}`,
formdata,
{},
{
'Content-Type': 'multipart/form-data',
},
);
},
};

View File

@@ -0,0 +1,38 @@
import { api } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
import {
CreatePlatformProjectRequest,
UpdateProjectPlatformRequest,
} from '@activepieces/ee-shared';
import {
ListProjectRequestForUserQueryParams,
ProjectWithLimits,
ProjectWithLimitsWithPlatform,
SeekPage,
} from '@activepieces/shared';
export const projectApi = {
current: async () => {
return projectApi.get(authenticationSession.getProjectId()!);
},
list(request: ListProjectRequestForUserQueryParams) {
return api.get<SeekPage<ProjectWithLimits>>('/v1/users/projects', request);
},
get: async (projectId: string) => {
return api.get<ProjectWithLimits>(`/v1/users/projects/${projectId}`);
},
update: async (projectId: string, request: UpdateProjectPlatformRequest) => {
return api.post<ProjectWithLimits>(`/v1/projects/${projectId}`, request);
},
create: async (request: CreatePlatformProjectRequest) => {
return api.post<ProjectWithLimits>('/v1/projects', request);
},
delete: async (projectId: string) => {
return api.delete<void>(`/v1/projects/${projectId}`);
},
listForPlatforms: async () => {
return api.get<ProjectWithLimitsWithPlatform[]>(
'/v1/users/projects/platforms',
);
},
};

View File

@@ -0,0 +1,29 @@
import { Mutex } from 'async-mutex';
export class PromiseQueue {
private queue: (() => Promise<unknown>)[] = [];
private lock: Mutex = new Mutex();
private halted = false;
add(promise: () => Promise<unknown>) {
this.queue.push(promise);
this.run();
}
halt() {
this.halted = true;
}
size() {
return this.queue.length;
}
private run() {
this.lock.runExclusive(async () => {
const promise = this.queue.shift()!;
if (this.halted) {
return;
}
await promise();
});
}
}

View File

@@ -0,0 +1,28 @@
import { CreateTrialLicenseKeyRequestBody } from '@activepieces/shared';
import { api } from './api';
import { flagsApi } from './flags-api';
export const requestTrialApi = {
createKey(params: CreateTrialLicenseKeyRequestBody): Promise<void> {
return api.post<void>(`/v1/license-keys`, params);
},
async contactSales(params: ContactSalesRequest): Promise<void> {
const flags = await flagsApi.getAll();
return api.post<void>(
`https://sales.activepieces.com/submit-inapp-contact-form`,
{
...params,
flags: flags,
},
);
},
};
type ContactSalesRequest = {
fullName: string;
email: string;
companyName: string;
goal: string;
numberOfEmployees: string;
};

View File

@@ -0,0 +1,107 @@
import {
ActionBase,
ErrorHandlingOptionsParam,
PieceAuthProperty,
PieceMetadataModelSummary,
TriggerBase,
} from '@activepieces/pieces-framework';
import {
FlowActionType,
PackageType,
PieceType,
FlowTriggerType,
FlowOperationType,
StepLocationRelativeToParent,
} from '@activepieces/shared';
type BaseStepMetadata = {
displayName: string;
logoUrl: string;
description: string;
};
export type PieceStepMetadata = BaseStepMetadata & {
type: FlowActionType.PIECE | FlowTriggerType.PIECE;
pieceName: string;
pieceVersion: string;
categories: string[];
packageType: PackageType;
pieceType: PieceType;
auth: PieceAuthProperty | PieceAuthProperty[] | undefined;
errorHandlingOptions?: ErrorHandlingOptionsParam;
};
export type PrimitiveStepMetadata = BaseStepMetadata & {
type:
| FlowActionType.CODE
| FlowActionType.LOOP_ON_ITEMS
| FlowActionType.ROUTER
| FlowTriggerType.EMPTY;
};
export type PieceStepMetadataWithSuggestions = PieceStepMetadata &
Pick<PieceMetadataModelSummary, 'suggestedActions' | 'suggestedTriggers'>;
export type StepMetadataWithSuggestions =
| PieceStepMetadataWithSuggestions
| PrimitiveStepMetadata;
export type CategorizedStepMetadataWithSuggestions = {
title: string;
metadata: StepMetadataWithSuggestions[];
};
export type StepMetadata = PieceStepMetadata | PrimitiveStepMetadata;
export type StepMetadataWithActionOrTriggerOrAgentDisplayName = StepMetadata & {
actionOrTriggerOrAgentDisplayName: string;
actionOrTriggerOrAgentDescription: string;
};
export type PieceSelectorOperation =
| {
type: FlowOperationType.ADD_ACTION;
actionLocation: {
branchIndex: number;
parentStep: string;
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_BRANCH;
};
}
| {
type: FlowOperationType.ADD_ACTION;
actionLocation: {
parentStep: string;
stepLocationRelativeToParent: Exclude<
StepLocationRelativeToParent,
StepLocationRelativeToParent.INSIDE_BRANCH
>;
};
}
| { type: FlowOperationType.UPDATE_TRIGGER }
| {
type: FlowOperationType.UPDATE_ACTION;
stepName: string;
};
export type AskAiButtonOperations = Exclude<
PieceSelectorOperation,
{ type: FlowOperationType.UPDATE_TRIGGER }
>;
export type PieceSelectorPieceItem =
| {
actionOrTrigger: TriggerBase;
type: FlowTriggerType.PIECE;
pieceMetadata: PieceStepMetadata;
}
| ({
actionOrTrigger: ActionBase;
type: FlowActionType.PIECE;
pieceMetadata: PieceStepMetadata;
} & {
auth?: PieceAuthProperty;
});
export type PieceSelectorItem = PieceSelectorPieceItem | PrimitiveStepMetadata;
export type HandleSelectActionOrTrigger = (item: PieceSelectorItem) => void;

View File

@@ -0,0 +1,9 @@
import { UserWithMetaInformationAndProject } from '@activepieces/shared';
import { api } from './api';
export const userApi = {
getCurrentUser() {
return api.get<UserWithMetaInformationAndProject>('/v1/users/me');
},
};

View File

@@ -0,0 +1,404 @@
import { AxiosError } from 'axios';
import { clsx, type ClassValue } from 'clsx';
import dayjs from 'dayjs';
import i18next, { t } from 'i18next';
import JSZip from 'jszip';
import { useEffect, useRef, useState, RefObject } from 'react';
import { twMerge } from 'tailwind-merge';
import { LocalesEnum, Permission } from '@activepieces/shared';
import { authenticationSession } from './authentication-session';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const emailRegex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const formatUtils = {
emailRegex,
convertEnumToHumanReadable(str: string) {
const words = str.split(/[_.]/);
return words
.map(
(word) =>
word.charAt(0).toUpperCase() + word.slice(1).toLocaleLowerCase(),
)
.join(' ');
},
convertEnumToReadable(value: string): string {
return (
value.charAt(0).toUpperCase() +
value.slice(1).toLowerCase().replace(/_/g, ' ')
);
},
formatNumber(number: number) {
return new Intl.NumberFormat(i18next.language).format(number);
},
formatDateOnlyOrFail(date: Date, fallback: string) {
try {
return this.formatDateOnly(date);
} catch (error) {
return fallback;
}
},
formatDateOnly(date: Date) {
return Intl.DateTimeFormat(i18next.language, {
month: 'numeric',
day: 'numeric',
year: 'numeric',
}).format(date);
},
formatDateWithTime(date: Date, hideCurrentYear: boolean) {
const now = dayjs();
const inputDate = dayjs(date);
const isToday = inputDate.isSame(now, 'day');
const isYesterday = inputDate.isSame(now.subtract(1, 'day'), 'day');
const isSameYear = inputDate.isSame(now, 'year');
const timeFormat = new Intl.DateTimeFormat(i18next.language, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
});
if (isToday) {
return `${t('Today')}, ${timeFormat.format(date)}`;
} else if (isYesterday) {
return `${t('Yesterday')}, ${timeFormat.format(date)}`;
}
if (isSameYear && !hideCurrentYear) {
return Intl.DateTimeFormat(i18next.language, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date);
}
return Intl.DateTimeFormat(i18next.language, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date);
},
formatDate(date: Date) {
const now = dayjs();
const inputDate = dayjs(date);
const isToday = inputDate.isSame(now, 'day');
const isYesterday = inputDate.isSame(now.subtract(1, 'day'), 'day');
const isSameYear = inputDate.isSame(now, 'year');
if (isToday) {
return t('Today');
}
if (isYesterday) {
return t('Yesterday');
}
if (isSameYear) {
return Intl.DateTimeFormat(i18next.language, {
month: 'short',
day: 'numeric',
}).format(date);
}
return Intl.DateTimeFormat(i18next.language, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date);
},
formatToHoursAndMinutes(minutes: number) {
if (minutes < 60) {
return `${formatUtils.formatNumber(minutes)} mins`;
}
const hours = Math.floor(minutes / 60);
return `${formatUtils.formatNumber(hours)} hours`;
},
formatDateToAgo(date: Date) {
const now = dayjs();
const inputDate = dayjs(date);
const diffInSeconds = now.diff(inputDate, 'second');
const diffInMinutes = now.diff(inputDate, 'minute');
const diffInHours = now.diff(inputDate, 'hour');
const diffInDays = now.diff(inputDate, 'day');
if (diffInSeconds < 60) {
return `${diffInSeconds}s ago`;
}
if (diffInMinutes < 60) {
return `${diffInMinutes}m ago`;
}
if (diffInHours < 24) {
return `${diffInHours}h ago`;
}
if (diffInDays < 30) {
return `${diffInDays}d ago`;
}
return inputDate.format('MMM D, YYYY');
},
formatDuration(durationMs: number | undefined, short?: boolean): string {
if (durationMs === undefined) {
return '-';
}
if (durationMs < 1000) {
const durationMsFormatted = Math.floor(durationMs);
return short
? `${durationMsFormatted} ms`
: `${durationMsFormatted} milliseconds`;
}
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
if (seconds < 60) {
return short ? `${seconds} s` : `${seconds} seconds`;
}
if (minutes > 0) {
const remainingSeconds = seconds % 60;
return short
? `${minutes} min ${
remainingSeconds > 0 ? `${remainingSeconds} s` : ''
}`
: `${minutes} minutes${
remainingSeconds > 0 ? ` ${remainingSeconds} seconds` : ''
}`;
}
return short ? `${seconds} s` : `${seconds} seconds`;
},
urlIsNotLocalhostOrIp(url: string): boolean {
const parsed = new URL(url);
if (
parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname === '::1'
) {
return false;
}
const ipv4Regex = /^(?:\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(parsed.hostname)) {
return false;
}
return parsed.protocol === 'https:';
},
};
export const validationUtils = {
isValidationError: (
error: unknown,
): error is AxiosError<{ code?: string; params?: { message?: string } }> => {
console.error('isValidationError', error);
return (
error instanceof AxiosError &&
error.response?.status === 409 &&
error.response?.data?.code === 'VALIDATION'
);
},
};
export function useForwardedRef<T>(ref: React.ForwardedRef<T>) {
const innerRef = useRef<T>(null);
useEffect(() => {
if (!ref) return;
if (typeof ref === 'function') {
ref(innerRef.current);
} else {
ref.current = innerRef.current;
}
});
return innerRef;
}
export const localesMap = {
[LocalesEnum.CHINESE_SIMPLIFIED]: '简体中文',
[LocalesEnum.GERMAN]: 'Deutsch',
[LocalesEnum.ENGLISH]: 'English',
[LocalesEnum.SPANISH]: 'Español',
[LocalesEnum.FRENCH]: 'Français',
[LocalesEnum.JAPANESE]: '日本語',
[LocalesEnum.DUTCH]: 'Nederlands',
[LocalesEnum.PORTUGUESE]: 'Português',
[LocalesEnum.CHINESE_TRADITIONAL]: '繁體中文',
};
export const useElementSize = (ref: RefObject<HTMLElement>) => {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = (entries: ResizeObserverEntry[]) => {
if (entries[0]) {
const { width, height } = entries[0].contentRect;
setSize({ width, height });
}
};
const resizeObserver = new ResizeObserver(handleResize);
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
resizeObserver.disconnect();
};
}, [ref.current]);
return size;
};
export const isStepFileUrl = (json: unknown): json is string => {
return (
Boolean(json) &&
typeof json === 'string' &&
(json.includes('/api/v1/step-files/') || json.includes('file://'))
);
};
export const useTimeAgo = (date: Date) => {
const [timeAgo, setTimeAgo] = useState(() =>
formatUtils.formatDateToAgo(date),
);
useEffect(() => {
const updateInterval = () => {
const now = dayjs();
const inputDate = dayjs(date);
const diffInSeconds = now.diff(inputDate, 'second');
// Update every second if less than a minute
// Update every minute if less than an hour
// Update every hour if less than a day
// Update every day if more than a day
if (diffInSeconds < 60) return 1000;
if (diffInSeconds < 3600) return 60000;
if (diffInSeconds < 86400) return 3600000;
return 86400000;
};
const intervalId = setInterval(() => {
setTimeAgo(formatUtils.formatDateToAgo(date));
}, updateInterval());
return () => clearInterval(intervalId);
}, [date]);
return timeAgo;
};
export const determineDefaultRoute = (
checkAccess: (permission: Permission) => boolean,
) => {
if (checkAccess(Permission.READ_FLOW)) {
return authenticationSession.appendProjectRoutePrefix('/flows');
}
if (checkAccess(Permission.READ_RUN)) {
return authenticationSession.appendProjectRoutePrefix('/runs');
}
return authenticationSession.appendProjectRoutePrefix('/settings');
};
export const NEW_FLOW_QUERY_PARAM = 'newFlow';
export const NEW_TABLE_QUERY_PARAM = 'newTable';
export const NEW_MCP_QUERY_PARAM = 'newMcp';
export const parentWindow: Window = window.opener ?? window.parent;
export const cleanLeadingSlash = (url: string) => {
return url.startsWith('/') ? url.slice(1) : url;
};
export const cleanTrailingSlash = (url: string) => {
return url.endsWith('/') ? url.slice(0, -1) : url;
};
export const combinePaths = ({
firstPath,
secondPath,
}: {
firstPath: string;
secondPath: string;
}) => {
const cleanedFirstPath = cleanTrailingSlash(firstPath);
const cleanedSecondPath = cleanLeadingSlash(secondPath);
return `${cleanedFirstPath}/${cleanedSecondPath}`;
};
const getBlobType = (extension: 'json' | 'txt' | 'csv') => {
switch (extension) {
case 'csv':
return 'text/csv';
case 'json':
return 'application/json';
case 'txt':
return 'text/plain';
default:
return `text/plain`;
}
};
type downloadFileProps =
| {
obj: string;
fileName: string;
extension: 'json' | 'txt' | 'csv';
}
| {
obj: JSZip;
fileName: string;
extension: 'zip';
};
export const downloadFile = async ({
obj,
fileName,
extension,
}: downloadFileProps) => {
const blob =
extension === 'zip'
? await obj.generateAsync({ type: 'blob' })
: //utf-8 with bom
new Blob([new Uint8Array([0xef, 0xbb, 0xbf]), obj], {
type: getBlobType(extension),
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${fileName}.${extension}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const wait = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const scrollToElementAndClickIt = (elementId: string) => {
const element = document.getElementById(elementId);
element?.scrollIntoView({
behavior: 'instant',
block: 'start',
});
element?.click();
};
export const routesThatRequireProjectId = {
runs: '/runs',
singleRun: '/runs/:runId',
flows: '/flows',
singleFlow: '/flows/:flowId',
connections: '/connections',
singleConnection: '/connections/:connectionId',
tables: '/tables',
singleTable: '/tables/:tableId',
todos: '/todos',
singleTodo: '/todos/:todoId',
settings: '/settings',
releases: '/releases',
singleRelease: '/releases/:releaseId',
};