From d766267949e3b3803cab59ac118c956852dc8990 Mon Sep 17 00:00:00 2001 From: Amr Elmohamady Date: Tue, 25 Aug 2026 09:51:10 +0300 Subject: [PATCH 1/2] feat(projects): platform toggle to disable automatic personal project creation (#14724) Co-authored-by: Claude Opus 4.7 --- packages/core/shared/package.json | 2 +- .../lib/management/platform/platform.model.ts | 2 + .../management/platform/platform.request.ts | 1 + .../authentication/authentication-utils.ts | 10 +- ...AddAutoCreatePersonalProjectsToPlatform.ts | 21 ++ .../src/app/database/postgres-connection.ts | 2 + .../projects/platform-project-controller.ts | 8 - .../api/src/app/platform/platform.entity.ts | 5 + .../api/src/app/platform/platform.service.ts | 2 + .../server/api/src/app/user/user-service.ts | 15 +- .../server/api/test/helpers/mocks/index.ts | 1 + .../auto-create-personal-projects.test.ts | 206 ++++++++++++++++++ .../web/public/locales/en/translation.json | 7 + .../src/app/components/no-projects-state.tsx | 88 ++++++++ .../app/components/project-layout/index.tsx | 37 ++-- .../components/sidebar/dashboard/index.tsx | 11 +- packages/web/src/app/guards/default-route.tsx | 12 + .../src/app/guards/project-route-wrapper.tsx | 15 +- .../app/routes/platform/projects/index.tsx | 74 +++++-- packages/web/src/app/routes/redirect.tsx | 2 +- .../components/sign-in-form.tsx | 2 +- .../components/sign-up-form.tsx | 2 +- .../components/sidebar-usage-limits.tsx | 5 + 23 files changed, 458 insertions(+), 72 deletions(-) create mode 100644 packages/server/api/src/app/database/migration/postgres/1834000000000-AddAutoCreatePersonalProjectsToPlatform.ts create mode 100644 packages/server/api/test/integration/ee/projects/auto-create-personal-projects.test.ts create mode 100644 packages/web/src/app/components/no-projects-state.tsx diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 714eec874df0..8300baf37148 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.141.0", + "version": "0.142.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/management/platform/platform.model.ts b/packages/core/shared/src/lib/management/platform/platform.model.ts index bbee7c2ae5ee..13f8bc5caebb 100644 --- a/packages/core/shared/src/lib/management/platform/platform.model.ts +++ b/packages/core/shared/src/lib/management/platform/platform.model.ts @@ -204,6 +204,7 @@ export const Platform = z.object({ ssoDomainVerification: Nullable(SsoDomainVerification), federatedAuthProviders: FederatedAuthnProviderConfig, emailAuthEnabled: z.boolean(), + autoCreatePersonalProjects: z.boolean(), pinnedPieces: z.array(z.string()), pieceSelectorConfig: Nullable(PieceSelectorConfig), }) @@ -233,6 +234,7 @@ export const PlatformWithoutSensitiveData = z.object({ ssoDomain: Nullable(z.string()), ssoDomainVerification: Nullable(SsoDomainVerification), emailAuthEnabled: z.boolean(), + autoCreatePersonalProjects: z.boolean(), pinnedPieces: z.array(z.string()), pieceSelectorConfig: Nullable(PieceSelectorConfig), }) diff --git a/packages/core/shared/src/lib/management/platform/platform.request.ts b/packages/core/shared/src/lib/management/platform/platform.request.ts index 750dda93df0f..181050e9fdc0 100644 --- a/packages/core/shared/src/lib/management/platform/platform.request.ts +++ b/packages/core/shared/src/lib/management/platform/platform.request.ts @@ -59,6 +59,7 @@ export const UpdatePlatformRequestBody = z.object({ cloudAuthEnabled: OptionalBooleanFromQuery, googleAuthEnabled: OptionalBooleanFromQuery, emailAuthEnabled: OptionalBooleanFromQuery, + autoCreatePersonalProjects: OptionalBooleanFromQuery, allowedAuthDomains: OptionalArrayFromQuery(z.string()), enforceAllowedAuthDomains: OptionalBooleanFromQuery, pinnedPieces: OptionalArrayFromQuery(z.string()), diff --git a/packages/server/api/src/app/authentication/authentication-utils.ts b/packages/server/api/src/app/authentication/authentication-utils.ts index 9bfa0a0733e8..d5d9ec2ad5fe 100644 --- a/packages/server/api/src/app/authentication/authentication-utils.ts +++ b/packages/server/api/src/app/authentication/authentication-utils.ts @@ -41,14 +41,6 @@ export const authenticationUtils = (log: FastifyBaseLogger) => ({ const project = isNil(params.projectId) ? findPersonalProject(projects, params.userId) ?? projects?.[0] : projects.find((project) => project.id === params.projectId) - if (isNil(project)) { - throw new ActivepiecesError({ - code: ErrorCode.INVITATION_ONLY_SIGN_UP, - params: { - message: 'No project found for user', - }, - }) - } const identity = await userIdentityService(log).getOneOrFail({ id: user.identityId }) if (!identity.verified) { throw new ActivepiecesError({ @@ -83,7 +75,7 @@ export const authenticationUtils = (log: FastifyBaseLogger) => ({ newsLetter: identity.newsLetter, verified: identity.verified, token, - projectId: project.id, + projectId: project?.id ?? null, } }, diff --git a/packages/server/api/src/app/database/migration/postgres/1834000000000-AddAutoCreatePersonalProjectsToPlatform.ts b/packages/server/api/src/app/database/migration/postgres/1834000000000-AddAutoCreatePersonalProjectsToPlatform.ts new file mode 100644 index 000000000000..5aa34a4a6966 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1834000000000-AddAutoCreatePersonalProjectsToPlatform.ts @@ -0,0 +1,21 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class AddAutoCreatePersonalProjectsToPlatform1834000000000 implements Migration { + name = 'AddAutoCreatePersonalProjectsToPlatform1834000000000' + breaking = false + release = '0.87.1' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "platform" + ADD "autoCreatePersonalProjects" boolean NOT NULL DEFAULT true + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "platform" DROP COLUMN "autoCreatePersonalProjects" + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 33b1ac032960..27f07c93fd3b 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -425,6 +425,7 @@ import { AddAiProviderScopes1830000000000 } from './migration/postgres/183000000 import { AddChatPersonalization1831000000000 } from './migration/postgres/1831000000000-AddChatPersonalization' import { BackfillChatPersonalizationForExistingUsers1832000000000 } from './migration/postgres/1832000000000-BackfillChatPersonalizationForExistingUsers' import { ClearRoleFromCompanyPersonalization1833000000000 } from './migration/postgres/1833000000000-ClearRoleFromCompanyPersonalization' +import { AddAutoCreatePersonalProjectsToPlatform1834000000000 } from './migration/postgres/1834000000000-AddAutoCreatePersonalProjectsToPlatform' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -865,6 +866,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddChatPersonalization1831000000000, BackfillChatPersonalizationForExistingUsers1832000000000, ClearRoleFromCompanyPersonalization1833000000000, + AddAutoCreatePersonalProjectsToPlatform1834000000000, ] return migrations } diff --git a/packages/server/api/src/app/ee/projects/platform-project-controller.ts b/packages/server/api/src/app/ee/projects/platform-project-controller.ts index a40957b61c52..b7dbe4bd562c 100644 --- a/packages/server/api/src/app/ee/projects/platform-project-controller.ts +++ b/packages/server/api/src/app/ee/projects/platform-project-controller.ts @@ -142,14 +142,6 @@ async function assertProjectIsSafeToDelete(projectId: string, callerPlatformId: }, }) } - if (project.type === ProjectType.PERSONAL) { - throw new ActivepiecesError({ - code: ErrorCode.VALIDATION, - params: { - message: 'Personal projects cannot be deleted', - }, - }) - } } async function assertMaximumNumberOfProjectsReachedByEdition(platformId: string, log: FastifyBaseLogger): Promise { diff --git a/packages/server/api/src/app/platform/platform.entity.ts b/packages/server/api/src/app/platform/platform.entity.ts index 68984246a99f..8a3e8ed0a8a7 100644 --- a/packages/server/api/src/app/platform/platform.entity.ts +++ b/packages/server/api/src/app/platform/platform.entity.ts @@ -77,6 +77,11 @@ export const PlatformEntity = new EntitySchema({ type: Boolean, nullable: false, }, + autoCreatePersonalProjects: { + type: Boolean, + nullable: false, + default: true, + }, federatedAuthProviders: { type: 'jsonb', select: false, diff --git a/packages/server/api/src/app/platform/platform.service.ts b/packages/server/api/src/app/platform/platform.service.ts index 203ade2455c7..dd1c689cdf2c 100644 --- a/packages/server/api/src/app/platform/platform.service.ts +++ b/packages/server/api/src/app/platform/platform.service.ts @@ -55,6 +55,7 @@ export const platformService = (log: FastifyBaseLogger) => ({ fullLogoUrl: fullLogoUrl ?? defaultTheme.logos.fullLogoUrl, favIconUrl: favIconUrl ?? defaultTheme.logos.favIconUrl, emailAuthEnabled: true, + autoCreatePersonalProjects: true, enforceAllowedAuthDomains: false, allowedAuthDomains: [], federatedAuthProviders: { saml: null }, @@ -176,6 +177,7 @@ export const platformService = (log: FastifyBaseLogger) => ({ ...spreadIfDefined('cloudAuthEnabled', params.cloudAuthEnabled), ...spreadIfDefined('googleAuthEnabled', params.googleAuthEnabled), ...spreadIfDefined('emailAuthEnabled', params.emailAuthEnabled), + ...spreadIfDefined('autoCreatePersonalProjects', params.autoCreatePersonalProjects), ...spreadIfDefined( 'enforceAllowedAuthDomains', params.enforceAllowedAuthDomains, diff --git a/packages/server/api/src/app/user/user-service.ts b/packages/server/api/src/app/user/user-service.ts index ef982469436a..c2291c11e7a5 100644 --- a/packages/server/api/src/app/user/user-service.ts +++ b/packages/server/api/src/app/user/user-service.ts @@ -45,12 +45,15 @@ export const userService = (log: FastifyBaseLogger) => ({ platformRole: PlatformRole.MEMBER, }) - await projectService(log).create({ - displayName: identity.firstName + '\'s Project', - ownerId: newUser.id, - platformId, - type: ProjectType.PERSONAL, - }) + const platform = await platformService(log).getOneOrThrow(platformId) + if (platform.autoCreatePersonalProjects) { + await projectService(log).create({ + displayName: identity.firstName + '\'s Project', + ownerId: newUser.id, + platformId, + type: ProjectType.PERSONAL, + }) + } return newUser } return user diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index 95142f21de43..ca1026a51b5d 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -207,6 +207,7 @@ export const createMockPlatform = (platform?: Partial): Platform => { logoIconUrl: platform?.logoIconUrl ?? faker.image.urlPlaceholder(), fullLogoUrl: platform?.fullLogoUrl ?? faker.image.urlPlaceholder(), emailAuthEnabled: platform?.emailAuthEnabled ?? faker.datatype.boolean(), + autoCreatePersonalProjects: platform?.autoCreatePersonalProjects ?? true, pinnedPieces: platform?.pinnedPieces ?? [], favIconUrl: platform?.favIconUrl ?? faker.image.urlPlaceholder(), cloudAuthEnabled: platform?.cloudAuthEnabled ?? faker.datatype.boolean(), diff --git a/packages/server/api/test/integration/ee/projects/auto-create-personal-projects.test.ts b/packages/server/api/test/integration/ee/projects/auto-create-personal-projects.test.ts new file mode 100644 index 000000000000..ad51aa3c322c --- /dev/null +++ b/packages/server/api/test/integration/ee/projects/auto-create-personal-projects.test.ts @@ -0,0 +1,206 @@ +import { apId, RoleType } from '@activepieces/core-utils' +import { PlatformRole, PrincipalType, ProjectType } from '@activepieces/shared' +import { FastifyBaseLogger, FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { authenticationUtils } from '../../../../src/app/authentication/authentication-utils' +import { userService } from '../../../../src/app/user/user-service' +import { generateMockToken } from '../../../helpers/auth' +import { + createMockPlatform, + createMockProject, + createMockProjectMember, + createMockProjectRole, + createMockUser, + createMockUserIdentity, + mockAndSaveBasicSetup, +} from '../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null +let mockLog: FastifyBaseLogger + +beforeAll(async () => { + app = await setupTestEnvironment() + mockLog = app!.log! +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('Auto-create personal projects toggle', () => { + it('creates a personal project when autoCreatePersonalProjects is true (default)', async () => { + const { mockPlatform } = await mockAndSaveBasicSetup({ + platform: { autoCreatePersonalProjects: true }, + }) + + const identity = createMockUserIdentity({ verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + + const projectsBefore = await databaseConnection().getRepository('project').count({ where: { platformId: mockPlatform.id, type: ProjectType.PERSONAL } }) + + await userService(mockLog).getOrCreateWithProject({ + identity, + platformId: mockPlatform.id, + }) + + const projectsAfter = await databaseConnection().getRepository('project').count({ where: { platformId: mockPlatform.id, type: ProjectType.PERSONAL } }) + expect(projectsAfter).toBe(projectsBefore + 1) + }) + + it('skips personal project creation when autoCreatePersonalProjects is false', async () => { + const { mockPlatform } = await mockAndSaveBasicSetup({ + platform: { autoCreatePersonalProjects: false }, + }) + + const identity = createMockUserIdentity({ verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + + const projectsBefore = await databaseConnection().getRepository('project').count({ where: { platformId: mockPlatform.id, type: ProjectType.PERSONAL } }) + + const user = await userService(mockLog).getOrCreateWithProject({ + identity, + platformId: mockPlatform.id, + }) + + const projectsAfter = await databaseConnection().getRepository('project').count({ where: { platformId: mockPlatform.id, type: ProjectType.PERSONAL } }) + expect(projectsAfter).toBe(projectsBefore) + + const userRow = await databaseConnection().getRepository('user').findOneBy({ id: user.id }) + expect(userRow).not.toBeNull() + }) + + it('lands a member on a team project when they have no personal project', async () => { + const { mockPlatform } = await mockAndSaveBasicSetup({ + platform: { autoCreatePersonalProjects: false }, + }) + + const identity = createMockUserIdentity({ verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + + const member = createMockUser({ + identityId: identity.id, + platformId: mockPlatform.id, + platformRole: PlatformRole.MEMBER, + }) + await databaseConnection().getRepository('user').save(member) + + const teamProject = createMockProject({ + ownerId: mockPlatform.ownerId, + platformId: mockPlatform.id, + type: ProjectType.TEAM, + }) + await databaseConnection().getRepository('project').save(teamProject) + + const projectRole = createMockProjectRole({ + platformId: mockPlatform.id, + type: RoleType.DEFAULT, + }) + await databaseConnection().getRepository('project_role').save(projectRole) + + const projectMember = createMockProjectMember({ + userId: member.id, + projectId: teamProject.id, + platformId: mockPlatform.id, + projectRoleId: projectRole.id, + }) + await databaseConnection().getRepository('project_member').save(projectMember) + + const response = await authenticationUtils(mockLog).getProjectAndToken({ + userId: member.id, + platformId: mockPlatform.id, + projectId: null, + }) + + expect(response.projectId).toBe(teamProject.id) + expect(response.token).toBeDefined() + }) + + it('returns projectId: null when the user has zero projects', async () => { + const { mockPlatform } = await mockAndSaveBasicSetup({ + platform: { autoCreatePersonalProjects: false }, + }) + + const identity = createMockUserIdentity({ verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + + const orphanUser = createMockUser({ + identityId: identity.id, + platformId: mockPlatform.id, + platformRole: PlatformRole.MEMBER, + }) + await databaseConnection().getRepository('user').save(orphanUser) + + const response = await authenticationUtils(mockLog).getProjectAndToken({ + userId: orphanUser.id, + platformId: mockPlatform.id, + projectId: null, + }) + + expect(response.projectId).toBeNull() + expect(response.token).toBeDefined() + }) + + it('allows a platform admin to DELETE a PERSONAL project while its owner is alive', async () => { + const { mockPlatform, mockOwner } = await mockAndSaveBasicSetup() + + const personalProject = createMockProject({ + ownerId: mockOwner.id, + platformId: mockPlatform.id, + type: ProjectType.PERSONAL, + }) + await databaseConnection().getRepository('project').save(personalProject) + + const adminToken = await generateMockToken({ + id: mockOwner.id, + type: PrincipalType.USER, + platform: { id: mockPlatform.id }, + }) + + const response = await app?.inject({ + method: 'DELETE', + url: `/api/v1/projects/${personalProject.id}`, + headers: { authorization: `Bearer ${adminToken}` }, + }) + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) + + const ownerRow = await databaseConnection().getRepository('user').findOneBy({ id: mockOwner.id }) + expect(ownerRow).not.toBeNull() + }) + + it('user delete still tears down the personal project', async () => { + const { mockPlatform } = await mockAndSaveBasicSetup() + + const memberIdentity = createMockUserIdentity({ verified: true }) + await databaseConnection().getRepository('user_identity').save(memberIdentity) + + const member = createMockUser({ + identityId: memberIdentity.id, + platformId: mockPlatform.id, + platformRole: PlatformRole.MEMBER, + }) + await databaseConnection().getRepository('user').save(member) + + const personalProject = createMockProject({ + id: apId(), + ownerId: member.id, + platformId: mockPlatform.id, + type: ProjectType.PERSONAL, + }) + await databaseConnection().getRepository('project').save(personalProject) + + await userService(mockLog).delete({ + id: member.id, + platformId: mockPlatform.id, + }) + + const memberAfter = await databaseConnection().getRepository('user').findOneBy({ id: member.id }) + expect(memberAfter).toBeNull() + + const projectAfter = await databaseConnection().getRepository('project').findOne({ where: { id: personalProject.id }, withDeleted: true }) + expect(projectAfter).toBeDefined() + expect(projectAfter?.deleted).not.toBeNull() + }) +}) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index e40af4042e02..8564193d4319 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1567,6 +1567,13 @@ "No description provided": "No description provided", "Invitation only sign up": "Invitation only sign up", "Please ask your administrator to add you to the organization.": "Please ask your administrator to add you to the organization.", + "No projects yet": "No projects yet", + "Waiting for access": "Waiting for access", + "You're all signed in, but you've got no projects yet.": "You're all signed in, but you've got no projects yet.", + "You haven't been added to a project. Ask your workspace admin to invite you and you'll be up and running in no time.": "You haven't been added to a project. Ask your workspace admin to invite you and you'll be up and running in no time.", + "Automatic personal project creation": "Automatic personal project creation", + "Create a personal project for every new user on signup. Turn off if you provision users into team projects manually (e.g. via SSO or SCIM).": "Create a personal project for every new user on signup. Turn off if you provision users into team projects manually (e.g. via SSO or SCIM).", + "Automatic personal project creation updated": "Automatic personal project creation updated", "View all": "View all", "No templates match your search criteria. Try adjusting your search terms.": "No templates match your search criteria. Try adjusting your search terms.", "Link copied to clipboard!": "Link copied to clipboard!", diff --git a/packages/web/src/app/components/no-projects-state.tsx b/packages/web/src/app/components/no-projects-state.tsx new file mode 100644 index 000000000000..d9f31621752f --- /dev/null +++ b/packages/web/src/app/components/no-projects-state.tsx @@ -0,0 +1,88 @@ +import { t } from 'i18next'; + +import { Badge } from '@/components/ui/badge'; + +export const NoProjectsState = () => { + return ( +
+ + + + + {t('Waiting for access')} + + +
+

+ {t("You're all signed in, but you've got no projects yet.")} +

+

+ {t( + "You haven't been added to a project. Ask your workspace admin to invite you and you'll be up and running in no time.", + )} +

+
+
+ ); +}; + +const MascotIllustration = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/web/src/app/components/project-layout/index.tsx b/packages/web/src/app/components/project-layout/index.tsx index 93481e8ba38a..3126d9caa7df 100644 --- a/packages/web/src/app/components/project-layout/index.tsx +++ b/packages/web/src/app/components/project-layout/index.tsx @@ -2,7 +2,7 @@ import { isNil } from '@activepieces/core-utils'; import { ApEdition, ApFlagId } from '@activepieces/shared'; import React, { ComponentType } from 'react'; import { useTranslation } from 'react-i18next'; -import { Navigate, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { BotIcon } from '@/components/icons/bot'; import { ChartLineIcon } from '@/components/icons/chart-line'; @@ -54,9 +54,7 @@ export function ProjectDashboardLayout({ const location = useLocation(); const isPlatformPage = location.pathname.includes('/platform/'); const isEmbedded = useEmbedding().embedState.isEmbedded; - if (isNil(currentProjectId) || currentProjectId === '') { - return ; - } + const hasNoProject = isNil(currentProjectId) || currentProjectId === ''; const itemsWithoutHeader: ProjectDashboardLayoutHeaderTab[] = [ { @@ -90,21 +88,30 @@ export function ProjectDashboardLayout({ ]; const hideHeader = + hasNoProject || itemsWithoutHeader.some((item) => location.pathname.includes(item.to)) || isPlatformPage; + const inner = ( + + + {children} + + {edition !== ApEdition.COMMUNITY && } + + ); + + if (hasNoProject) { + return inner; + } + return ( - - - - {children} - - {edition !== ApEdition.COMMUNITY && } - + + {inner} ); } diff --git a/packages/web/src/app/components/sidebar/dashboard/index.tsx b/packages/web/src/app/components/sidebar/dashboard/index.tsx index 7c0e6ae88fe9..5c12ce398caf 100644 --- a/packages/web/src/app/components/sidebar/dashboard/index.tsx +++ b/packages/web/src/app/components/sidebar/dashboard/index.tsx @@ -324,11 +324,12 @@ export function ProjectDashboardSidebar({ )} /> ) : ( - isSearchMode && ( -
- {state === 'expanded' && t('No projects found.')} -
- ) +
+ {state === 'expanded' && + (isSearchMode + ? t('No projects found.') + : t('No projects yet'))} +
)} {shouldShowInlineAddButton && state === 'expanded' && ( diff --git a/packages/web/src/app/guards/default-route.tsx b/packages/web/src/app/guards/default-route.tsx index 8916a611f9fe..7c1aff181835 100644 --- a/packages/web/src/app/guards/default-route.tsx +++ b/packages/web/src/app/guards/default-route.tsx @@ -1,3 +1,4 @@ +import { isNil } from '@activepieces/core-utils'; import { Navigate, useLocation } from 'react-router-dom'; import { useAuthorization } from '@/hooks/authorization-hooks'; @@ -5,6 +6,9 @@ import { platformHooks } from '@/hooks/platform-hooks'; import { authenticationSession } from '@/lib/authentication-session'; import { determineDefaultRoute } from '@/lib/route-utils'; +import { NoProjectsState } from '../components/no-projects-state'; +import { ProjectDashboardLayout } from '../components/project-layout'; + export const DefaultRoute = () => { const token = authenticationSession.getToken(); const location = useLocation(); @@ -27,6 +31,14 @@ export const DefaultRoute = () => { const AuthenticatedDefaultRoute = () => { const { checkAccess } = useAuthorization(); const { platform } = platformHooks.useCurrentPlatform(); + const currentProjectId = authenticationSession.getProjectId(); + if (isNil(currentProjectId)) { + return ( + + + + ); + } return ( = ({ children, @@ -51,14 +49,11 @@ const RedirectToCurrentProjectRoute: React.FC< const currentProjectId = authenticationSession.getProjectId(); const params = useParams(); const [searchParams] = useSearchParams(); - const defaultRedirectPath = useDefaultRedirectPath(); - const from = searchParams.get(FROM_QUERY_PARAM) ?? defaultRedirectPath; if (isNil(currentProjectId)) { return ( - + + + ); } diff --git a/packages/web/src/app/routes/platform/projects/index.tsx b/packages/web/src/app/routes/platform/projects/index.tsx index 382391715192..1073ad7637fb 100644 --- a/packages/web/src/app/routes/platform/projects/index.tsx +++ b/packages/web/src/app/routes/platform/projects/index.tsx @@ -1,11 +1,13 @@ import { ProjectType, ProjectWithLimits } from '@activepieces/shared'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ColumnDef } from '@tanstack/react-table'; import { t } from 'i18next'; -import { CheckIcon, Package, Pencil, Trash } from 'lucide-react'; +import { CheckIcon, Package, Pencil, Trash, UserCircle } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; +import { platformApi } from '@/api/platforms-api'; import { DashboardPageHeader } from '@/app/components/dashboard-page-header'; import LockedFeatureGuard from '@/app/components/locked-feature-guard'; import { @@ -14,8 +16,17 @@ import { BulkAction, } from '@/components/custom/data-table'; import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; +import { + Item, + ItemMedia, + ItemContent, + ItemTitle, + ItemDescription, + ItemActions, +} from '@/components/custom/item'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, @@ -35,7 +46,8 @@ import { validationUtils } from '@/lib/validation-utils'; import { projectsTableColumns } from './columns'; export default function ProjectsPage() { - const { platform } = platformHooks.useCurrentPlatform(); + const { platform, setCurrentPlatform } = platformHooks.useCurrentPlatform(); + const queryClient = useQueryClient(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const isEnabled = platform.plan.billedTeamProjectsLimit !== 0; @@ -72,6 +84,23 @@ export default function ProjectsPage() { const { data: allProjects } = projectCollectionUtils.useAllPlatformProjects(filters); + const { + mutate: toggleAutoCreatePersonalProjects, + isPending: isAutoCreatePersonalProjectsPending, + } = useMutation({ + mutationFn: (autoCreatePersonalProjects: boolean) => + platformApi.update({ autoCreatePersonalProjects }, platform.id), + onSuccess: (updatedPlatform) => { + setCurrentPlatform(queryClient, updatedPlatform); + toast.success(t('Automatic personal project creation updated'), { + duration: 3000, + }); + }, + onError: () => { + toast.error(t('Failed to save changes. Please try again.')); + }, + }); + const [selectedRows, setSelectedRows] = useState([]); const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogInitialValues, setEditDialogInitialValues] = @@ -111,11 +140,7 @@ export default function ProjectsPage() { header: ({ table }) => { const selectableRows = table .getRowModel() - .rows.filter( - (row) => - row.original.id !== currentProject?.id && - row.original.type !== ProjectType.PERSONAL, - ); + .rows.filter((row) => row.original.id !== currentProject?.id); const allSelectableSelected = selectableRows.length > 0 && selectableRows.every((row) => row.getIsSelected()); @@ -157,8 +182,7 @@ export default function ProjectsPage() { }, cell: ({ row }) => { const isCurrentProject = row.original.id === currentProject?.id; - const isPersonalProject = row.original.type === ProjectType.PERSONAL; - const isDisabled = isCurrentProject || isPersonalProject; + const isDisabled = isCurrentProject; const isChecked = selectedRows.some( (selectedRow) => selectedRow.id === row.original.id, ); @@ -233,9 +257,7 @@ export default function ProjectsPage() { resetSelection: () => void, ) => { const canDeleteAny = selectedRows.some( - (row) => - row.id !== currentProject?.id && - row.type !== ProjectType.PERSONAL, + (row) => row.id !== currentProject?.id, ); return (
e.stopPropagation()}> @@ -248,9 +270,7 @@ export default function ProjectsPage() { buttonText={t('Delete')} mutationFn={async () => { const deletableProjects = selectedRows.filter( - (row) => - row.id !== currentProject?.id && - row.type !== ProjectType.PERSONAL, + (row) => row.id !== currentProject?.id, ); projectCollectionUtils.delete( deletableProjects.map((row) => row.id), @@ -355,6 +375,30 @@ export default function ProjectsPage() { title={t('Projects')} description={t('Manage your automation projects')} /> +
+ + + + + + {t('Automatic personal project creation')} + + {t( + 'Create a personal project for every new user on signup. Turn off if you provision users into team projects manually (e.g. via SSO or SCIM).', + )} + + + + + toggleAutoCreatePersonalProjects(checked) + } + disabled={isAutoCreatePersonalProjectsPending} + /> + + +
{ code, }); authenticationSession.saveResponse(data, false); - if (isNil(data.projectId)) { + if (isNil(data.platformId)) { navigate('/create-platform'); return; } diff --git a/packages/web/src/features/authentication/components/sign-in-form.tsx b/packages/web/src/features/authentication/components/sign-in-form.tsx index 66befacae33c..1c35bf1b105a 100644 --- a/packages/web/src/features/authentication/components/sign-in-form.tsx +++ b/packages/web/src/features/authentication/components/sign-in-form.tsx @@ -64,7 +64,7 @@ const SignInForm = ({ onForgotPassword }: SignInFormProps) => { onSuccess: (data) => { authenticationSession.saveResponse(data, false); - if (isNil(data.projectId)) { + if (isNil(data.platformId)) { navigate('/create-platform'); return; } diff --git a/packages/web/src/features/authentication/components/sign-up-form.tsx b/packages/web/src/features/authentication/components/sign-up-form.tsx index d3d5192cfe7b..1dca7747528d 100644 --- a/packages/web/src/features/authentication/components/sign-up-form.tsx +++ b/packages/web/src/features/authentication/components/sign-up-form.tsx @@ -104,7 +104,7 @@ const SignUpForm = ({ if (data.verified) { authenticationSession.saveResponse(data, false); - if (isNil(data.projectId)) { + if (isNil(data.platformId)) { navigate('/create-platform'); return; } diff --git a/packages/web/src/features/billing/components/sidebar-usage-limits.tsx b/packages/web/src/features/billing/components/sidebar-usage-limits.tsx index f8af05861d2d..6714c931b450 100644 --- a/packages/web/src/features/billing/components/sidebar-usage-limits.tsx +++ b/packages/web/src/features/billing/components/sidebar-usage-limits.tsx @@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { flowRunUtils } from '@/features/flow-runs/utils/flow-run-utils'; import { projectCollectionUtils } from '@/features/projects'; import { flagsHooks } from '@/hooks/flags-hooks'; +import { authenticationSession } from '@/lib/authentication-session'; import { cn } from '@/lib/utils'; import { billingQueries } from '../hooks/billing-hooks'; @@ -47,6 +48,10 @@ export const SidebarUsageLimits = React.memo(() => { return null; } + if (isNil(authenticationSession.getProjectId())) { + return null; + } + if (isNil(project) || isNil(usage)) { return (
From 0b758bdca90423501468fdc8e2f18c4c05203ce8 Mon Sep 17 00:00:00 2001 From: Ibrahim Abuznaid <135799669+ibrahim-abuznaid@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:46:24 +0300 Subject: [PATCH 2/2] fix(pieces): stop self-hosted piece sync from dropping ai actions (#14673) Co-authored-by: Othman Abu Ajamieh <52608229+othmanemad@users.noreply.github.com> --- .../api/src/app/pieces/piece-sync-service.ts | 23 ++- .../ce/pieces/piece-sync-audience.test.ts | 150 ++++++++++++++++++ 2 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 packages/server/api/test/integration/ce/pieces/piece-sync-audience.test.ts diff --git a/packages/server/api/src/app/pieces/piece-sync-service.ts b/packages/server/api/src/app/pieces/piece-sync-service.ts index 2c793041ec3a..00607c845108 100644 --- a/packages/server/api/src/app/pieces/piece-sync-service.ts +++ b/packages/server/api/src/app/pieces/piece-sync-service.ts @@ -1,6 +1,6 @@ import { groupBy, tryCatch } from '@activepieces/core-utils' import { apVersionUtil } from '@activepieces/server-utils' -import { PieceSyncMode, PieceType } from '@activepieces/shared' +import { PieceAudienceFilter, PieceSyncMode, PieceType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import semver from 'semver' import { rejectedPromiseHandler } from '../helper/promise-handler' @@ -52,11 +52,12 @@ export const pieceSyncService = (log: FastifyBaseLogger) => ({ }, }), listCloudPieces()]) log.info({ dbCount: dbPieces.length, cloudCount: cloudPieces.length }, 'Fetched pieces from DB and Cloud') - const added = await installNewPieces(cloudPieces, dbPieces, log, publishCacheRefresh) + const { added, fetchFailed } = await installNewPieces(cloudPieces, dbPieces, log, publishCacheRefresh) const deleted = await deletePiecesIfNotOnCloud(dbPieces, cloudPieces, log) log.info({ added, + fetchFailed, deleted, durationMs: Math.floor(performance.now() - startTime), }, 'Piece synchronization completed') @@ -83,17 +84,24 @@ async function deletePiecesIfNotOnCloud(dbPieces: PieceMetadataOnly[], cloudPiec return piecesToDelete.length } -async function installNewPieces(cloudPieces: PieceRegistryResponse[], dbPieces: PieceMetadataOnly[], log: FastifyBaseLogger, _publishCacheRefresh: boolean): Promise { +async function installNewPieces(cloudPieces: PieceRegistryResponse[], dbPieces: PieceMetadataOnly[], log: FastifyBaseLogger, _publishCacheRefresh: boolean): Promise<{ added: number, fetchFailed: number }> { const dbMap = new Map(dbPieces.map(dbPiece => [`${dbPiece.name}:${dbPiece.version}`, true])) const newPiecesToFetch = cloudPieces.filter(piece => !dbMap.has(`${piece.name}:${piece.version}`)) const batchSize = 5 + let added = 0 + let fetchFailed = 0 for (let done = 0; done < newPiecesToFetch.length; done += batchSize) { const currentBatch = newPiecesToFetch.slice(done, done + batchSize) await Promise.all(currentBatch.map(async (piece) => { - const url = `${CLOUD_API_URL}/${piece.name}${piece.version ? '?version=' + piece.version : ''}` + const queryParams = new URLSearchParams({ audience: PieceAudienceFilter.ALL }) + if (piece.version) { + queryParams.append('version', piece.version) + } + const url = `${CLOUD_API_URL}/${piece.name}?${queryParams.toString()}` const response = await fetch(url) if (!response.ok) { log.warn({ piece: { name: piece.name, version: piece.version }, status: response.status }, '[pieceSyncService#installNewPieces] Error reading piece metadata') + fetchFailed++ return } const pieceMetadata = await response.json() @@ -106,12 +114,15 @@ async function installNewPieces(cloudPieces: PieceRegistryResponse[], dbPieces: if (error) { log.debug({ piece: { name: piece.name, version: piece.version } }, '[pieceSyncService#installNewPieces] Piece already exists, skipping') } + else { + added++ + } })) } - if (newPiecesToFetch.length > 0) { + if (added > 0) { await pieceCache(log).invalidate() } - return newPiecesToFetch.length + return { added, fetchFailed } } diff --git a/packages/server/api/test/integration/ce/pieces/piece-sync-audience.test.ts b/packages/server/api/test/integration/ce/pieces/piece-sync-audience.test.ts new file mode 100644 index 000000000000..c823ac569f2c --- /dev/null +++ b/packages/server/api/test/integration/ce/pieces/piece-sync-audience.test.ts @@ -0,0 +1,150 @@ +import { ActionBase, Audience } from '@activepieces/pieces-framework' +import { PackageType, PieceType } from '@activepieces/shared' +import { FastifyBaseLogger, FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { pieceCache } from '../../../../src/app/pieces/metadata/piece-cache' +import { pieceRepos } from '../../../../src/app/pieces/metadata/piece-metadata-service' +import { pieceSyncService } from '../../../../src/app/pieces/piece-sync-service' +import { createMockPieceMetadata } from '../../../helpers/mocks' +import { createTestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +const originalSyncMode = vi.hoisted(() => { + const previous = process.env.AP_PIECES_SYNC_MODE + process.env.AP_PIECES_SYNC_MODE = 'OFFICIAL_AUTO' + return previous +}) + +const CLOUD_PIECES_URL = 'https://cloud.activepieces.com/api/v1/pieces' +const PIECE_NAME = '@activepieces/piece-audience-sync-probe' +const PIECE_VERSION = '1.0.0' + +let app: FastifyInstance +let mockLog: FastifyBaseLogger + +function mockAction(name: string, audience: Audience): ActionBase { + return { + name, + displayName: name, + description: `${name} description`, + props: {}, + requireAuth: false, + audience, + } +} + +const cloudPiece = createMockPieceMetadata({ + name: PIECE_NAME, + version: PIECE_VERSION, + pieceType: PieceType.OFFICIAL, + packageType: PackageType.REGISTRY, + actions: { + human_action: mockAction('human_action', 'human'), + ai_action: mockAction('ai_action', 'ai'), + shared_action: mockAction('shared_action', 'both'), + }, +}) + +function applyCloudAudienceFilter(audience: string | null): typeof cloudPiece { + if (audience === 'all') { + return cloudPiece + } + return { + ...cloudPiece, + actions: Object.fromEntries( + Object.entries(cloudPiece.actions).filter(([, action]) => action.audience !== 'ai'), + ), + } +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function stubCloudFetch(): void { + const realFetch = globalThis.fetch + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const target = input instanceof Request ? input.url : String(input) + if (!target.startsWith(CLOUD_PIECES_URL)) { + return realFetch(input, init) + } + const url = new URL(target) + if (url.pathname === '/api/v1/pieces/registry') { + return jsonResponse([{ name: PIECE_NAME, version: PIECE_VERSION }]) + } + if (url.pathname === `/api/v1/pieces/${PIECE_NAME}`) { + return jsonResponse(applyCloudAudienceFilter(url.searchParams.get('audience'))) + } + return jsonResponse({ message: 'not found' }) + }) +} + +// setup() fires an unawaited boot sync; let it finish before tests truncate and +// re-sync, so it can't race them or outlive the fetch stub. +async function settleBootSync(): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const row = await pieceRepos().findOneBy({ name: PIECE_NAME, version: PIECE_VERSION }) + if (row !== null) { + return + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error('Boot piece sync did not settle in time') +} + +beforeAll(async () => { + stubCloudFetch() + app = await setupTestEnvironment({ fresh: true }) + mockLog = app.log + await settleBootSync() +}) + +afterAll(async () => { + vi.unstubAllGlobals() + if (originalSyncMode === undefined) { + delete process.env.AP_PIECES_SYNC_MODE + } + else { + process.env.AP_PIECES_SYNC_MODE = originalSyncMode + } + await teardownTestEnvironment() +}) + +beforeEach(async () => { + await pieceRepos().createQueryBuilder().delete().execute() +}) + +describe('Piece Sync Audience', () => { + it('stores every action of a synced piece, including audience ai', async () => { + await pieceSyncService(mockLog).sync({ publishCacheRefresh: false }) + + const stored = await pieceRepos().findOneByOrFail({ + name: PIECE_NAME, + version: PIECE_VERSION, + }) + + expect(Object.keys(stored.actions).sort()).toEqual(['ai_action', 'human_action', 'shared_action']) + }) + + it('keeps ai actions hidden from the default read paths', async () => { + await pieceSyncService(mockLog).sync({ publishCacheRefresh: false }) + await pieceCache(mockLog).setup() + const ctx = await createTestContext(app) + + const getResponse = await ctx.get(`/v1/pieces/${PIECE_NAME}`) + expect(getResponse.statusCode).toBe(StatusCodes.OK) + const piece: { actions: Record } = getResponse.json() + expect(Object.keys(piece.actions).sort()).toEqual(['human_action', 'shared_action']) + + const listResponse = await ctx.get('/v1/pieces') + expect(listResponse.statusCode).toBe(StatusCodes.OK) + const summaries: { name: string, actions: number }[] = listResponse.json() + const summary = summaries.find(item => item.name === PIECE_NAME) + expect(summary?.actions).toBe(2) + }) +})