Refactor application module architecture for clarity and explicitness (#18432)

## Summary

- **Module reorganization**: Moved `ApplicationUpgradeService` and cron
jobs to `application-upgrade/`, `ApplicationSyncService` to
`application-manifest/`, and
`runWorkspaceMigration`/`uninstallApplication` mutations to the manifest
resolver — each module now has a single clear responsibility.
- **Explicit install flow**: Removed implicit `ApplicationEntity`
creation from `ApplicationSyncService`. The install service and dev
resolver now explicitly create the `ApplicationEntity` before syncing.
npm packages are resolved at registration time to extract manifest
metadata (universalIdentifier, name, description, etc.), eliminating the
`reconcileUniversalIdentifier` hack.
- **Better error handling**: Frontend hooks now surface actual server
error messages in snackbars instead of swallowing them. Replaced the
ugly `ConfirmationModal` for transfer ownership with a proper form
modal. Fixed `SettingsAdminTableCard` row height overflow and corrected
the `yarn-engine` asset path.

## Test plan
- [ ] Register an npm package — verify manifest metadata (name,
description, universalIdentifier) is extracted correctly
- [ ] Install a registered npm app on a workspace — verify
ApplicationEntity is created and sync succeeds
- [ ] Test `app:dev` CLI flow — verify local app registration and sync
work
- [ ] Upload a tarball — verify registration and install flow
- [ ] Transfer ownership — verify the new modal UX works
- [ ] Verify error messages appear correctly in snackbars when
operations fail


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-06 08:45:08 +01:00
committed by GitHub
parent 90cced0e74
commit 514d0017ea
184 changed files with 3179 additions and 1382 deletions
@@ -7,6 +7,7 @@ export const FIND_MANY_APPLICATIONS = gql`
name
description
version
universalIdentifier
applicationRegistrationId
applicationRegistration {
id
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const FIND_ONE_APPLICATION_BY_UNIVERSAL_IDENTIFIER = gql`
query FindOneApplicationByUniversalIdentifier($universalIdentifier: UUID!) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
}
}
`;
@@ -50,6 +50,7 @@ export const MARKETPLACE_APP_FRAGMENT = gql`
description
}
sourcePackage
isFeatured
defaultRole {
id
label
@@ -0,0 +1,7 @@
import gql from 'graphql-tag';
export const INSTALL_APPLICATION = gql`
mutation InstallApplication($appRegistrationId: String!, $version: String) {
installApplication(appRegistrationId: $appRegistrationId, version: $version)
}
`;
@@ -1,7 +0,0 @@
import gql from 'graphql-tag';
export const INSTALL_NPM_APP = gql`
mutation InstallNpmApp($packageName: String!, $version: String) {
installNpmApp(packageName: $packageName, version: $version)
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const REGISTER_NPM_PACKAGE = gql`
mutation RegisterNpmPackage($packageName: String!) {
registerNpmPackage(packageName: $packageName) {
id
universalIdentifier
name
}
}
`;
@@ -0,0 +1,12 @@
import gql from 'graphql-tag';
import { MARKETPLACE_APP_FRAGMENT } from '@/marketplace/graphql/fragments/marketplaceAppFragment';
export const FIND_ONE_MARKETPLACE_APP = gql`
${MARKETPLACE_APP_FRAGMENT}
query FindOneMarketplaceApp($universalIdentifier: String!) {
findOneMarketplaceApp(universalIdentifier: $universalIdentifier) {
...MarketplaceAppFields
}
}
`;
@@ -26,9 +26,11 @@ export const useInstallApp = <TVariables extends Record<string, unknown>>(
}
return false;
} catch {
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: t`Failed to install the application.`,
message: graphqlMessage ?? t`Failed to install the application.`,
});
return false;
@@ -1,13 +0,0 @@
import { useMutation } from '@apollo/client';
import { useInstallApp } from '~/modules/marketplace/hooks/useInstallApp';
import { INSTALL_NPM_APP } from '~/modules/marketplace/graphql/mutations/installNpmApp';
export const useInstallNpmApp = () => {
const [installNpmAppMutation] = useMutation(INSTALL_NPM_APP);
return useInstallApp<{
packageName: string;
version?: string;
}>(installNpmAppMutation);
};
@@ -0,0 +1,50 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { REGISTER_NPM_PACKAGE } from '~/modules/marketplace/graphql/mutations/registerNpmPackage';
export const useRegisterNpmPackage = () => {
const [registerNpmPackageMutation] = useMutation(REGISTER_NPM_PACKAGE);
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [isRegistering, setIsRegistering] = useState(false);
const register = async (params: {
packageName: string;
}): Promise<boolean> => {
setIsRegistering(true);
try {
const result = await registerNpmPackageMutation({
variables: { packageName: params.packageName },
});
const registration = result.data?.registerNpmPackage;
if (!isDefined(registration)) {
enqueueErrorSnackBar({ message: t`Registration failed.` });
return false;
}
enqueueSuccessSnackBar({
message: t`Package registered successfully.`,
});
return true;
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: graphqlMessage ?? t`Failed to register npm package.`,
});
return false;
} finally {
setIsRegistering(false);
}
};
return { register, isRegistering };
};
@@ -29,9 +29,11 @@ export const useUpgradeApplication = () => {
}
return false;
} catch {
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: t`Failed to upgrade the application.`,
message: graphqlMessage ?? t`Failed to upgrade the application.`,
});
return false;
@@ -8,6 +8,7 @@ import { UPLOAD_APP_TARBALL } from '~/modules/marketplace/graphql/mutations/uplo
type UploadResult =
| {
success: true;
registrationId: string;
universalIdentifier: string;
}
| {
@@ -29,7 +30,10 @@ export const useUploadAppTarball = () => {
const registration = result.data?.uploadAppTarball;
if (!isDefined(registration?.universalIdentifier)) {
if (
!isDefined(registration?.id) ||
!isDefined(registration?.universalIdentifier)
) {
enqueueErrorSnackBar({ message: t`Upload failed.` });
return { success: false };
@@ -37,11 +41,14 @@ export const useUploadAppTarball = () => {
return {
success: true,
registrationId: registration.id,
universalIdentifier: registration.universalIdentifier,
};
} catch {
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: t`Failed to upload tarball.`,
message: graphqlMessage ?? t`Failed to upload tarball.`,
});
return { success: false };
@@ -1,27 +1,17 @@
import { useState } from 'react';
import { styled } from '@linaria/react';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { t } from '@lingui/core/macro';
import {
H2Title,
IconArchive,
IconFilter,
IconPlug,
IconRobot,
IconSearch,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { H2Title, IconArchive, IconPlug, IconRobot } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
useCreateDatabaseConfigVariableMutation,
useGetAdminAiModelsQuery,
@@ -30,17 +20,6 @@ import {
import { getModelIcon } from '~/pages/settings/ai/utils/getModelIcon';
import { getModelProviderLabel } from '~/pages/settings/ai/utils/getModelProviderLabel';
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledSearchInputContainer = styled.div`
flex: 1;
`;
export const SettingsAdminAI = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [searchQuery, setSearchQuery] = useState('');
@@ -162,53 +141,41 @@ export const SettingsAdminAI = () => {
description={t`Toggle model availability across all workspaces`}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInputContainer>
<SettingsTextInput
instanceId="admin-model-search"
LeftIcon={IconSearch}
placeholder={t`Search a model...`}
value={searchQuery}
onChange={setSearchQuery}
<SearchInput
placeholder={t`Search a model...`}
value={searchQuery}
onChange={setSearchQuery}
filterDropdown={(filterButton) => (
<Dropdown
dropdownId="admin-ai-models-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconPlug}
onToggleChange={() =>
setShowUnconfigured(!showUnconfigured)
}
toggled={showUnconfigured}
text={t`Unconfigured models`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeprecated(!showDeprecated)}
toggled={showDeprecated}
text={t`Deprecated models`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchInputContainer>
<Dropdown
dropdownId="admin-ai-models-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconPlug}
onToggleChange={() =>
setShowUnconfigured(!showUnconfigured)
}
toggled={showUnconfigured}
text={t`Unconfigured models`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeprecated(!showDeprecated)}
toggled={showDeprecated}
text={t`Deprecated models`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchAndFilterContainer>
)}
/>
<Card rounded>
{filteredModels.map((model, index) => (
@@ -0,0 +1,107 @@
import { FIND_ALL_APPLICATION_REGISTRATIONS } from '@/settings/admin-panel/apps/graphql/queries/findAllApplicationRegistrations';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, Status } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationRegistrationFragmentFragment } from '~/generated-metadata/graphql';
const StyledTableContainer = styled.div`
margin-top: ${themeCssVariables.spacing[3]};
`;
const StyledTableHeaderRowContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const TABLE_GRID = '1fr 1fr 100px 80px';
export const SettingsAdminApps = () => {
const [searchQuery, setSearchQuery] = useState('');
const { data } = useQuery(FIND_ALL_APPLICATION_REGISTRATIONS);
const registrations: ApplicationRegistrationFragmentFragment[] =
data?.findAllApplicationRegistrations ?? [];
const filtered =
searchQuery.trim().length === 0
? registrations
: registrations.filter((registration) => {
const query = searchQuery.toLowerCase();
return (
registration.name.toLowerCase().includes(query) ||
(registration.sourcePackage ?? '').toLowerCase().includes(query) ||
registration.universalIdentifier.toLowerCase().includes(query)
);
});
return (
<Section>
<H2Title
title={t`All App Registrations`}
description={t`All application registrations across the platform, including orphaned marketplace apps`}
/>
<SearchInput
placeholder={t`Search registrations...`}
value={searchQuery}
onChange={setSearchQuery}
/>
<StyledTableContainer>
<Table>
<StyledTableHeaderRowContainer>
<TableRow gridTemplateColumns={TABLE_GRID}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Source`}</TableHeader>
<TableHeader>{t`Listed`}</TableHeader>
<TableHeader>{t`Featured`}</TableHeader>
</TableRow>
</StyledTableHeaderRowContainer>
<TableBody>
{filtered.map((registration) => (
<UndecoratedLink
key={registration.id}
to={getSettingsPath(
SettingsPath.ApplicationRegistrationDetail,
{ applicationRegistrationId: registration.id },
)}
fullWidth
>
<TableRow gridTemplateColumns={TABLE_GRID} isClickable>
<TableCell>{registration.name}</TableCell>
<TableCell>
{registration.sourcePackage ?? registration.sourceType}
</TableCell>
<TableCell>
<Status
color={registration.isListed ? 'green' : 'gray'}
text={registration.isListed ? t`Yes` : t`No`}
/>
</TableCell>
<TableCell>
<Status
color={registration.isFeatured ? 'yellow' : 'gray'}
text={registration.isFeatured ? t`Yes` : t`No`}
/>
</TableCell>
</TableRow>
</UndecoratedLink>
))}
</TableBody>
</Table>
</StyledTableContainer>
</Section>
);
};
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const FIND_ALL_APPLICATION_REGISTRATIONS = gql`
query FindAllApplicationRegistrations {
findAllApplicationRegistrations {
...ApplicationRegistrationFragment
}
}
${APPLICATION_REGISTRATION_FRAGMENT}
`;
@@ -5,6 +5,7 @@ import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/Setting
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { t } from '@lingui/core/macro';
import {
IconApps,
IconHeart,
IconSettings2,
IconSparkles,
@@ -24,6 +25,12 @@ export const SettingsAdminContent = () => {
Icon: IconSettings2,
disabled: !canAccessFullAdminPanel && !canImpersonate,
},
{
id: SETTINGS_ADMIN_TABS.APPS,
title: t`Apps`,
Icon: IconApps,
disabled: !canAccessFullAdminPanel,
},
{
id: SETTINGS_ADMIN_TABS.AI,
title: t`AI`,
@@ -1,4 +1,5 @@
import { SettingsAdminAI } from '@/settings/admin-panel/ai/components/SettingsAdminAI';
import { SettingsAdminApps } from '@/settings/admin-panel/apps/components/SettingsAdminApps';
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
import { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables';
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
@@ -16,6 +17,8 @@ export const SettingsAdminTabContent = () => {
switch (activeTabId) {
case SETTINGS_ADMIN_TABS.GENERAL:
return <SettingsAdminGeneral />;
case SETTINGS_ADMIN_TABS.APPS:
return <SettingsAdminApps />;
case SETTINGS_ADMIN_TABS.AI:
return <SettingsAdminAI />;
case SETTINGS_ADMIN_TABS.CONFIG_VARIABLES:
@@ -45,13 +45,13 @@ export const SettingsAdminTableCard = ({
<TableRow
key={index + item.label}
gridAutoColumns={gridAutoColumns}
height={themeCssVariables.spacing[6]}
>
<TableCell
align={labelAlign}
color={themeCssVariables.font.color.tertiary}
height={themeCssVariables.spacing[6]}
height="auto"
gap={themeCssVariables.spacing[2]}
padding={`${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]}`}
>
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<span>{item.label}</span>
@@ -59,9 +59,10 @@ export const SettingsAdminTableCard = ({
<TableCell
align={valueAlign}
color={themeCssVariables.font.color.primary}
height={themeCssVariables.spacing[6]}
height="auto"
onClick={item.onClick}
clickable={isDefined(item.onClick)}
padding={`${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]}`}
>
{item.value}
</TableCell>
@@ -1,11 +1,5 @@
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconSearch } from 'twenty-ui/display';
const StyledSearchInputContainer = styled.div`
width: 100%;
`;
import { SearchInput } from 'twenty-ui/input';
type ConfigVariableSearchInputProps = {
value: string;
@@ -17,15 +11,10 @@ export const ConfigVariableSearchInput = ({
onChange,
}: ConfigVariableSearchInputProps) => {
return (
<StyledSearchInputContainer>
<SettingsTextInput
instanceId="config-variable-search"
placeholder={t`Search config variables`}
value={value}
onChange={onChange}
autoFocus={false}
LeftIcon={IconSearch}
/>
</StyledSearchInputContainer>
<SearchInput
placeholder={t`Search config variables`}
value={value}
onChange={onChange}
/>
);
};
@@ -1,5 +1,6 @@
export const SETTINGS_ADMIN_TABS = {
GENERAL: 'general',
APPS: 'apps',
AI: 'ai',
CONFIG_VARIABLES: 'config-variables',
HEALTH_STATUS: 'health-status',
@@ -16,6 +16,9 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
latestAvailableVersion
websiteUrl
termsUrl
isListed
isFeatured
ownerWorkspaceId
createdAt
updatedAt
}
@@ -0,0 +1,16 @@
import { gql } from '@apollo/client';
export const TRANSFER_APPLICATION_REGISTRATION_OWNERSHIP = gql`
mutation TransferApplicationRegistrationOwnership(
$applicationRegistrationId: String!
$targetWorkspaceSubdomain: String!
) {
transferApplicationRegistrationOwnership(
applicationRegistrationId: $applicationRegistrationId
targetWorkspaceSubdomain: $targetWorkspaceSubdomain
) {
id
name
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const APPLICATION_REGISTRATION_TARBALL_URL = gql`
query ApplicationRegistrationTarballUrl($id: String!) {
applicationRegistrationTarballUrl(id: $id)
}
`;