From 038431f1ce5306f97b802bd9c81eee3af217472c Mon Sep 17 00:00:00 2001
From: Douglas Gubert
Date: Mon, 20 Oct 2025 16:11:38 -0300
Subject: [PATCH 001/129] chore: add federation-sdk as dependency for meteor
(#37267)
---
apps/meteor/package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/meteor/package.json b/apps/meteor/package.json
index 4334bb83b4a76..ed07f5eb0d9be 100644
--- a/apps/meteor/package.json
+++ b/apps/meteor/package.json
@@ -252,6 +252,7 @@
"@rocket.chat/emitter": "~0.31.25",
"@rocket.chat/favicon": "workspace:^",
"@rocket.chat/federation-matrix": "workspace:^",
+ "@rocket.chat/federation-sdk": "0.2.0",
"@rocket.chat/freeswitch": "workspace:^",
"@rocket.chat/fuselage": "~0.66.4",
"@rocket.chat/fuselage-forms": "~0.1.0",
From 08b2d4f154dcd68af1a579d8be492ae9eb668faf Mon Sep 17 00:00:00 2001
From: Martin Schoeler
Date: Mon, 20 Oct 2025 17:32:34 -0300
Subject: [PATCH 002/129] chore: new `secondary-danger` generic modal variant
(#37223)
Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
---
.../Modal/GenericModal/GenericModal.spec.tsx | 16 +++++
.../GenericModal/GenericModal.stories.tsx | 3 +
.../Modal/GenericModal/GenericModal.tsx | 6 +-
.../__snapshots__/GenericModal.spec.tsx.snap | 58 +++++++++++++++++++
4 files changed, 81 insertions(+), 2 deletions(-)
create mode 100644 packages/ui-client/src/components/Modal/GenericModal/__snapshots__/GenericModal.spec.tsx.snap
diff --git a/packages/ui-client/src/components/Modal/GenericModal/GenericModal.spec.tsx b/packages/ui-client/src/components/Modal/GenericModal/GenericModal.spec.tsx
index 3d4b083e0c621..74e682b9988f1 100644
--- a/packages/ui-client/src/components/Modal/GenericModal/GenericModal.spec.tsx
+++ b/packages/ui-client/src/components/Modal/GenericModal/GenericModal.spec.tsx
@@ -1,6 +1,7 @@
import { useSetModal } from '@rocket.chat/ui-contexts';
import { act, screen, renderHook } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { axe } from 'jest-axe';
import type { ReactElement } from 'react';
import { Suspense } from 'react';
@@ -25,6 +26,21 @@ const renderModal = (modalElement: ReactElement) => {
return { setModal };
};
+describe('renders', () => {
+ it('should render a modal without crashing', async () => {
+ renderModal();
+
+ expect(await screen.findByRole('dialog', { name: 'Modal' })).toMatchSnapshot();
+ });
+
+ it('should have no accessibility violations when rendered', async () => {
+ renderModal();
+
+ const results = await axe(screen.getByRole('dialog', { name: 'Modal' }));
+ expect(results).toHaveNoViolations();
+ });
+});
+
describe('callbacks', () => {
it('should call onClose callback when dismissed', async () => {
const handleClose = jest.fn();
diff --git a/packages/ui-client/src/components/Modal/GenericModal/GenericModal.stories.tsx b/packages/ui-client/src/components/Modal/GenericModal/GenericModal.stories.tsx
index 38bc99839f17b..663e9a3580266 100644
--- a/packages/ui-client/src/components/Modal/GenericModal/GenericModal.stories.tsx
+++ b/packages/ui-client/src/components/Modal/GenericModal/GenericModal.stories.tsx
@@ -46,6 +46,9 @@ Warning.args = { variant: 'warning' };
export const Success = Template.bind({});
Success.args = { variant: 'success' };
+export const DangerSecondary = Template.bind({});
+DangerSecondary.args = { variant: 'secondary-danger' };
+
export const WithDontAskAgain: StoryFn = (args) => ;
WithDontAskAgain.args = {
dontAskAgain: {
diff --git a/packages/ui-client/src/components/Modal/GenericModal/GenericModal.tsx b/packages/ui-client/src/components/Modal/GenericModal/GenericModal.tsx
index 392f3ec8f02af..8fe78ae7fa788 100644
--- a/packages/ui-client/src/components/Modal/GenericModal/GenericModal.tsx
+++ b/packages/ui-client/src/components/Modal/GenericModal/GenericModal.tsx
@@ -22,7 +22,7 @@ import type { RequiredModalProps } from './withDoNotAskAgain';
import { withDoNotAskAgain } from './withDoNotAskAgain';
import { modalStore } from '../../../providers/ModalProvider/ModalStore';
-type VariantType = 'danger' | 'warning' | 'info' | 'success' | 'upsell';
+type VariantType = 'danger' | 'secondary-danger' | 'warning' | 'info' | 'success' | 'upsell';
type GenericModalProps = RequiredModalProps & {
variant?: VariantType;
@@ -50,6 +50,8 @@ const getButtonProps = (variant: VariantType): ComponentProps =>
switch (variant) {
case 'danger':
return { danger: true };
+ case 'secondary-danger':
+ return { secondary: true, danger: true };
case 'warning':
case 'upsell':
return { primary: true };
@@ -59,7 +61,7 @@ const getButtonProps = (variant: VariantType): ComponentProps =>
};
const renderIcon = (icon: GenericModalProps['icon'], variant: VariantType): ReactNode => {
- if (icon === null) {
+ if (icon === null || iconMap[variant] === undefined) {
return null;
}
diff --git a/packages/ui-client/src/components/Modal/GenericModal/__snapshots__/GenericModal.spec.tsx.snap b/packages/ui-client/src/components/Modal/GenericModal/__snapshots__/GenericModal.spec.tsx.snap
new file mode 100644
index 0000000000000..f73c133b38b63
--- /dev/null
+++ b/packages/ui-client/src/components/Modal/GenericModal/__snapshots__/GenericModal.spec.tsx.snap
@@ -0,0 +1,58 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`renders should render a modal without crashing 1`] = `
+
+`;
From 7a7aad5cb1191c0eca61a22dadd620bd4f6c12a2 Mon Sep 17 00:00:00 2001
From: Douglas Gubert
Date: Mon, 20 Oct 2025 20:19:09 -0300
Subject: [PATCH 003/129] fix(apps): prevent installation invalidation on app
cron updates (#37152)
---
.changeset/rotten-jars-occur.md | 6 +
packages/apps-engine/src/server/AppManager.ts | 11 +-
.../tests/server/AppManager.spec.ts | 142 +++++++++++++++++-
.../server/managers/AppApiManager.spec.ts | 4 +-
.../server/managers/AppSlashCommand.spec.ts | 3 +-
.../managers/AppSlashCommandManager.spec.ts | 4 +-
.../AppVideoConfProviderManager.spec.ts | 4 +-
.../tests/test-data/storage/storage.ts | 41 +++--
.../apps-engine/tests/test-data/utilities.ts | 100 +++++++++++-
9 files changed, 286 insertions(+), 29 deletions(-)
create mode 100644 .changeset/rotten-jars-occur.md
diff --git a/.changeset/rotten-jars-occur.md b/.changeset/rotten-jars-occur.md
new file mode 100644
index 0000000000000..e11d510d2614c
--- /dev/null
+++ b/.changeset/rotten-jars-occur.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/apps-engine': patch
+'@rocket.chat/meteor': patch
+---
+
+Fixes a bug that would cause apps to go into `invalid_installation_disabled` in some cases
diff --git a/packages/apps-engine/src/server/AppManager.ts b/packages/apps-engine/src/server/AppManager.ts
index 1a0480c54c144..173dfc18fbd76 100644
--- a/packages/apps-engine/src/server/AppManager.ts
+++ b/packages/apps-engine/src/server/AppManager.ts
@@ -909,10 +909,15 @@ export class AppManager {
}
appStorageItem.marketplaceInfo[0].subscriptionInfo = appInfo.subscriptionInfo;
+ appStorageItem.signature = await this.getSignatureManager().signApp(appStorageItem);
- return this.appMetadataStorage.updateMarketplaceInfo(appStorageItem._id, appStorageItem.marketplaceInfo);
+ return this.appMetadataStorage.updatePartialAndReturnDocument({
+ _id: appStorageItem._id,
+ marketplaceInfo: appStorageItem.marketplaceInfo,
+ signature: appStorageItem.signature,
+ });
}),
- ).catch();
+ ).catch(() => {});
const queue = [] as Array>;
@@ -933,7 +938,7 @@ export class AppManager {
return;
}
- await this.purgeAppConfig(app);
+ await this.purgeAppConfig(app, { keepScheduledJobs: true });
return app.setStatus(AppStatus.INVALID_LICENSE_DISABLED);
})
diff --git a/packages/apps-engine/tests/server/AppManager.spec.ts b/packages/apps-engine/tests/server/AppManager.spec.ts
index 1f6ef3e4a18f2..b31900cb76301 100644
--- a/packages/apps-engine/tests/server/AppManager.spec.ts
+++ b/packages/apps-engine/tests/server/AppManager.spec.ts
@@ -1,4 +1,4 @@
-import { Expect, SetupFixture, Teardown, Test } from 'alsatian';
+import { AsyncTest, Expect, SetupFixture, SpyOn, Teardown, Test } from 'alsatian';
import { AppManager } from '../../src/server/AppManager';
import { AppBridges } from '../../src/server/bridges';
@@ -14,7 +14,7 @@ import {
AppOutboundCommunicationProviderManager,
} from '../../src/server/managers';
import type { AppLogStorage, AppMetadataStorage, AppSourceStorage } from '../../src/server/storage';
-import { SimpleClass, TestInfastructureSetup } from '../test-data/utilities';
+import { SimpleClass, TestData, TestInfastructureSetup } from '../test-data/utilities';
export class AppManagerTestFixture {
private testingInfastructure: TestInfastructureSetup;
@@ -121,4 +121,142 @@ export class AppManagerTestFixture {
Expect(manager.getVideoConfProviderManager() instanceof AppVideoConfProviderManager).toBe(true);
Expect(manager.getOutboundCommunicationProviderManager() instanceof AppOutboundCommunicationProviderManager).toBe(true);
}
+
+ @AsyncTest('Update Apps Marketplace Info - Apps without subscription info are skipped')
+ public async updateAppsMarketplaceInfoSkipsAppsWithoutSubscriptionInfo() {
+ const manager = new AppManager({
+ metadataStorage: this.testingInfastructure.getAppStorage(),
+ logStorage: this.testingInfastructure.getLogStorage(),
+ bridges: this.testingInfastructure.getAppBridges(),
+ sourceStorage: this.testingInfastructure.getSourceStorage(),
+ });
+
+ const appsOverview = TestData.getAppsOverview();
+ appsOverview[0].latest.subscriptionInfo = undefined; // No subscription info
+
+ // Mock the apps Map to return our mock app
+ (manager as any).apps = new Map([['test-app', TestData.getMockApp(TestData.getAppStorageItem(), manager)]]);
+
+ const updatePartialAndReturnDocumentSpy = SpyOn(manager.getStorage(), 'updatePartialAndReturnDocument');
+ updatePartialAndReturnDocumentSpy.andReturn(Promise.resolve());
+
+ // Should not throw and complete successfully
+ await manager.updateAppsMarketplaceInfo(appsOverview);
+
+ Expect(updatePartialAndReturnDocumentSpy).not.toHaveBeenCalled();
+ }
+
+ @AsyncTest('Update Apps Marketplace Info - Apps not found in manager are skipped')
+ public async updateAppsMarketplaceInfoSkipsAppsNotInManager() {
+ const manager = new AppManager({
+ metadataStorage: this.testingInfastructure.getAppStorage(),
+ logStorage: this.testingInfastructure.getLogStorage(),
+ bridges: this.testingInfastructure.getAppBridges(),
+ sourceStorage: this.testingInfastructure.getSourceStorage(),
+ });
+
+ const appsOverview = TestData.getAppsOverview();
+ appsOverview[0].latest.id = 'nonexistent-app'; // App not in manager
+
+ // Mock the apps Map to return our mock app
+ (manager as any).apps = new Map([['test-app', TestData.getMockApp(TestData.getAppStorageItem(), manager)]]);
+
+ const updatePartialAndReturnDocumentSpy = SpyOn(manager.getStorage(), 'updatePartialAndReturnDocument');
+ updatePartialAndReturnDocumentSpy.andReturn(Promise.resolve());
+
+ // Should not throw and complete successfully
+ await manager.updateAppsMarketplaceInfo(appsOverview);
+
+ Expect(updatePartialAndReturnDocumentSpy).not.toHaveBeenCalled();
+ }
+
+ @AsyncTest('Update Apps Marketplace Info - Apps with same license are skipped')
+ public async updateAppsMarketplaceInfoSkipsAppsWithSameLicense() {
+ const manager = new AppManager({
+ metadataStorage: this.testingInfastructure.getAppStorage(),
+ logStorage: this.testingInfastructure.getLogStorage(),
+ bridges: this.testingInfastructure.getAppBridges(),
+ sourceStorage: this.testingInfastructure.getSourceStorage(),
+ });
+
+ const sameLicenseData = 'same-license-data';
+ const existingSubscriptionInfo = TestData.getMarketplaceSubscriptionInfo({
+ license: { license: sameLicenseData, version: 1, expireDate: new Date('2023-01-01') },
+ });
+
+ const mockStorageItem = TestData.getAppStorageItem({
+ marketplaceInfo: [TestData.getMarketplaceInfo({ subscriptionInfo: existingSubscriptionInfo })],
+ });
+
+ const mockApp = TestData.getMockApp(mockStorageItem, manager);
+
+ // Mock the apps Map to return our mock app
+ (manager as any).apps = new Map([['test-app', mockApp]]);
+
+ const appsOverview = TestData.getAppsOverview(
+ TestData.getMarketplaceSubscriptionInfo({
+ license: { license: sameLicenseData, version: 1, expireDate: new Date('2023-01-01') },
+ }),
+ );
+
+ const updatePartialAndReturnDocumentSpy = SpyOn(manager.getStorage(), 'updatePartialAndReturnDocument');
+ updatePartialAndReturnDocumentSpy.andReturn(Promise.resolve());
+
+ // Should not throw and complete successfully
+ await manager.updateAppsMarketplaceInfo(appsOverview);
+
+ // Verify the subscription info was not updated (should remain the same)
+ Expect(mockStorageItem.marketplaceInfo[0].subscriptionInfo.seats).toBe(10); // Original value
+ Expect(updatePartialAndReturnDocumentSpy).not.toHaveBeenCalled();
+ }
+
+ @AsyncTest('Update Apps Marketplace Info - Subscription info is updated and app is signed')
+ public async updateAppsMarketplaceInfoUpdatesSubscriptionAndSignsApp() {
+ const manager = new AppManager({
+ metadataStorage: this.testingInfastructure.getAppStorage(),
+ logStorage: this.testingInfastructure.getLogStorage(),
+ bridges: this.testingInfastructure.getAppBridges(),
+ sourceStorage: this.testingInfastructure.getSourceStorage(),
+ });
+
+ const existingSubscriptionInfo = TestData.getMarketplaceSubscriptionInfo({
+ license: { license: 'old-license-data', version: 1, expireDate: new Date('2023-01-01') },
+ });
+
+ const newSubscriptionInfo = TestData.getMarketplaceSubscriptionInfo({
+ seats: 20,
+ maxSeats: 200,
+ startDate: '2023-02-01',
+ periodEnd: '2024-01-31',
+ license: { license: 'new-license-data', version: 1, expireDate: new Date('2026-01-01') },
+ });
+
+ const mockStorageItem = TestData.getAppStorageItem({
+ marketplaceInfo: [TestData.getMarketplaceInfo({ subscriptionInfo: existingSubscriptionInfo })],
+ });
+
+ const mockApp = TestData.getMockApp(mockStorageItem, manager);
+
+ // eslint-disable-next-line no-return-assign
+ SpyOn(manager.getSignatureManager(), 'signApp').andReturn(Promise.resolve('signed-app-data'));
+ SpyOn(mockApp, 'validateLicense').andReturn(Promise.resolve());
+
+ const updatePartialAndReturnDocumentSpy = SpyOn(manager.getStorage(), 'updatePartialAndReturnDocument');
+ updatePartialAndReturnDocumentSpy.andReturn(Promise.resolve(mockStorageItem));
+
+ // Mock the apps Map and dependencies
+ (manager as any).apps = new Map([['test-app', mockApp]]);
+
+ const appsOverview = TestData.getAppsOverview(newSubscriptionInfo);
+
+ await manager.updateAppsMarketplaceInfo(appsOverview);
+
+ const expectedStorageItem = mockApp.getStorageItem();
+
+ // Verify the subscription info was updated
+ Expect(expectedStorageItem.marketplaceInfo[0].subscriptionInfo.seats).toBe(20);
+ Expect(expectedStorageItem.marketplaceInfo[0].subscriptionInfo.license.license).toBe('new-license-data');
+ Expect(expectedStorageItem.signature).toBe('signed-app-data');
+ Expect(updatePartialAndReturnDocumentSpy).toHaveBeenCalled().exactly(1).times;
+ }
}
diff --git a/packages/apps-engine/tests/server/managers/AppApiManager.spec.ts b/packages/apps-engine/tests/server/managers/AppApiManager.spec.ts
index ed683a9c6931a..7fb250b154e07 100644
--- a/packages/apps-engine/tests/server/managers/AppApiManager.spec.ts
+++ b/packages/apps-engine/tests/server/managers/AppApiManager.spec.ts
@@ -16,7 +16,7 @@ import type {
import { AppAccessorManager, AppApiManager } from '../../../src/server/managers';
import { AppApi } from '../../../src/server/managers/AppApi';
import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager';
-import type { AppLogStorage } from '../../../src/server/storage';
+import type { AppLogStorage, IAppStorageItem } from '../../../src/server/storage';
import { TestsAppBridges } from '../../test-data/bridges/appBridges';
import { TestsAppLogStorage } from '../../test-data/storage/logStorage';
import { TestData } from '../../test-data/utilities';
@@ -38,7 +38,7 @@ export class AppApiManagerTestFixture {
public setupFixture() {
this.mockBridges = new TestsAppBridges();
- this.mockApp = TestData.getMockApp({ id: 'testing', name: 'TestApp' }, this.mockManager);
+ this.mockApp = TestData.getMockApp({ info: { id: 'testing', name: 'TestApp' } } as IAppStorageItem, this.mockManager);
const bri = this.mockBridges;
const app = this.mockApp;
diff --git a/packages/apps-engine/tests/server/managers/AppSlashCommand.spec.ts b/packages/apps-engine/tests/server/managers/AppSlashCommand.spec.ts
index f6eda56c6fb02..7e4ed81caeeb8 100644
--- a/packages/apps-engine/tests/server/managers/AppSlashCommand.spec.ts
+++ b/packages/apps-engine/tests/server/managers/AppSlashCommand.spec.ts
@@ -1,5 +1,6 @@
import { Expect, SetupFixture, Test } from 'alsatian';
+import type { IAppStorageItem } from '../../../server/storage';
import type { ISlashCommand } from '../../../src/definition/slashcommands';
import type { AppManager } from '../../../src/server/AppManager';
import type { ProxiedApp } from '../../../src/server/ProxiedApp';
@@ -11,7 +12,7 @@ export class AppSlashCommandRegistrationTestFixture {
@SetupFixture
public setupFixture() {
- this.mockApp = TestData.getMockApp({ id: 'test', name: 'TestApp' }, {} as AppManager);
+ this.mockApp = TestData.getMockApp({ info: { id: 'test', name: 'TestApp' } } as IAppStorageItem, {} as AppManager);
}
@Test()
diff --git a/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts b/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts
index 204e31e21fee1..f8e648625b765 100644
--- a/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts
+++ b/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts
@@ -17,7 +17,7 @@ import { AppAccessorManager, AppSlashCommandManager } from '../../../src/server/
import { AppSlashCommand } from '../../../src/server/managers/AppSlashCommand';
import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager';
import { Room } from '../../../src/server/rooms/Room';
-import type { AppLogStorage } from '../../../src/server/storage';
+import type { AppLogStorage, IAppStorageItem } from '../../../src/server/storage';
import { TestsAppBridges } from '../../test-data/bridges/appBridges';
import { TestsAppLogStorage } from '../../test-data/storage/logStorage';
import { TestData } from '../../test-data/utilities';
@@ -39,7 +39,7 @@ export class AppSlashCommandManagerTestFixture {
public setupFixture() {
this.mockBridges = new TestsAppBridges();
- this.mockApp = TestData.getMockApp({ id: 'testing', name: 'TestApp' }, this.mockManager);
+ this.mockApp = TestData.getMockApp({ info: { id: 'testing', name: 'TestApp' } } as IAppStorageItem, this.mockManager);
const bri = this.mockBridges;
const app = this.mockApp;
diff --git a/packages/apps-engine/tests/server/managers/AppVideoConfProviderManager.spec.ts b/packages/apps-engine/tests/server/managers/AppVideoConfProviderManager.spec.ts
index 2ec03c61119a4..b78bca29888d8 100644
--- a/packages/apps-engine/tests/server/managers/AppVideoConfProviderManager.spec.ts
+++ b/packages/apps-engine/tests/server/managers/AppVideoConfProviderManager.spec.ts
@@ -8,7 +8,7 @@ import type { AppApiManager, AppExternalComponentManager, AppSchedulerManager, A
import { AppAccessorManager, AppVideoConfProviderManager } from '../../../src/server/managers';
import { AppVideoConfProvider } from '../../../src/server/managers/AppVideoConfProvider';
import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager';
-import type { AppLogStorage } from '../../../src/server/storage';
+import type { AppLogStorage, IAppStorageItem } from '../../../src/server/storage';
import { TestsAppBridges } from '../../test-data/bridges/appBridges';
import { TestsAppLogStorage } from '../../test-data/storage/logStorage';
import { TestData } from '../../test-data/utilities';
@@ -28,7 +28,7 @@ export class AppVideoConfProviderManagerTestFixture {
public setupFixture() {
this.mockBridges = new TestsAppBridges();
- this.mockApp = TestData.getMockApp({ id: 'testing', name: 'testing' }, this.mockManager);
+ this.mockApp = TestData.getMockApp({ info: { id: 'testing', name: 'testing' } } as IAppStorageItem, this.mockManager);
const bri = this.mockBridges;
const app = this.mockApp;
diff --git a/packages/apps-engine/tests/test-data/storage/storage.ts b/packages/apps-engine/tests/test-data/storage/storage.ts
index 8412e67c65e24..dbab953c6487e 100644
--- a/packages/apps-engine/tests/test-data/storage/storage.ts
+++ b/packages/apps-engine/tests/test-data/storage/storage.ts
@@ -1,3 +1,7 @@
+import type { AppStatus } from '../../../src/definition/AppStatus';
+import type { IAppInfo } from '../../../src/definition/metadata';
+import type { ISetting } from '../../../src/definition/settings';
+import type { IMarketplaceInfo } from '../../../src/server/marketplace';
import type { IAppStorageItem } from '../../../src/server/storage';
import { AppMetadataStorage } from '../../../src/server/storage';
@@ -82,20 +86,6 @@ export class TestsAppStorage extends AppMetadataStorage {
});
}
- public update(item: IAppStorageItem): Promise {
- return new Promise((resolve, reject) => {
- this.db.update({ id: item.id }, item, {}, (err, _numOfUpdated: number) => {
- if (err) {
- reject(err);
- } else {
- this.retrieveOne(item.id)
- .then((updated: IAppStorageItem) => resolve(updated))
- .catch((err2: Error) => reject(err2));
- }
- });
- });
- }
-
public remove(id: string): Promise<{ success: boolean }> {
return new Promise((resolve, reject) => {
this.db.remove({ id }, (err) => {
@@ -107,4 +97,27 @@ export class TestsAppStorage extends AppMetadataStorage {
});
});
}
+
+ public updatePartialAndReturnDocument(
+ item: Partial,
+ options?: { unsetPermissionsGranted?: boolean },
+ ): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ public updateStatus(_id: string, status: AppStatus): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ public updateSetting(_id: string, setting: ISetting): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ public updateAppInfo(_id: string, info: IAppInfo): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ public updateMarketplaceInfo(_id: string, marketplaceInfo: IMarketplaceInfo[]): Promise {
+ throw new Error('Method not implemented.');
+ }
}
diff --git a/packages/apps-engine/tests/test-data/utilities.ts b/packages/apps-engine/tests/test-data/utilities.ts
index 049d346ed25a4..9cd3135ed061c 100644
--- a/packages/apps-engine/tests/test-data/utilities.ts
+++ b/packages/apps-engine/tests/test-data/utilities.ts
@@ -51,8 +51,13 @@ import type {
} from '../../src/server/managers';
import type { AppRuntimeManager } from '../../src/server/managers/AppRuntimeManager';
import type { UIActionButtonManager } from '../../src/server/managers/UIActionButtonManager';
+import type { IMarketplaceInfo, IMarketplaceSubscriptionInfo } from '../../src/server/marketplace';
+import { MarketplacePurchaseType } from '../../src/server/marketplace/MarketplacePurchaseType';
+import { MarketplaceSubscriptionStatus } from '../../src/server/marketplace/MarketplaceSubscriptionStatus';
+import { MarketplaceSubscriptionType } from '../../src/server/marketplace/MarketplaceSubscriptionType';
import type { IRuntimeController } from '../../src/server/runtime/IRuntimeController';
import type { AppLogStorage, AppMetadataStorage, AppSourceStorage, IAppStorageItem } from '../../src/server/storage';
+import { AppInstallationSource } from '../../src/server/storage/IAppStorageItem';
export class TestInfastructureSetup {
private appStorage: TestsAppStorage;
@@ -100,7 +105,9 @@ export class TestInfastructureSetup {
return {} as AppExternalComponentManager;
},
getOneById(appId: string): ProxiedApp {
- return appId === 'failMePlease' ? undefined : TestData.getMockApp({ id: appId, name: 'testing' }, this);
+ return appId === 'failMePlease'
+ ? undefined
+ : TestData.getMockApp({ info: { id: appId, name: 'testing' } } as IAppStorageItem, this);
},
getLogStorage(): AppLogStorage {
return new TestsAppLogStorage();
@@ -601,13 +608,100 @@ export class TestData {
return mock;
}
- public static getMockApp({ id, name }: { id: string; name: string }, manager: AppManager): ProxiedApp {
+ public static getMockApp(storageItem: Partial, manager: AppManager): ProxiedApp {
+ const { id, name } = storageItem.info || { id: 'test-app', name: 'Test App' };
+
return new ProxiedApp(
manager,
- { id, status: AppStatus.AUTO_ENABLED, info: { id, name } } as IAppStorageItem,
+ { id, status: AppStatus.AUTO_ENABLED, info: { id, name }, ...storageItem } as IAppStorageItem,
TestData.getMockRuntimeController(id),
);
}
+
+ public static getMarketplaceSubscriptionInfo(overrides: Partial = {}): IMarketplaceSubscriptionInfo {
+ return {
+ seats: 10,
+ maxSeats: 100,
+ startDate: '2023-01-01',
+ periodEnd: '2023-12-31',
+ isSubscripbedViaBundle: false,
+ typeOf: MarketplaceSubscriptionType.SubscriptionTypeApp,
+ status: MarketplaceSubscriptionStatus.PurchaseSubscriptionStatusActive,
+ license: {
+ license: 'encrypted-license-data',
+ version: 1,
+ expireDate: new Date('2023-01-01'),
+ },
+ ...overrides,
+ };
+ }
+
+ public static getMarketplaceInfo(overrides: Partial = {}): IMarketplaceInfo {
+ return {
+ id: 'test-app',
+ name: 'Test App',
+ nameSlug: 'test-app',
+ version: '1.0.0',
+ description: 'Test app',
+ author: { name: 'Test Author', support: 'https://test.com', homepage: 'https://test.com' },
+ permissions: [],
+ requiredApiVersion: '1.0.0',
+ classFile: 'main.js',
+ iconFile: 'icon.png',
+ implements: [],
+ categories: [],
+ status: 'active',
+ isVisible: true,
+ isPurchased: false,
+ isSubscribed: false,
+ isBundled: false,
+ createdDate: '2023-01-01',
+ modifiedDate: '2023-01-01',
+ price: 0,
+ purchaseType: MarketplacePurchaseType.PurchaseTypeSubscription,
+ subscriptionInfo: TestData.getMarketplaceSubscriptionInfo(),
+ ...overrides,
+ };
+ }
+
+ public static getAppStorageItem(overrides: Partial = {}): IAppStorageItem {
+ return {
+ id: 'test-app',
+ status: AppStatus.AUTO_ENABLED,
+ info: {
+ id: 'test-app',
+ name: 'Test App',
+ nameSlug: 'test-app',
+ version: '1.0.0',
+ description: 'Test app',
+ author: { name: 'Test Author', support: 'https://test.com', homepage: 'https://test.com' },
+ permissions: [],
+ requiredApiVersion: '1.0.0',
+ classFile: 'main.js',
+ iconFile: 'icon.png',
+ implements: [],
+ },
+ marketplaceInfo: [TestData.getMarketplaceInfo()],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ installationSource: AppInstallationSource.MARKETPLACE,
+ languageContent: {},
+ settings: {},
+ implemented: {},
+ signature: 'default-signature',
+ ...overrides,
+ };
+ }
+
+ public static getAppsOverview(subscriptionInfo?: IMarketplaceSubscriptionInfo): Array<{ latest: IMarketplaceInfo }> {
+ return [
+ {
+ latest: TestData.getMarketplaceInfo({
+ subscriptionInfo: subscriptionInfo || TestData.getMarketplaceSubscriptionInfo(),
+ }),
+ },
+ ];
+ }
}
export class SimpleClass {
From 0870d729214092d7be3dd74572cb0eb10598ccac Mon Sep 17 00:00:00 2001
From: gabriellsh <40830821+gabriellsh@users.noreply.github.com>
Date: Mon, 20 Oct 2025 23:27:56 -0300
Subject: [PATCH 004/129] feat: Replace old voice extension assingment flow
(#37245)
---
.changeset/shy-bats-worry.md | 5 +
apps/meteor/app/api/server/v1/users.ts | 5 +
.../server/functions/saveUser/saveNewUser.ts | 4 +
.../lib/server/functions/saveUser/saveUser.ts | 10 +
.../functions/saveUser/validateUserEditing.ts | 27 ++
.../components/UserInfo/UserInfo.stories.tsx | 5 +
.../client/components/UserInfo/UserInfo.tsx | 9 +
.../__snapshots__/UserInfo.spec.tsx.snap | 275 ++++++++++++++++++
.../views/admin/users/AdminUserForm.tsx | 22 +-
.../admin/users/AdminUserInfoWithData.tsx | 2 +
.../users/UsersPageHeaderContent.spec.tsx | 33 ---
.../admin/users/UsersPageHeaderContent.tsx | 5 -
.../users/UsersTable/UsersTable.spec.tsx | 137 +--------
.../admin/users/UsersTable/UsersTable.tsx | 2 +-
.../admin/users/UsersTable/UsersTableRow.tsx | 11 -
.../hooks => }/useVoipExtensionPermission.tsx | 0
.../users/voip/AssignExtensionButton.tsx | 24 --
.../users/voip/AssignExtensionModal.spec.tsx | 123 --------
.../admin/users/voip/AssignExtensionModal.tsx | 164 -----------
.../users/voip/RemoveExtensionModal.spec.tsx | 49 ----
.../admin/users/voip/RemoveExtensionModal.tsx | 98 -------
.../voip/hooks/useVoipExtensionAction.tsx | 36 ---
.../UserInfo/UserInfoWithData.tsx | 2 +
.../api-enterprise/server/voip-freeswitch.ts | 4 +
apps/meteor/tests/data/users.helper.ts | 1 +
apps/meteor/tests/end-to-end/api/users.ts | 203 +++++++++++++
.../src/v1/users/UserCreateParamsPOST.ts | 2 +
.../src/v1/users/UsersUpdateParamsPOST.ts | 5 +
28 files changed, 582 insertions(+), 681 deletions(-)
create mode 100644 .changeset/shy-bats-worry.md
rename apps/meteor/client/views/admin/users/{voip/hooks => }/useVoipExtensionPermission.tsx (100%)
delete mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionButton.tsx
delete mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionModal.spec.tsx
delete mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx
delete mode 100644 apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.spec.tsx
delete mode 100644 apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx
delete mode 100644 apps/meteor/client/views/admin/users/voip/hooks/useVoipExtensionAction.tsx
diff --git a/.changeset/shy-bats-worry.md b/.changeset/shy-bats-worry.md
new file mode 100644
index 0000000000000..8615105495228
--- /dev/null
+++ b/.changeset/shy-bats-worry.md
@@ -0,0 +1,5 @@
+---
+"@rocket.chat/meteor": minor
+---
+
+Replaces old `Assign Extension` button and modal by introducing a proper input in the user edit form.
diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts
index b418d555761fd..048b289b3fcec 100644
--- a/apps/meteor/app/api/server/v1/users.ts
+++ b/apps/meteor/app/api/server/v1/users.ts
@@ -56,6 +56,7 @@ import { saveCustomFields } from '../../../lib/server/functions/saveCustomFields
import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions/saveCustomFieldsWithoutValidation';
import { saveUser } from '../../../lib/server/functions/saveUser';
import { sendWelcomeEmail } from '../../../lib/server/functions/saveUser/sendUserEmail';
+import { canEditExtension } from '../../../lib/server/functions/saveUser/validateUserEditing';
import { setStatusText } from '../../../lib/server/functions/setStatusText';
import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar';
import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername';
@@ -325,6 +326,10 @@ API.v1.addRoute(
validateCustomFields(this.bodyParams.customFields);
}
+ if (this.bodyParams.freeSwitchExtension && !(await canEditExtension(this.userId, this.bodyParams.freeSwitchExtension))) {
+ return API.v1.failure('Setting user voice call extension is not allowed', 'error-action-not-allowed');
+ }
+
const newUserId = await saveUser(this.userId, this.bodyParams);
const userId = typeof newUserId !== 'string' ? this.userId : newUserId;
diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts
index bb9a6a1cac672..508472110fc32 100644
--- a/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts
+++ b/apps/meteor/app/lib/server/functions/saveUser/saveNewUser.ts
@@ -47,6 +47,10 @@ export const saveNewUser = async function (userData: SaveUserData, sendPassword:
updater.set('emails.0.verified', userData.verified);
}
+ if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== '') {
+ updater.set('freeSwitchExtension', userData.freeSwitchExtension);
+ }
+
handleBio(updater, userData.bio);
handleNickname(updater, userData.nickname);
diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
index 13884b6e34b9b..e77e56cc84b9d 100644
--- a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
+++ b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
@@ -53,6 +53,8 @@ export type SaveUserData = {
customFields?: Record;
active?: boolean;
+
+ freeSwitchExtension?: string;
};
export type UpdateUserData = RequiredField;
export const isUpdateUserData = (params: SaveUserData): params is UpdateUserData => '_id' in params && !!params._id;
@@ -162,6 +164,14 @@ const _saveUser = (session?: ClientSession) =>
}
}
+ if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== (oldUserData?.freeSwitchExtension ?? '')) {
+ if (userData.freeSwitchExtension.trim() === '') {
+ updater.unset('freeSwitchExtension');
+ } else {
+ updater.set('freeSwitchExtension', userData.freeSwitchExtension);
+ }
+ }
+
if (typeof userData.verified === 'boolean') {
if (oldUserData && 'emails' in oldUserData && oldUserData.emails?.some(({ address }) => address === userData.email)) {
const index = oldUserData.emails.findIndex(({ address }) => address === userData.email);
diff --git a/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts b/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts
index c9f3ddbe296ac..2fd4a3f0e6f42 100644
--- a/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts
+++ b/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
import { MeteorError } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
@@ -11,6 +12,22 @@ const isEditingUserRoles = (previousRoles: IUser['roles'], newRoles?: IUser['rol
(newRoles.some((item) => !previousRoles.includes(item)) || previousRoles.some((item) => !newRoles.includes(item)));
const isEditingField = (previousValue?: string, newValue?: string) => typeof newValue !== 'undefined' && newValue !== previousValue;
+export const canEditExtension = async (userId: string, newExtension?: string) => {
+ if (!settings.get('VoIP_TeamCollab_Enabled')) {
+ return false;
+ }
+
+ if (!(await hasPermissionAsync(userId, 'manage-voip-extensions'))) {
+ return false;
+ }
+
+ if (newExtension && (await Users.findOneByFreeSwitchExtension(newExtension, { projection: { _id: 1 } }))) {
+ throw new MeteorError('error-extension-not-available', 'Extension is already assigned to another user');
+ }
+
+ return true;
+};
+
/**
* Validate permissions to edit user fields
*
@@ -97,4 +114,14 @@ export async function validateUserEditing(userId: IUser['_id'], userData: Update
action: 'Update_user',
});
}
+
+ if (
+ isEditingField(user.freeSwitchExtension ?? '', userData.freeSwitchExtension) &&
+ !(await canEditExtension(userId, userData.freeSwitchExtension))
+ ) {
+ throw new MeteorError('error-action-not-allowed', 'Edit user voice call extension is not allowed', {
+ method: 'insertOrUpdateUser',
+ action: 'Update_user',
+ });
+ }
}
diff --git a/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx b/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx
index 9e76aa0064590..cf8aa0baad335 100644
--- a/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx
+++ b/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx
@@ -35,6 +35,11 @@ const Template: StoryFn = (args) =>
>;
@@ -71,6 +72,7 @@ const UserInfo = ({
canViewAllInfo,
actions,
reason,
+ freeSwitchExtension,
// @ts-expect-error - abacAttributes is not yet implemented in Users properties
abacAttributes = null,
...props
@@ -180,6 +182,13 @@ const UserInfo = ({
)}
+ {freeSwitchExtension && (
+
+ {t('Voice_call_extension')}
+ {freeSwitchExtension}
+
+ )}
+
{abacAttributes?.length > 0 && (
{t('ABAC_Attributes')}
diff --git a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap
index 33c5d0d0d537b..3a519a834fa8f 100644
--- a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap
+++ b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap
@@ -572,3 +572,278 @@ exports[`renders WithABACAttributes without crashing 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ guilherme.gazzo
+
+
+
+
+
+ currently working on User Card
+
+
+
+
+
+
+ Nickname
+
+
+ gazzo
+
+
+
+
+ Roles
+
+
+
+
+
+
+ admin
+
+
+
+
+
+
+ user
+
+
+
+
+
+
+
+
+ Username
+
+
+ guilherme.gazzo
+
+
+
+
+ Bio
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempus, eros convallis vulputate cursus, nisi neque eleifend libero, eget lacinia justo purus nec est. In at sodales ipsum. Sed lacinia quis purus eget pulvinar. Aenean eu pretium nunc, at aliquam magna. Praesent dignissim, tortor sed volutpat mattis, mauris diam pulvinar leo, porta commodo risus est non purus.
+