diff --git a/redisinsight/api/src/constants/agreements-spec.json b/redisinsight/api/src/constants/agreements-spec.json index 001c218789..1e59a2230d 100644 --- a/redisinsight/api/src/constants/agreements-spec.json +++ b/redisinsight/api/src/constants/agreements-spec.json @@ -2,6 +2,7 @@ "version": "1.0.6", "agreements": { "analytics": { + "code": "analytics", "defaultValue": false, "displayInSetting": true, "required": false, @@ -15,6 +16,7 @@ "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": { + "code": "notifications", "defaultValue": false, "displayInSetting": true, "required": false, @@ -24,8 +26,8 @@ "category": "notifications", "since": "1.0.6", "title": "Notification", - "label": "Show notification", - "description": "Select to display notification. Otherwise, notifications are shown in the Notification Center." + "label": "Show notifications", + "description": "Select to display notifications. Otherwise, notifications are shown in the Notification Center." }, "encryption": { "conditional": true, @@ -34,6 +36,7 @@ "options": { "true": { + "code": "encryptionEnabled", "defaultValue": true, "displayInSetting": false, "required": false, @@ -47,6 +50,7 @@ "description": "Select to encrypt sensitive information using system keychain. Otherwise, this information is stored locally in plain text, which may incur security risk." }, "false": { + "code": "encryptionDisabled", "defaultValue": false, "displayInSetting": false, "required": false, @@ -60,6 +64,7 @@ "description": "Install or enable the system keychain to encrypt and securely store your sensitive information added before using the application. Otherwise, this information will be stored locally in plain text and may lead to security risks." }, "stack_false": { + "code": "encryptionStackDisabled", "defaultValue": false, "displayInSetting": false, "required": false, @@ -75,6 +80,7 @@ } }, "eula": { + "code": "eula", "defaultValue": false, "displayInSetting": false, "required": true, diff --git a/redisinsight/api/src/modules/settings/models/agreements.interface.ts b/redisinsight/api/src/modules/settings/models/agreements.interface.ts index 98b0f44c93..270096207d 100644 --- a/redisinsight/api/src/modules/settings/models/agreements.interface.ts +++ b/redisinsight/api/src/modules/settings/models/agreements.interface.ts @@ -1,4 +1,5 @@ export interface IAgreement { + code?: string; defaultValue?: boolean; displayInSetting?: boolean; required?: boolean; diff --git a/redisinsight/api/src/modules/settings/settings.service.ts b/redisinsight/api/src/modules/settings/settings.service.ts index 6519dfbd1e..643ec5c563 100644 --- a/redisinsight/api/src/modules/settings/settings.service.ts +++ b/redisinsight/api/src/modules/settings/settings.service.ts @@ -6,7 +6,7 @@ import { InternalServerErrorException, Logger, } from '@nestjs/common'; -import { difference, isEmpty, map, cloneDeep } from 'lodash'; +import { difference, isEmpty, map, cloneDeep, forEach } from 'lodash'; import { readFile } from 'fs-extra'; import { join } from 'path'; import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; @@ -20,7 +20,10 @@ import { classToClass } from 'src/utils'; import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository'; import { FeatureServerEvents } from 'src/modules/feature/constants'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { IAgreementSpecFile } from 'src/modules/settings/models/agreements.interface'; +import { + IAgreement, + IAgreementSpecFile, +} from 'src/modules/settings/models/agreements.interface'; import { SessionMetadata } from 'src/common/models'; import { DatabaseDiscoveryService } from 'src/modules/database-discovery/database-discovery.service'; @@ -228,9 +231,19 @@ export class SettingsService { private async getAgreementsSpecFromFile(): Promise { try { if (SERVER_CONFIG.agreementsPath) { - return JSON.parse( + const spec: IAgreementSpecFile = JSON.parse( await readFile(join(__dirname, SERVER_CONFIG.agreementsPath), 'utf8'), ); + + // A custom spec (RI_AGREEMENTS_PATH) owns its consent/legal copy. Drop + // the i18n `code` fields (even if copied from the bundled spec) so the + // UI renders that text verbatim instead of the built-in translation. + forEach(spec?.agreements, (agreement: IAgreement) => { + delete agreement.code; + forEach(agreement?.options, (option) => delete option.code); + }); + + return spec; } } catch (e) { // ignore error diff --git a/redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts b/redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts index 96e64e9276..3f76937522 100644 --- a/redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts +++ b/redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts @@ -6,6 +6,7 @@ const { server, request } = deps; const endpoint = () => request(server).get('/settings/agreements/spec'); const agreementItemSchema = Joi.object().keys({ + code: Joi.string().optional(), defaultValue: Joi.bool().required(), required: Joi.bool().required(), disabled: Joi.bool().required(), diff --git a/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.spec.tsx b/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.spec.tsx index 2af16c186a..bde49c49d4 100644 --- a/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.spec.tsx +++ b/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.spec.tsx @@ -4,6 +4,7 @@ import ConsentOption from './ConsentOption' import { IConsent } from '../ConsentsSettings' const mockConsent: IConsent = { + code: 'test-consent', agreementName: 'analytics', title: 'Analytics', label: 'Share usage data', @@ -121,6 +122,59 @@ describe('ConsentOption', () => { expect(screen.queryByText('Privacy Policy')).not.toBeInTheDocument() }) + it('should localize label and description by agreement code', () => { + const analyticsConsent = { + ...mockConsent, + code: 'analytics', + label: 'Share usage data', + description: 'Help us improve Redis Insight by sharing usage data.', + } + + render() + + // The spec-provided English is overridden by the api.agreement.analytics.* copy. + expect(screen.getByText('Usage Data')).toBeInTheDocument() + expect( + screen.queryByText( + 'Help us improve Redis Insight by sharing usage data.', + ), + ).not.toBeInTheDocument() + }) + + it('should keep server-provided copy when no code is sent (custom spec)', () => { + const customConsent = { + ...mockConsent, + code: undefined, + agreementName: 'analytics', + label: 'Custom usage data label', + description: 'Custom usage data description.', + } + + render() + + // No code -> do not fall back to api.agreement.analytics.* built-in copy. + expect(screen.getByText('Custom usage data label')).toBeInTheDocument() + expect( + screen.getByText('Custom usage data description.'), + ).toBeInTheDocument() + expect(screen.queryByText('Usage Data')).not.toBeInTheDocument() + }) + + it('should not render a description (or the raw key) when the agreement has none', () => { + const eulaConsent = { + ...mockConsent, + code: 'eula', + description: undefined, + } + + render() + + // Missing description + missing key must not leak the i18n key string. + expect( + screen.queryByText('api.agreement.eula.description'), + ).not.toBeInTheDocument() + }) + it('should render disabled switch when consent is disabled', () => { const disabledConsent = { ...mockConsent, diff --git a/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx b/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx index d56b2c2c8c..9b57dd49d7 100644 --- a/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx +++ b/redisinsight/ui/src/components/consents-settings/ConsentOption/ConsentOption.tsx @@ -6,6 +6,7 @@ import { Spacer } from 'uiSrc/components/base/layout/spacer' import { Text } from 'uiSrc/components/base/text' import { SwitchInput } from 'uiSrc/components/base/inputs' +import { useTranslation } from 'uiSrc/i18n' import { ItemDescription } from './components' import { IConsent } from '../ConsentsSettings' @@ -27,14 +28,30 @@ const ConsentOption = (props: Props) => { withoutSpacer = false, } = props + const { t } = useTranslation() + + // Localize the backend-supplied copy by its stable agreement code, falling + // back to the English text shipped in the spec (mirrors the error-code i18n). + const label = consent.code + ? t(`api.agreement.${consent.code}.label` as never, { + defaultValue: consent.label, + }) + : consent.label + const description = + consent.code && consent.description + ? t(`api.agreement.${consent.code}.description` as never, { + defaultValue: consent.description, + }) + : consent.description + return ( - {isSettingsPage && consent.description && ( + {isSettingsPage && description && ( <> @@ -55,14 +72,14 @@ const ConsentOption = (props: Props) => { - {parse(consent.label)} + {parse(label)} - {!isSettingsPage && consent.description && ( + {!isSettingsPage && 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 index bb0a6e6ce9..0a11347297 100644 --- a/redisinsight/ui/src/components/consents-settings/ConsentOption/components/ItemDescription.tsx +++ b/redisinsight/ui/src/components/consents-settings/ConsentOption/components/ItemDescription.tsx @@ -3,6 +3,7 @@ import React from 'react' import { Link } from 'uiSrc/components/base/link/Link' import { EXTERNAL_LINKS, UTM_MEDIUMS } from 'uiSrc/constants/links' import { getUtmExternalLink } from 'uiSrc/utils/links' +import { useTranslation } from 'uiSrc/i18n' interface ItemDescriptionProps { description: string @@ -12,25 +13,29 @@ interface ItemDescriptionProps { export const ItemDescription = ({ description, withLink, -}: ItemDescriptionProps) => ( - <> - {description && parse(description)} - {withLink && ( - <> - - Privacy Policy - - . - - )} - -) +}: ItemDescriptionProps) => { + const { t } = useTranslation() + + return ( + <> + {description && parse(description)} + {withLink && ( + <> + + {t('common.privacyPolicy')} + + . + + )} + + ) +} diff --git a/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx index 57988d8575..81949e1b51 100644 --- a/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx +++ b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx @@ -29,6 +29,7 @@ interface Values { } export interface IConsent { + code?: string defaultValue: boolean displayInSetting: boolean required: boolean diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 71a5eb0c76..06701fa487 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -1,4 +1,8 @@ { + "api.agreement.analytics.description": "Помогнете за подобряването на Redis Insight, като споделяте анонимни данни за употреба. Това ни помага да разберем използването на функциите и да направим приложението по-добро. Активирайки това, се съгласявате с нашата ", + "api.agreement.analytics.label": "Данни за употреба", + "api.agreement.notifications.description": "Изберете, за да се показват известия. В противен случай известията се показват в Центъра за известия.", + "api.agreement.notifications.label": "Показвай известия", "api.error.code.11000.message": "Опитайте да рестартирате Redis Insight.", "api.error.code.11000.title": "Сървърна грешка", "api.error.code.11001.message": "Влезте отново, за да продължите работа с Redis Cloud.", @@ -44,6 +48,7 @@ "browser.array.delete.range.trigger": "Изтриване на диапазон", "browser.array.delete.row.message": "Този елемент ще бъде премахнат за постоянно от масива.", "browser.array.delete.row.title": "Изтриване на елемент", + "common.privacyPolicy": "Политика за поверителност", "notification.error.arrayBulkDeleteLimit.message": "Можете да изтриете най-много {{max}} елемента наведнъж. Изчистете част от селекцията и опитайте отново.", "notification.error.arrayBulkDeleteLimit.title": "Избрани са твърде много елементи", "notification.error.button.copied": "Копирано", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 8128845356..2e021b1563 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -1,4 +1,8 @@ { + "api.agreement.analytics.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 ", + "api.agreement.analytics.label": "Usage Data", + "api.agreement.notifications.description": "Select to display notifications. Otherwise, notifications are shown in the Notification Center.", + "api.agreement.notifications.label": "Show notifications", "api.error.code.11000.message": "Try restarting Redis Insight.", "api.error.code.11000.title": "Server error", "api.error.code.11001.message": "Sign in again to continue working with Redis Cloud.", @@ -44,6 +48,7 @@ "browser.array.delete.range.trigger": "Delete range", "browser.array.delete.row.message": "This element will be permanently removed from the array.", "browser.array.delete.row.title": "Delete element", + "common.privacyPolicy": "Privacy Policy", "notification.error.arrayBulkDeleteLimit.message": "You can delete up to {{max}} elements at once. Clear some of the selection and try again.", "notification.error.arrayBulkDeleteLimit.title": "Too many elements selected", "notification.error.button.copied": "Copied", diff --git a/tests/e2e-playwright/pages/settings/SettingsPage.ts b/tests/e2e-playwright/pages/settings/SettingsPage.ts index 0892c21aa6..778e1aad1e 100644 --- a/tests/e2e-playwright/pages/settings/SettingsPage.ts +++ b/tests/e2e-playwright/pages/settings/SettingsPage.ts @@ -68,7 +68,7 @@ export class SettingsPage extends BasePage { this.themeDropdown = page.getByRole('combobox', { name: /color theme/i }); this.notificationSwitch = page .locator('div') - .filter({ hasText: /^Show notification$/ }) + .filter({ hasText: /^Show notifications$/ }) .locator('..') .getByRole('switch'); this.dateFormatRadioPreselected = page.getByRole('radio', { name: 'Pre-selected formats' });