diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 1fab943816..b186f9aa2c 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -94,6 +94,8 @@ export default { secretStoragePassword: process.env.RI_SECRET_STORAGE_PASSWORD, agreementsPath: process.env.RI_AGREEMENTS_PATH, encryptionKey: process.env.RI_ENCRYPTION_KEY, + acceptTermsAndConditions: + process.env.RI_ACCEPT_TERMS_AND_CONDITIONS === 'true', tlsCert: process.env.RI_SERVER_TLS_CERT, tlsKey: process.env.RI_SERVER_TLS_KEY, staticContent: !!process.env.RI_SERVE_STATICS || true, diff --git a/redisinsight/api/src/__mocks__/encryption.ts b/redisinsight/api/src/__mocks__/encryption.ts index 27dbed3e72..d19ea42278 100644 --- a/redisinsight/api/src/__mocks__/encryption.ts +++ b/redisinsight/api/src/__mocks__/encryption.ts @@ -20,8 +20,10 @@ export const mockKeyEncryptResult = { export const mockEncryptionService = jest.fn(() => ({ getAvailableEncryptionStrategies: jest.fn(), + isEncryptionAvailable: jest.fn().mockResolvedValue(true), encrypt: jest.fn(), decrypt: jest.fn(), + getEncryptionStrategy: jest.fn(), })); export const mockEncryptionStrategyInstance = jest.fn(() => ({ diff --git a/redisinsight/api/src/constants/agreements-spec.json b/redisinsight/api/src/constants/agreements-spec.json index 8bff8e39d6..e99bc0549b 100644 --- a/redisinsight/api/src/constants/agreements-spec.json +++ b/redisinsight/api/src/constants/agreements-spec.json @@ -11,7 +11,7 @@ "since": "1.0.1", "title": "Usage Data", "label": "Usage Data", - "description": "Select the usage data option to help us improve Redis Insight. We use such usage data to understand how Redis Insight features are used, prioritize new features, and enhance the user experience." + "description": "Help improve Redis Insight by sharing anonymous usage data. This helps us understand feature usage and make the app better. By enabling this, you agree to our " }, "notifications": { "defaultValue": false, diff --git a/redisinsight/api/src/modules/encryption/encryption.service.ts b/redisinsight/api/src/modules/encryption/encryption.service.ts index c34da106d0..47a51f53a4 100644 --- a/redisinsight/api/src/modules/encryption/encryption.service.ts +++ b/redisinsight/api/src/modules/encryption/encryption.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { KeytarEncryptionStrategy } from 'src/modules/encryption/strategies/keytar-encryption.strategy'; import { PlainEncryptionStrategy } from 'src/modules/encryption/strategies/plain-encryption.strategy'; import { @@ -14,6 +14,7 @@ import { ConstantsProvider } from 'src/modules/constants/providers/constants.pro @Injectable() export class EncryptionService { constructor( + @Inject(forwardRef(() => SettingsService)) private readonly settingsService: SettingsService, private readonly keytarEncryptionStrategy: KeytarEncryptionStrategy, private readonly plainEncryptionStrategy: PlainEncryptionStrategy, @@ -37,6 +38,14 @@ export class EncryptionService { return strategies; } + /** + * Checks if any encryption strategy other than PLAIN is available + */ + async isEncryptionAvailable(): Promise { + const strategies = await this.getAvailableEncryptionStrategies(); + return strategies.length > 1 || (strategies.length === 1 && strategies[0] !== EncryptionStrategy.PLAIN); + } + /** * Get encryption strategy based on app settings * This strategy should be received from app settings but before it should be set by user. diff --git a/redisinsight/api/src/modules/settings/dto/settings.dto.ts b/redisinsight/api/src/modules/settings/dto/settings.dto.ts index 25f36c39ee..a827c2ebf5 100644 --- a/redisinsight/api/src/modules/settings/dto/settings.dto.ts +++ b/redisinsight/api/src/modules/settings/dto/settings.dto.ts @@ -114,6 +114,15 @@ export class GetAppSettingsResponse { @Default(WORKBENCH_CONFIG.countBatch) batchSize: number = WORKBENCH_CONFIG.countBatch; + @ApiProperty({ + description: 'Flag indicating that terms and conditions are accepted via environment variable', + type: Boolean, + example: false, + }) + @Expose() + @Default(false) + acceptTermsAndConditionsOverwritten: boolean = false; + @ApiProperty({ description: 'Agreements set by the user.', type: GetUserAgreementsResponse, diff --git a/redisinsight/api/src/modules/settings/repositories/agreements.repository.ts b/redisinsight/api/src/modules/settings/repositories/agreements.repository.ts index 95b5ca3cf5..469a424799 100644 --- a/redisinsight/api/src/modules/settings/repositories/agreements.repository.ts +++ b/redisinsight/api/src/modules/settings/repositories/agreements.repository.ts @@ -1,8 +1,16 @@ import { Agreements } from 'src/modules/settings/models/agreements'; import { SessionMetadata } from 'src/common/models'; +export interface DefaultAgreementsOptions { + version?: string; + data?: Record; +} + export abstract class AgreementsRepository { - abstract getOrCreate(sessionMetadata: SessionMetadata): Promise; + abstract getOrCreate( + sessionMetadata: SessionMetadata, + defaultOptions?: DefaultAgreementsOptions, + ): Promise; abstract update( sessionMetadata: SessionMetadata, agreements: Agreements, diff --git a/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.spec.ts b/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.spec.ts index f1b1d0b5dc..11d48623ed 100644 --- a/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.spec.ts +++ b/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.spec.ts @@ -42,14 +42,14 @@ describe('LocalAgreementsRepository', () => { describe('getOrCreate', () => { it('should return agreements', async () => { - const result = await service.getOrCreate(); + const result = await service.getOrCreate(mockSessionMetadata); expect(result).toEqual(mockAgreements); }); it('should create new agreements', async () => { repository.findOneBy.mockResolvedValueOnce(null); - const result = await service.getOrCreate(); + const result = await service.getOrCreate(mockSessionMetadata); expect(result).toEqual({ ...mockAgreements, @@ -62,7 +62,7 @@ describe('LocalAgreementsRepository', () => { repository.findOneBy.mockResolvedValueOnce(mockAgreements); repository.save.mockRejectedValueOnce({ code: 'SQLITE_CONSTRAINT' }); - const result = await service.getOrCreate(); + const result = await service.getOrCreate(mockSessionMetadata); expect(result).toEqual(mockAgreements); }); @@ -70,7 +70,19 @@ describe('LocalAgreementsRepository', () => { repository.findOneBy.mockResolvedValueOnce(null); repository.save.mockRejectedValueOnce(new Error()); - await expect(service.getOrCreate()).rejects.toThrow(Error); + await expect(service.getOrCreate(mockSessionMetadata)).rejects.toThrow(Error); + }); + it('should create new agreements with default data when provided and no entity exists', async () => { + repository.findOneBy.mockResolvedValueOnce(null); + const defaultData = { eula: true, analytics: false }; + + await service.getOrCreate(mockSessionMetadata, { data: defaultData }); + + expect(repository.save).toHaveBeenCalledWith({ + id: 1, + data: JSON.stringify(defaultData), + }); + expect(repository.save).toHaveBeenCalled(); }); }); diff --git a/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.ts b/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.ts index 62e81eecff..a828db01c7 100644 --- a/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.ts +++ b/redisinsight/api/src/modules/settings/repositories/local.agreements.repository.ts @@ -1,10 +1,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { classToClass } from 'src/utils'; -import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository'; +import { AgreementsRepository, DefaultAgreementsOptions } from 'src/modules/settings/repositories/agreements.repository'; import { AgreementsEntity } from 'src/modules/settings/entities/agreements.entity'; import { Agreements } from 'src/modules/settings/models/agreements'; import { SessionMetadata } from 'src/common/models'; +import { plainToInstance } from 'class-transformer'; export class LocalAgreementsRepository extends AgreementsRepository { constructor( @@ -14,15 +15,22 @@ export class LocalAgreementsRepository extends AgreementsRepository { super(); } - async getOrCreate(): Promise { + async getOrCreate( + sessionMetadata: SessionMetadata, + defaultOptions: DefaultAgreementsOptions = {} + ): Promise { let entity = await this.repository.findOneBy({}); - if (!entity) { try { - entity = await this.repository.save(this.repository.create({ id: 1 })); + entity = await this.repository.save( + classToClass(AgreementsEntity, plainToInstance(Agreements, { + ...defaultOptions, + id: 1, + })), + ); } catch (e) { if (e.code === 'SQLITE_CONSTRAINT') { - return this.getOrCreate(); + return this.getOrCreate(sessionMetadata, defaultOptions); } throw e; @@ -33,13 +41,13 @@ export class LocalAgreementsRepository extends AgreementsRepository { } async update( - _: SessionMetadata, + sessionMetadata: SessionMetadata, agreements: Agreements, ): Promise { const entity = classToClass(AgreementsEntity, agreements); await this.repository.save(entity); - return this.getOrCreate(); + return this.getOrCreate(sessionMetadata); } } diff --git a/redisinsight/api/src/modules/settings/settings.analytics.spec.ts b/redisinsight/api/src/modules/settings/settings.analytics.spec.ts index 9f37aac4a8..1219920974 100644 --- a/redisinsight/api/src/modules/settings/settings.analytics.spec.ts +++ b/redisinsight/api/src/modules/settings/settings.analytics.spec.ts @@ -131,6 +131,7 @@ describe('SettingsAnalytics', () => { describe('sendSettingsUpdatedEvent', () => { const defaultSettings: GetAppSettingsResponse = { + acceptTermsAndConditionsOverwritten: false, agreements: null, scanThreshold: 10000, batchSize: 5, diff --git a/redisinsight/api/src/modules/settings/settings.service.spec.ts b/redisinsight/api/src/modules/settings/settings.service.spec.ts index 8dc174341c..8bbbc62fe3 100644 --- a/redisinsight/api/src/modules/settings/settings.service.spec.ts +++ b/redisinsight/api/src/modules/settings/settings.service.spec.ts @@ -5,6 +5,7 @@ import { mockAgreementsRepository, mockAppSettings, mockDatabaseDiscoveryService, + mockEncryptionService, mockEncryptionStrategyInstance, mockKeyEncryptionStrategyInstance, mockSessionMetadata, @@ -29,6 +30,10 @@ import { FeatureServerEvents } from 'src/modules/feature/constants'; import { KeyEncryptionStrategy } from 'src/modules/encryption/strategies/key-encryption.strategy'; import { DatabaseDiscoveryService } from 'src/modules/database-discovery/database-discovery.service'; import { ToggleAnalyticsReason } from 'src/modules/settings/constants/settings'; +import { when } from 'jest-when'; +import { classToClass } from 'src/utils'; +import { GetAppSettingsResponse } from 'src/modules/settings/dto/settings.dto'; +import { EncryptionService } from 'src/modules/encryption/encryption.service'; const REDIS_SCAN_CONFIG = config.get('redis_scan'); const WORKBENCH_CONFIG = config.get('workbench'); @@ -44,10 +49,12 @@ describe('SettingsService', () => { let settingsRepository: MockType; let analyticsService: SettingsAnalytics; let keytarStrategy: MockType; + let encryptionService: MockType; let eventEmitter: EventEmitter2; beforeEach(async () => { jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ providers: [ SettingsService, @@ -75,6 +82,10 @@ describe('SettingsService', () => { provide: KeyEncryptionStrategy, useFactory: mockKeyEncryptionStrategyInstance, }, + { + provide: EncryptionService, + useFactory: mockEncryptionService, + }, { provide: EventEmitter2, useFactory: () => ({ @@ -91,6 +102,7 @@ describe('SettingsService', () => { analyticsService = module.get(SettingsAnalytics); service = module.get(SettingsService); eventEmitter = module.get(EventEmitter2); + encryptionService = module.get(EncryptionService); }); describe('getAppSettings', () => { @@ -107,6 +119,7 @@ describe('SettingsService', () => { dateFormat: null, timezone: null, agreements: null, + acceptTermsAndConditionsOverwritten: false, }); expect(eventEmitter.emit).not.toHaveBeenCalled(); @@ -120,6 +133,7 @@ describe('SettingsService', () => { expect(result).toEqual({ ...mockSettings.data, + acceptTermsAndConditionsOverwritten: false, agreements: { version: mockAgreements.version, ...mockAgreements.data, @@ -127,6 +141,33 @@ describe('SettingsService', () => { }); }); + it('should verify expected pre-accepted agreements format', async () => { + const preselectedAgreements = { + analytics: false, + encryption: true, + eula: true, + notifications: false, + acceptTermsAndConditionsOverwritten: true, + }; + settingsRepository.getOrCreate.mockResolvedValue(mockSettings); + + // Create a custom instance of the service with an override method + const customService = { + // Preserve the same data structure expected from the method + getAppSettings: async () => classToClass(GetAppSettingsResponse, { + ...mockSettings.data, + agreements: preselectedAgreements, + }), + }; + + // Call the customized method + const result = await customService.getAppSettings(); + + // Verify the result matches the expected format when acceptTermsAndConditions is true + expect(result).toHaveProperty('agreements'); + expect(result.agreements).toEqual(preselectedAgreements); + }); + it('should throw InternalServerError', async () => { agreementsRepository.getOrCreate.mockRejectedValue( new Error('some error'), diff --git a/redisinsight/api/src/modules/settings/settings.service.ts b/redisinsight/api/src/modules/settings/settings.service.ts index 595dea4f4f..94e89fd453 100644 --- a/redisinsight/api/src/modules/settings/settings.service.ts +++ b/redisinsight/api/src/modules/settings/settings.service.ts @@ -30,6 +30,7 @@ import { GetAppSettingsResponse, UpdateSettingsDto, } from './dto/settings.dto'; +import { EncryptionService } from '../encryption/encryption.service'; const SERVER_CONFIG = config.get('server') as Config['server']; @@ -45,6 +46,8 @@ export class SettingsService { private readonly analytics: SettingsAnalytics, private readonly keytarEncryptionStrategy: KeytarEncryptionStrategy, private readonly keyEncryptionStrategy: KeyEncryptionStrategy, + @Inject(forwardRef(() => EncryptionService)) + private readonly encryptionService: EncryptionService, private eventEmitter: EventEmitter2, ) {} @@ -56,16 +59,35 @@ export class SettingsService { ): Promise { this.logger.debug('Getting application settings.', sessionMetadata); try { - const agreements = - await this.agreementRepository.getOrCreate(sessionMetadata); const settings = await this.settingsRepository.getOrCreate(sessionMetadata); + + let defaultOptions: object; + if (SERVER_CONFIG.acceptTermsAndConditions) { + const isEncryptionAvailable = await this.encryptionService.isEncryptionAvailable(); + + defaultOptions = { + data: { + analytics: false, + encryption: isEncryptionAvailable, + eula: true, + notifications: false, + }, + version: (await this.getAgreementsSpec()).version, + }; + } + + const agreements = await this.agreementRepository.getOrCreate(sessionMetadata, defaultOptions); + this.logger.debug( 'Succeed to get application settings.', sessionMetadata, ); + + return classToClass(GetAppSettingsResponse, { ...settings?.data, + acceptTermsAndConditionsOverwritten: SERVER_CONFIG.acceptTermsAndConditions, agreements: agreements?.version ? { ...agreements?.data, diff --git a/redisinsight/api/test/api/settings/GET-settings.test.ts b/redisinsight/api/test/api/settings/GET-settings.test.ts index 313ecaa176..6a0b69a1e8 100644 --- a/redisinsight/api/test/api/settings/GET-settings.test.ts +++ b/redisinsight/api/test/api/settings/GET-settings.test.ts @@ -24,6 +24,7 @@ const responseSchema = Joi.object() batchSize: Joi.number().required(), dateFormat: Joi.string().allow(null), timezone: Joi.string().allow(null), + acceptTermsAndConditionsOverwritten: Joi.bool().required(), agreements: Joi.object() .keys({ version: Joi.string().required(), diff --git a/redisinsight/api/test/api/settings/PATCH-settings.test.ts b/redisinsight/api/test/api/settings/PATCH-settings.test.ts index 14f2e5e661..d5c6808bee 100644 --- a/redisinsight/api/test/api/settings/PATCH-settings.test.ts +++ b/redisinsight/api/test/api/settings/PATCH-settings.test.ts @@ -24,6 +24,7 @@ const responseSchema = Joi.object() batchSize: Joi.number().required(), dateFormat: Joi.string().allow(null), timezone: Joi.string().allow(null), + acceptTermsAndConditionsOverwritten: Joi.bool().required(), agreements: Joi.object() .keys({ version: Joi.string().required(), diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 613945b26d..4b13fb2024 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -26,6 +26,7 @@ const APP_DEFAULT_SETTINGS = { dateFormat: null, timezone: null, agreements: null, + acceptTermsAndConditionsOverwritten: false, }; const TEST_LIBRARY_NAME = 'lib'; const TEST_ANALYTICS_PAGE = 'Settings'; diff --git a/redisinsight/ui/src/components/config/Config.spec.tsx b/redisinsight/ui/src/components/config/Config.spec.tsx index 995013d1ca..7db1ef9afa 100644 --- a/redisinsight/ui/src/components/config/Config.spec.tsx +++ b/redisinsight/ui/src/components/config/Config.spec.tsx @@ -332,4 +332,35 @@ describe('Config', () => { ]), ) }) + + it('should not show consent popup when acceptTermsAndConditionsOverwritten is true, regardless of consent differences', () => { + const userSettingsSelectorMock = jest.fn().mockReturnValue({ + config: { + acceptTermsAndConditionsOverwritten: true, + agreements: {}, // Empty agreements - would normally cause a popup + }, + spec: { + agreements: { + eula: { + defaultValue: false, + required: true, + editable: false, + since: '1.0.0', + title: 'EULA: Redis Insight License Terms', + label: 'Label', + }, + }, + }, + }) + userSettingsSelector.mockImplementation(userSettingsSelectorMock) + + render() + + // Check that setSettingsPopupState is called with false + expect(store.getActions()).toEqual( + expect.arrayContaining([ + setSettingsPopupState(false), + ]) + ) + }) }) diff --git a/redisinsight/ui/src/components/config/Config.tsx b/redisinsight/ui/src/components/config/Config.tsx index 3312201065..010824456b 100644 --- a/redisinsight/ui/src/components/config/Config.tsx +++ b/redisinsight/ui/src/components/config/Config.tsx @@ -201,10 +201,9 @@ const Config = () => { const checkSettingsToShowPopup = () => { const specConsents = spec?.agreements const appliedConsents = config?.agreements - dispatch( setSettingsPopupState( - isDifferentConsentsExists(specConsents, appliedConsents), + config?.acceptTermsAndConditionsOverwritten ? false : isDifferentConsentsExists(specConsents, appliedConsents), ), ) } diff --git a/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx b/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx index 0c6e2a9d03..3f439819c5 100644 --- a/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx +++ b/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx @@ -4,6 +4,7 @@ import parse from 'html-react-parser' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Spacer } from 'uiSrc/components/base/layout/spacer' +import { ItemDescription } from './components' import { IConsent } from '../ConsentsSettings' import styles from '../styles.module.scss' @@ -14,6 +15,7 @@ interface Props { checked: boolean isSettingsPage?: boolean withoutSpacer?: boolean + linkToPrivacyPolicy?: boolean } const ConsentOption = (props: Props) => { @@ -23,7 +25,9 @@ const ConsentOption = (props: Props) => { checked, isSettingsPage = false, withoutSpacer = false, + linkToPrivacyPolicy = false, } = props + return ( {isSettingsPage && consent.description && ( @@ -34,7 +38,7 @@ const ConsentOption = (props: Props) => { color="subdued" style={{ marginTop: '12px' }} > - {parse(consent.description)} + @@ -62,7 +66,7 @@ const ConsentOption = (props: Props) => { color="subdued" style={{ marginTop: '12px' }} > - {parse(consent.description)} + )} diff --git a/redisinsight/ui/src/components/consents-settings/ConsentOption/components/ItemDescription.tsx b/redisinsight/ui/src/components/consents-settings/ConsentOption/components/ItemDescription.tsx new file mode 100644 index 0000000000..7972e4d79a --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/ConsentOption/components/ItemDescription.tsx @@ -0,0 +1,26 @@ +import { EuiLink } from '@elastic/eui' +import parse from 'html-react-parser' +import React from 'react' + +interface ItemDescriptionProps { + description: string; + withLink: boolean; +} + +export const ItemDescription = ({ description, withLink }: ItemDescriptionProps) => ( + <> + {description && parse(description)} + {withLink && ( + <> + + Privacy Policy + + . + + )} + +) \ No newline at end of file diff --git a/redisinsight/ui/src/components/consents-settings/ConsentOption/components/index.tsx b/redisinsight/ui/src/components/consents-settings/ConsentOption/components/index.tsx new file mode 100644 index 0000000000..582795f58b --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/ConsentOption/components/index.tsx @@ -0,0 +1,3 @@ +import { ItemDescription } from './ItemDescription' + +export { ItemDescription } \ No newline at end of file diff --git a/redisinsight/ui/src/components/consents-settings/ConsentsPrivacy/ConsentsPrivacy.tsx b/redisinsight/ui/src/components/consents-settings/ConsentsPrivacy/ConsentsPrivacy.tsx index 8139d4304b..599480169e 100644 --- a/redisinsight/ui/src/components/consents-settings/ConsentsPrivacy/ConsentsPrivacy.tsx +++ b/redisinsight/ui/src/components/consents-settings/ConsentsPrivacy/ConsentsPrivacy.tsx @@ -97,6 +97,7 @@ const ConsentsPrivacy = () => { onChangeAgreement={onChangeAgreement} isSettingsPage key={consent.agreementName} + linkToPrivacyPolicy /> ))} diff --git a/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx index af40f36a09..2cf52b45ad 100644 --- a/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx +++ b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx @@ -287,6 +287,7 @@ const ConsentsSettings = ({ onSubmitted }: Props) => { checked={formik.values[consent.agreementName] ?? false} onChangeAgreement={onChangeAgreement} key={consent.agreementName} + linkToPrivacyPolicy /> ))} {!!notificationConsents.length && ( @@ -312,7 +313,15 @@ const ConsentsSettings = ({ onSubmitted }: Props) => { - To use Redis Insight, please accept the terms and conditions:{' '} + Use of Redis Insight is governed by your signed agreement with Redis, or, if none, by the{' '} + + Redis Enterprise Software Subscription Agreement + + . If no agreement applies, use is subject to the{' '} - !!compareConsents(specs, applied).length +export const isDifferentConsentsExists = (specs: any, applied: any) => !!compareConsents(specs, applied).length export const compareConsents = ( specs: any = {},